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..2abac935 100644 --- a/FSD/CSD/CSD-005-people.md +++ b/FSD/CSD/CSD-005-people.md @@ -1,11 +1,11 @@ # 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 -stage: building +stage: testable owner: CIRISClient ``` @@ -219,7 +219,7 @@ again. On a fresh node with no contacts → `card_contacts_add` and no ## 5. QA plan -Spec complete and flow written (`testing/flows/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. +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-005-people.yaml`, floor `>=0.5.225`, `fixture: two_node`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **11/12 passed, 1 skipped** — the list, the seeded row with its trust chip and hamburger, the five-fact receipt and its close, the empty search and its clearing, the add card, the node-code refusal by name, and the code card from the header; `a_scan_is_offered_where_there_is_a_camera` skipped as designed (desktop has no `btn_scan_contact_code`). The matrix run of the same day (36588619656) failed this flow on every desktop leg for csd-092's open contact-code card, which now closes itself (`cleanup:`), and its fixture waited only for the peer's owner key, not the binding (`reachable_nodes`, CIRISServer#699) — both fixed. Not yet run on the other four legs. On the second matrix run (36600766576) it passed 11/12 on Linux and macOS (the scan step skipped, no camera) and could not start on Windows (the fixture's console crash, fixed in the gate); its last steps left the add card open with a refusal in it, which on macOS's shorter window pushed the list below the fold for csd_006 — the flow's `cleanup:` now clears the refusal and closes the card (`when:` guards the header toggle). **Platforms.** All five. The Contacts entry screen is what CIRISAgent's five-platform gate leans on; no tag it drives has changed. diff --git a/FSD/CSD/CSD-006-receipt.md b/FSD/CSD/CSD-006-receipt.md index 4e68132d..e0baecbb 100644 --- a/FSD/CSD/CSD-006-receipt.md +++ b/FSD/CSD/CSD-006-receipt.md @@ -1,10 +1,10 @@ # 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 +stage: testable owner: CIRISClient ``` @@ -143,7 +143,7 @@ Bound per surface; CSD-005 §4 is the first instance. ## 5. QA plan -Spec complete and flow written (`testing/flows/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. +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-006-receipt.yaml`, floor `>=0.5.225`, `fixture: two_node`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. The two-node fixture (`testing/gate/two_node.py`) seeds a contact, and the flow opens `btn_receipt_${PEER_KEY_ID}` and asserts all five facts, the wire dimension and the wire rule (`chat:`). Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/6 passed, 1 skipped** — the seeded row's hamburger, all five envelope rows, the wire dimension, the rule row off the wire (`chat:` among the grant's prefixes) and the close; the grant-less step skipped as designed (the node sends the grant). On the matrix run of the same day (36588619656) every desktop leg failed this flow for csd-092's open contact-code card, since fixed (`cleanup:`). Not yet run on the other four legs. On the second matrix run (36600766576) it passed 6/6 on Linux and failed on macOS at its first step: `contacts_row_${PEER_KEY_ID}` was composed but below the fold, under the add card csd_005 had left open with a refusal, on a screen the harness cannot scroll (People's list is a LazyColumn with no `testableVerticalScroll`). csd_005's `cleanup:` now closes the card, and the runner says "composed but off screen after scrolling" with what `/scroll` answered, rather than "not on screen". A card CSD that binds this template asserts `visible:` on all five `receipt_*` tags after clicking its `btn_receipt_`; a card whose rows are furniture diff --git a/FSD/CSD/CSD-008-notes-to-self.md b/FSD/CSD/CSD-008-notes-to-self.md index 96adf362..5ae87940 100644 --- a/FSD/CSD/CSD-008-notes-to-self.md +++ b/FSD/CSD/CSD-008-notes-to-self.md @@ -2,10 +2,10 @@ **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-107 (Where is this file — each note's receipt opens it) · 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 +stage: testable owner: CIRISClient ``` @@ -88,7 +88,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). +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-008-notes-to-self.yaml`, floor `>=0.5.225`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **2/2 passed**. On the matrix run of the same day (36588619656) it passed on Linux and could not start on macOS — `circle_agent -> tab_chats` landed on Rooms because the tab was clicked before the circle hop had landed (a node client signs in under Neighbours); the runner now verifies each hop against `/state`. Not yet run on the other four legs since. **Verified live** (desktop, scratch ciris-server 0.5.215, 2026-09-24): writing a note from the UI and reading it back from `/v1/notes`; a readable note diff --git a/FSD/CSD/CSD-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..461cbd3f 100644 --- a/FSD/CSD/CSD-047-network-content.md +++ b/FSD/CSD/CSD-047-network-content.md @@ -3,10 +3,10 @@ **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 +stage: testable owner: CIRISClient ``` @@ -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. +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-047-network-content.yaml`, floor `>=0.5.225`, `fixture: two_node`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. It enters from the hub's `tile_federation_content` and picks `peer_pick_row_${PEER_NODE_KEY_ID}`, the peer the fixture admitted. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **4/4 passed** — the Content tile (below the fold; the runner now scrolls to an off-screen control), the peer search, the fixture's peer row, the digest step and its refusal of a bad digest. On the matrix run of the same day (36588619656) it could not start on any desktop leg: the tab was clicked before the circle hop had landed, so Everyone › Rules was never shown; fixed in the runner. A real fetch still needs a digest the peer holds, which the fixture does not seed. **Platforms.** All five, as the node's owner. A real fetch needs a second node holding a known digest; the matrix stands one up. diff --git a/FSD/CSD/CSD-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..4d5bd000 100644 --- a/FSD/CSD/CSD-057-wallet.md +++ b/FSD/CSD/CSD-057-wallet.md @@ -1,10 +1,10 @@ # 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 +stage: testable owner: CIRISClient ``` @@ -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). +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-057-wallet.yaml`, floor `>=0.5.224`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **3/6 passed, 3 skipped** — the experimental notice before any number, the paymaster and the limits, and back to the tab; the address, the transfer form and its inputs skipped as designed (a node build synthesises a wallet with no address). On the matrix run of the same day (36588619656) it could not start on any desktop leg (the tab was clicked before the circle hop landed; fixed in the runner), and the page's own `btn_wallet_back` is not drawn under the shell — the flow presses the shell's `btn_nav_back` (§4 step 6). **Platforms.** All five, agent build. Plus a node build for the `wallet_unsupported` state in §2 once it exists. diff --git a/FSD/CSD/CSD-068-provision-accord-holder.md b/FSD/CSD/CSD-068-provision-accord-holder.md index 0c15739a..886ceda6 100644 --- a/FSD/CSD/CSD-068-provision-accord-holder.md +++ b/FSD/CSD/CSD-068-provision-accord-holder.md @@ -1,10 +1,10 @@ # 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 +stage: testable owner: CIRISClient ``` @@ -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). +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/5 passed** — the form with no banner, the disabled submit doing nothing, the FIPS acknowledgement and the two fields taking input, the no-token refusal landing on `provision_holder_error` and not on success, and back. Reaching the screen found two client defects the same day: the three fields had no input sinks (`/input` had nothing to apply to), and the empty state's tag `txt_provision_holder_start` was drawn in every state, so a refused submit showed error and empty at once — both fixed. On the matrix run (36588619656) it could not start on any desktop leg (the circle-hop race, fixed in the runner). On the second (36600766576) Linux and macOS passed 5/5 and Windows failed `fips_and_a_path` with `provision_holder_error` already on screen — a third client defect: the submit's `testableClickable` did not carry the Button's `enabled`, so the disabled-submit step's `/click` ran `provision()` and raised the "confirm your YubiKey" banner (CIRISClient#69's shape). Linux and macOS had passed that step only because the banner composed above the fold the runner had scrolled past, where the geometry-based `absent:` could not see it. Fixed; `testing/test_platform_automation_wiring.py` now pins the mirror for every tag a flow clicks. On the third (36733112700) every desktop leg passed; iOS failed `empty_submit_does_nothing` because the now-disabled submit refused `/click` (404 "No click handler") and a plain `click:` counts a refusal as a failure. The step now says `click_refused:`, which holds when the click is refused and fails if a handler runs, and every platform answers a disabled control the same way (409, "is disabled"). **Platforms.** Desktop and Android in practice — the flow needs a USB path and a physical token, and the iOS/browser corners have neither. The screen composes on diff --git a/FSD/CSD/CSD-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 7282ab46..265939a0 100644 --- a/FSD/CSD/CSD-091-user-chat.md +++ b/FSD/CSD/CSD-091-user-chat.md @@ -295,7 +295,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..8eaf5e54 100644 --- a/FSD/CSD/CSD-092-share-contact-code.md +++ b/FSD/CSD/CSD-092-share-contact-code.md @@ -2,11 +2,11 @@ **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 -stage: building +stage: testable owner: CIRISClient ``` @@ -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). +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-092-share-contact-code.yaml`, floor `>=0.5.225`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. The tags are the client's at 0.5.225; the route is ciris-server 0.5.218's, so on an older node the flow drives the version fact and skips the populated, empty and refusal states. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **3/7 passed, 4 skipped** — the card opens, names the version it needs ("0.5.218 or newer") and closes; the four 0.5.218 states skipped as designed. On the matrix run of the same day (36588619656) the version step failed on every desktop leg: the client drew the sentence as the error's body and `StateBlock` registered its tag with the title alone, so the tree could not show it — fixed in the client (the tag now carries body and detail). The flow also closes its card whatever its verdict (`cleanup:`), because left open it replaced People's body for the three flows after it. **Platforms.** All five for the card. The copy → paste → contact round trip needs two nodes and runs on desktop. diff --git a/FSD/CSD/CSD-100-household.md b/FSD/CSD/CSD-100-household.md index c7bc5fd0..be68b68e 100644 --- a/FSD/CSD/CSD-100-household.md +++ b/FSD/CSD/CSD-100-household.md @@ -337,7 +337,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 adfc878d..50856f90 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 @@ -182,7 +182,7 @@ someone" with the invitee's pending row, is **CSD-106** (`envisioned`); ## 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. @@ -199,7 +199,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`); the flow passed on all five legs in the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217), but the CSD stays at `building`: `x_private:membership_invitation` (the consent-to-join ruling, CSD-106) is `blocked_by: CIRISPersist#955`, and `testable` admits no unconfirmed field. The matrix's node has no household, so the first step accepts the roster's empty shape and the populated roster is gated on `household_members_list`. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **2/4 passed, 2 skipped** — the roster composes in its empty shape and points to the hub; the populated roster and the add card skipped as designed (no household on the bare node). On the matrix run of the same day (36588619656) it could not start on any desktop leg (the tab was clicked before the circle hop landed; fixed in the runner). **Platforms.** All five. diff --git a/client/VENDORING.md b/client/VENDORING.md index 97083f4b..8379a989 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:** `af8dc0c3ac546de670947e9eb22f7218ec51ec37f872b0a494cb19f87655f243` +**state digest:** `ac46b34d2ff8b2f07e78eb0b4626e7501e3fae082b6e47456f0c23665fedea73` `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..ff46d37d 100644 --- a/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt +++ b/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt @@ -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, )) } @@ -405,6 +407,25 @@ class TestAutomationServer( return@post } + // DISABLED: REFUSED, NOT COORDINATE-CLICKED. A disabled + // `testableClickable` has no handler by design, and the + // fallback below used to click the greyed-out button and + // answer success — while iOS and Android answered "No click + // handler" for the same control (run 36733112700). All three + // now say what it is (ai.ciris.mobile.shared.platform.DisabledControls). + if (ai.ciris.mobile.shared.platform.DisabledControls.isDisabled(request.testTag)) { + call.respond( + HttpStatusCode.Conflict, + ActionResponse( + success = false, + element = request.testTag, + action = ai.ciris.mobile.shared.platform.DisabledControls.REFUSED_ACTION, + error = ai.ciris.mobile.shared.platform.DisabledControls.refusal(request.testTag), + ) + ) + return@post + } + if (element == null) { call.respond( HttpStatusCode.NotFound, diff --git a/client/shared/src/androidMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.android.kt b/client/shared/src/androidMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.android.kt index 3d131da8..aebdd56a 100644 --- a/client/shared/src/androidMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.android.kt +++ b/client/shared/src/androidMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.android.kt @@ -85,8 +85,15 @@ class AndroidTestAutomationServer(private val port: Int = 9091) { post("/click") { val request = call.receive() val resp = TestAutomationHandler.handleClick(request) + // 409 for a disabled control: it exists and refuses, which + // is not "not found" (desktop and iOS answer the same). call.respond( - if (resp.success) HttpStatusCode.OK else HttpStatusCode.NotFound, + when { + resp.success -> HttpStatusCode.OK + resp.action == ai.ciris.mobile.shared.platform.DisabledControls.REFUSED_ACTION -> + HttpStatusCode.Conflict + else -> HttpStatusCode.NotFound + }, resp ) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt index 94080330..33f28a9b 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 @@ -5148,6 +5148,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 @@ -5378,9 +5387,32 @@ private suspend fun checkFirstRunStatus( // waited for rather than declared unreachable. if (ActiveBackend.endpoint == NODE_ONLY_ENDPOINT) { if (isNodeReachable(nodeUrl)) { + // ANSWERING IS NOT OWNED. This branch used to return + // "setup complete" the moment the node answered, which is + // right after the run-without-AI hand-off (#48: that node + // was claimed before the agent left) and wrong for a node + // nobody has claimed yet: the Android leg of the + // five-platform gate (run 36600766576) met a fresh + // ciris-server, was told it was configured, rendered Login + // with isFirstRun=false, and "Local login" opened a + // password form for an owner that did not exist. Desktop + // never saw it only because its ActiveBackend was still + // the agent pin at this point, so it took the + // /v1/setup/status + probeNodeOwnership path below and got + // FRESH. Ask the node the same two questions here. + val ownership = probeNodeOwnership(nodeUrl) + if (ownership == NodeOwnership.FRESH) { + platformLog( + "checkFirstRunStatus", + "[INFO] backend is the node on :${ActiveBackend.endpoint.port} and it answers, " + + "but it has no owner (FRESH) — first run", + ) + return true + } platformLog( "checkFirstRunStatus", - "[INFO] backend is the node on :${ActiveBackend.endpoint.port} and it answers — setup complete", + "[INFO] backend is the node on :${ActiveBackend.endpoint.port} and it answers, " + + "$ownership — setup complete", ) return false } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt index fe9f3993..8e476195 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt @@ -12,7 +12,9 @@ import androidx.compose.ui.composed import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update /** * Data class for pending text input requests. @@ -231,8 +233,8 @@ fun Modifier.testable(tag: String, text: String? = null): Modifier = composed { * Register [tag]'s programmatic click handler only while the control is * [enabled]; a disabled control has none. * - * With no handler `/click` answers "No click handler" and `/tree` reports - * `canClick: false` — which is what a disabled control IS. The handler used to + * With no handler `/tree` reports `canClick: false` — which is what a disabled + * control IS — and `/click` answers that it is disabled ([DisabledControls]). The handler used to * be registered unconditionally, so `/click btn_next` ran the wizard's final * step while Next was greyed out for a step already in flight (CIRISClient#69): * a robot could do what no person could. @@ -240,6 +242,39 @@ fun Modifier.testable(tag: String, text: String? = null): Modifier = composed { internal fun bindClickHandler(tag: String, enabled: Boolean, handler: () -> Unit) { if (enabled) TestAutomation.registerClickHandler(tag, handler) else TestAutomation.unregisterClickHandler(tag) + DisabledControls.mark(tag, disabled = !enabled) +} + +/** The control is gone (or its `enabled` is about to change): no handler, and no longer "disabled". */ +internal fun releaseClickHandler(tag: String) { + TestAutomation.unregisterClickHandler(tag) + DisabledControls.mark(tag, disabled = false) +} + +/** + * The controls composed right now with `enabled = false` — so `/click` can say + * "disabled" instead of "no click handler", on every platform, in one sentence. + * + * iOS, run 36733112700: a disabled Provision submit answered `/click` with 404 + * "No click handler", which reads as a wiring defect, while desktop fell back + * to a coordinate click on the greyed-out button and answered success. Three + * platforms, two answers, neither of them "disabled". A disabled control is + * a known state of a known control, and the answer now names it (HTTP 409, + * `action = "click-refused"`). + */ +object DisabledControls { + /** The `action` a refused click reports; the servers map it to HTTP 409. */ + const val REFUSED_ACTION = "click-refused" + + private val tags = MutableStateFlow>(emptySet()) + + fun mark(tag: String, disabled: Boolean) = + tags.update { if (disabled) it + tag else it - tag } + + fun isDisabled(tag: String): Boolean = tag in tags.value + + fun refusal(tag: String): String = + "$tag is disabled: it refuses /click, as it refuses a press, until the screen enables it" } /** @@ -266,13 +301,13 @@ fun Modifier.testableClickable( val currentOnClick by rememberUpdatedState(onClick) DisposableEffect(tag) { onDispose { - TestAutomation.unregisterClickHandler(tag) + releaseClickHandler(tag) TestAutomation.unregisterElement(tag) } } DisposableEffect(tag, enabled) { bindClickHandler(tag, enabled) { currentOnClick() } - onDispose { TestAutomation.unregisterClickHandler(tag) } + onDispose { releaseClickHandler(tag) } } this.testTag(tag).clickable(enabled = enabled) { onClick() }.trackPosition(tag, text) } @@ -281,18 +316,31 @@ fun Modifier.testableClickable( * Track an element and register a handler WITHOUT adding `clickable`. * * For components that already handle their own clicks (Button, DropdownMenuItem) - * — adding another `clickable` would give them two. + * — adding another `clickable` would give them two. Pass the component's + * `enabled`, so a disabled one is disabled to `/click` too. */ -fun Modifier.testableWithHandler(tag: String, onClick: () -> Unit): Modifier = composed { +fun Modifier.testableWithHandler( + tag: String, + enabled: Boolean = true, + onClick: () -> Unit, +): Modifier = composed { if (!TestAutomation.isEnabled()) return@composed this.testTag(tag) val currentOnClick by rememberUpdatedState(onClick) DisposableEffect(tag) { - TestAutomation.registerClickHandler(tag) { currentOnClick() } onDispose { - TestAutomation.unregisterClickHandler(tag) + releaseClickHandler(tag) TestAutomation.unregisterElement(tag) } } + // [enabled] is the component's own `enabled`, bound as in + // [testableClickable]. A guard INSIDE the handler (`{ if (enabled) … }`) + // answered /click with success while doing nothing: iOS, run 36733112700, + // csd_005 clicked Add in the frame before the typed key enabled it, and + // nothing was sent and nothing said so. + DisposableEffect(tag, enabled) { + bindClickHandler(tag, enabled) { currentOnClick() } + onDispose { releaseClickHandler(tag) } + } this.testTag(tag).trackPosition(tag, null) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt index 7dab5d2f..41140749 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt @@ -125,6 +125,8 @@ object TestAutomationHandler { testMode = true, clientMode = TestAutomationState.clientMode, nodeUrl = TestAutomationState.nodeUrl, + circle = TestAutomationState.circle, + tab = TestAutomationState.tab, ) fun handleScreen(): ScreenResponse { @@ -205,6 +207,18 @@ object TestAutomationHandler { ) } + // DISABLED IS AN ANSWER, NOT A MISSING HANDLER. A `testableClickable` + // with `enabled = false` has no handler by design (CIRISClient#69); + // say so, the same way on every platform (HTTP 409 via the action). + if (ai.ciris.mobile.shared.platform.DisabledControls.isDisabled(request.testTag)) { + return ActionResponse( + success = false, + element = request.testTag, + action = ai.ciris.mobile.shared.platform.DisabledControls.REFUSED_ACTION, + error = ai.ciris.mobile.shared.platform.DisabledControls.refusal(request.testTag), + ) + } + if (element == null) { return ActionResponse( success = false, diff --git a/client/shared/src/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/components/FailurePanel.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanel.kt index 436c7cdd..b260f303 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanel.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanel.kt @@ -130,27 +130,33 @@ fun FailurePanel( fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.testable("failure_panel_title"), + // THE TEXTS ARE REGISTERED, NOT ONLY DRAWN. `testable(tag)` puts a + // tag on /tree with no text unless it is handed one, so a driver saw + // this panel and could not read it: Android, run 36746575125, the + // session fixture reported "(no reason on screen)" over a panel + // that said "claim PIN not captured". FailurePanelAutomationTextTest. + modifier = Modifier.testable("failure_panel_title", text = title), ) Spacer(Modifier.height(10.dp)) + val guidance = when (kind) { + FailureKind.Timeout -> + "This is taking longer than expected. It may still finish — you can keep " + + "waiting, or restart the app. If it never completes, please open an " + + "issue so we can fix it." + FailureKind.Recoverable -> + "This didn't work just now, but it should work shortly — nothing is " + + "broken and nothing needs reinstalling. Try again in a moment. If it " + + "keeps failing, please open an issue so we can fix it." + FailureKind.Unrecoverable -> + "We're sorry — this error is not recoverable by retrying. Please open an " + + "issue on GitHub so we can fix it, or wipe and reinstall the app to " + + "start over." + } Text( - text = when (kind) { - FailureKind.Timeout -> - "This is taking longer than expected. It may still finish — you can keep " + - "waiting, or restart the app. If it never completes, please open an " + - "issue so we can fix it." - FailureKind.Recoverable -> - "This didn't work just now, but it should work shortly — nothing is " + - "broken and nothing needs reinstalling. Try again in a moment. If it " + - "keeps failing, please open an issue so we can fix it." - FailureKind.Unrecoverable -> - "We're sorry — this error is not recoverable by retrying. Please open an " + - "issue on GitHub so we can fix it, or wipe and reinstall the app to " + - "start over." - }, + text = guidance, fontSize = 15.sp, color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.testable("failure_panel_guidance"), + modifier = Modifier.testable("failure_panel_guidance", text = guidance), ) Spacer(Modifier.height(14.dp)) @@ -183,7 +189,7 @@ fun FailurePanel( modifier = Modifier .padding(8.dp) .testableVerticalScroll() - .testable("failure_panel_detail"), + .testable("failure_panel_detail", text = detail), ) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ConfirmSheet.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ConfirmSheet.kt index 829e5da8..f032d5f6 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ConfirmSheet.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ConfirmSheet.kt @@ -2,6 +2,7 @@ package ai.ciris.mobile.shared.ui.primitives import ai.ciris.mobile.shared.localization.localizedString import ai.ciris.mobile.shared.platform.testable +import ai.ciris.mobile.shared.platform.testableVerticalScroll import ai.ciris.mobile.shared.ui.theme.CirisShape import ai.ciris.mobile.shared.ui.theme.CirisTheme import androidx.compose.foundation.layout.Arrangement @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -70,18 +72,29 @@ fun ConfirmSheet( tonalElevation = 0.dp, dragHandle = null, ) { + // THE BUTTONS ARE PINNED, THE FACTS SCROLL. With no scroll at all, a + // confirm taller than a small phone squeezed its third fact and its + // note to nothing: the buttons stayed, the facts the person is meant to + // read before confirming did not, and nothing could bring them back. + // Same shape as the receipt's hidden Close (matrix run 36752849889). Column( modifier = Modifier.fillMaxWidth().testable("sheet_$tagPrefix", title) + .statusBarsPadding() .padding(horizontal = 18.dp, vertical = 14.dp).navigationBarsPadding(), ) { Text(title, style = type.title, color = t.ink) Spacer(Modifier.height(8.dp)) - checked.forEachIndexed { i, f -> - FieldRow(label = f.label, value = f.value, mono = f.mono, tag = "${tagPrefix}_fact_${i + 1}", divider = i < 2) - } - if (note != null) { - Spacer(Modifier.height(10.dp)) - Text(note, style = type.body, color = t.danger, modifier = Modifier.testable("${tagPrefix}_note", note)) + Column( + modifier = Modifier.fillMaxWidth().weight(1f, fill = false) + .testableVerticalScroll(name = "sheet_$tagPrefix"), + ) { + checked.forEachIndexed { i, f -> + FieldRow(label = f.label, value = f.value, mono = f.mono, tag = "${tagPrefix}_fact_${i + 1}", divider = i < 2) + } + if (note != null) { + Spacer(Modifier.height(10.dp)) + Text(note, style = type.body, color = t.danger, modifier = Modifier.testable("${tagPrefix}_note", note)) + } } Spacer(Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = androidx.compose.ui.Alignment.End)) { diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/Controls.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/Controls.kt index 48ecc959..022ab390 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/Controls.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/Controls.kt @@ -51,7 +51,7 @@ fun CirisButton( disabledContentColor = t.mute, ), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), - modifier = modifier.testableWithHandler(tag) { if (enabled) onClick() }, + modifier = modifier.testableWithHandler(tag, enabled = enabled) { if (enabled) onClick() }, ) { Text(label, style = CirisTheme.type.body) } @@ -75,7 +75,7 @@ fun CirisTextButton( contentColor = if (danger) t.danger else t.brand, disabledContentColor = t.mute, ), - modifier = modifier.testableWithHandler(tag) { if (enabled) onClick() }, + modifier = modifier.testableWithHandler(tag, enabled = enabled) { if (enabled) onClick() }, ) { Text(label, style = CirisTheme.type.body) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ReceiptSheet.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ReceiptSheet.kt index 45f93870..2a737703 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ReceiptSheet.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ReceiptSheet.kt @@ -6,6 +6,7 @@ import ai.ciris.mobile.shared.ceg.cohortScopeOf import ai.ciris.mobile.shared.ceg.shortKey import ai.ciris.mobile.shared.localization.localizedString import ai.ciris.mobile.shared.platform.testable +import ai.ciris.mobile.shared.platform.testableVerticalScroll import ai.ciris.mobile.shared.ui.glyphs.Glyph import ai.ciris.mobile.shared.ui.glyphs.GlyphName import ai.ciris.mobile.shared.ui.theme.CirisShape @@ -19,8 +20,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -60,61 +60,79 @@ fun ReceiptSheet( tonalElevation = 0.dp, dragHandle = null, ) { + // THE WAY OUT IS IN THE HEADER, AND ONLY THE BODY SCROLLS. Close used + // to sit at the end of the sheet's own scroll: on a phone-height + // Android emulator it was below the fold, and that scroll was a plain + // verticalScroll automation could not drive, so /scroll moved the list + // BEHIND the sheet and /click btn_receipt_close answered 404 "composed + // but off screen" (matrix run 36752849889, CSD-005/006). A person on a + // small phone had the same problem with a thumb. Column( modifier = Modifier .fillMaxWidth() .testable(tag, receipt.id) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 18.dp, vertical = 14.dp) + // A full-height sheet runs under the status bar; the header + // (and Close in it) must not sit beneath the system icons. + .statusBarsPadding() .navigationBarsPadding(), - verticalArrangement = Arrangement.spacedBy(2.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 18.dp, end = 8.dp, top = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { Glyph(GlyphName.RECEIPT, tint = t.brand, size = 18.dp) - Text(localizedString("mobile.receipt_title"), style = type.title, color = t.ink) + Text( + localizedString("mobile.receipt_title"), style = type.title, color = t.ink, + modifier = Modifier.weight(1f), + ) + CirisTextButton(localizedString("mobile.receipt_close"), tag = "btn_receipt_close", onClick = onDismiss) } - Spacer(Modifier.height(6.dp)) - - FactRow(Envelope.subjectKeyIds, receipt.subject, "receipt_subject", keyish = true) - FactRow(Envelope.attestingKeyId, receipt.attester, "receipt_attester", keyish = true) - ScopeRow(receipt.scope, receipt.scopeNote) - FactRow(Envelope.dimension, receipt.dimensionValue, "receipt_dimension", keyish = false) - FactRow(Envelope.consentScope, receipt.rule, "receipt_rule", keyish = false, divider = receipt.forAgent != null) - receipt.forAgent?.let { FactRow(ForAgentMember, it, "receipt_for_agent", keyish = true, divider = false) } + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .testableVerticalScroll(name = tag) + .padding(start = 18.dp, end = 18.dp, top = 2.dp, bottom = 14.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + FactRow(Envelope.subjectKeyIds, receipt.subject, "receipt_subject", keyish = true) + FactRow(Envelope.attestingKeyId, receipt.attester, "receipt_attester", keyish = true) + ScopeRow(receipt.scope, receipt.scopeNote) + FactRow(Envelope.dimension, receipt.dimensionValue, "receipt_dimension", keyish = false) + FactRow(Envelope.consentScope, receipt.rule, "receipt_rule", keyish = false, divider = receipt.forAgent != null) + receipt.forAgent?.let { FactRow(ForAgentMember, it, "receipt_for_agent", keyish = true, divider = false) } - Spacer(Modifier.height(10.dp)) - FieldRow( - label = localizedString("mobile.receipt_holders"), - value = receipt.holders?.let { localizedString("mobile.receipt_holders_count", "count", it.toString()) } - ?: localizedString("ceg.envelope.not_sent"), - tone = if (receipt.holders == null) Tone.DANGER else Tone.INK, - mono = receipt.holders != null, - tag = "receipt_holders", - ) - FieldRow( - label = localizedString("mobile.receipt_notes"), - value = if (receipt.notes.isEmpty()) localizedString("mobile.receipt_notes_none") - else receipt.notes.joinToString("\n") { "${it.by}: ${it.text}" }, - tone = if (receipt.notes.isEmpty()) Tone.DIM else Tone.INK, - tag = "receipt_notes", - divider = receipt.acts.isNotEmpty(), - ) - if (receipt.acts.isNotEmpty()) { - Text( - localizedString("mobile.receipt_acts").uppercase(), - style = type.label, color = t.mute, - modifier = Modifier.padding(top = 10.dp, bottom = 6.dp), + Spacer(Modifier.height(10.dp)) + FieldRow( + label = localizedString("mobile.receipt_holders"), + value = receipt.holders?.let { localizedString("mobile.receipt_holders_count", "count", it.toString()) } + ?: localizedString("ceg.envelope.not_sent"), + tone = if (receipt.holders == null) Tone.DANGER else Tone.INK, + mono = receipt.holders != null, + tag = "receipt_holders", + ) + FieldRow( + label = localizedString("mobile.receipt_notes"), + value = if (receipt.notes.isEmpty()) localizedString("mobile.receipt_notes_none") + else receipt.notes.joinToString("\n") { "${it.by}: ${it.text}" }, + tone = if (receipt.notes.isEmpty()) Tone.DIM else Tone.INK, + tag = "receipt_notes", + divider = receipt.acts.isNotEmpty(), ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - for (act in receipt.acts) { - Chip(ChipSpec(label = act.label, tag = act.tag, kind = ChipKind.CHOICE, tone = Tone.BRAND, onClick = act.onAct)) + if (receipt.acts.isNotEmpty()) { + Text( + localizedString("mobile.receipt_acts").uppercase(), + style = type.label, color = t.mute, + modifier = Modifier.padding(top = 10.dp, bottom = 6.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (act in receipt.acts) { + Chip(ChipSpec(label = act.label, tag = act.tag, kind = ChipKind.CHOICE, tone = Tone.BRAND, onClick = act.onAct)) + } } } } - Spacer(Modifier.height(12.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - CirisTextButton(localizedString("mobile.receipt_close"), tag = "btn_receipt_close", onClick = onDismiss) - } } } } diff --git a/client/shared/src/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/ChatScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ChatScreen.kt index 705aec12..12708359 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ChatScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ChatScreen.kt @@ -204,7 +204,7 @@ fun ChatScreen( // finish first, letting the OLDER response overwrite the // newer transcript. The handler re-checks what enabled // gates. - modifier = Modifier.testableWithHandler("btn_chat_refresh") { + modifier = Modifier.testableWithHandler("btn_chat_refresh", enabled = !loading) { if (!loading) viewModel.refresh() }, ) { @@ -341,7 +341,7 @@ fun ChatScreen( Button( onClick = { viewModel.send() }, enabled = canSend, - modifier = Modifier.testableWithHandler("btn_chat_send") { + modifier = Modifier.testableWithHandler("btn_chat_send", enabled = canSend) { if (canSend) viewModel.send() }, ) { diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ContactsScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ContactsScreen.kt index fba82ca9..47a5234a 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ContactsScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ContactsScreen.kt @@ -201,7 +201,7 @@ fun ContactsScreen( IconButton( onClick = { viewModel.refresh() }, enabled = !loading, - modifier = Modifier.testableWithHandler(PeopleTags.REFRESH) { if (!loading) viewModel.refresh() }, + modifier = Modifier.testableWithHandler(PeopleTags.REFRESH, enabled = !loading) { if (!loading) viewModel.refresh() }, ) { Glyph(GlyphName.REFRESH, tint = if (loading) t.mute else t.dim, contentDescription = localizedString("common_refresh")) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt index 539e6cc7..950496ed 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt @@ -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 @@ -307,7 +336,7 @@ fun ProvisionAccordHolderScreen( TextButton( onClick = { if (!busy) showDirPicker = true }, enabled = !busy, - modifier = Modifier.testableClickable("btn_provision_holder_usb_browse") { + modifier = Modifier.testableClickable("btn_provision_holder_usb_browse", enabled = !busy) { if (!busy) showDirPicker = true }, ) { @@ -352,10 +381,17 @@ fun ProvisionAccordHolderScreen( // ── Step 3: Provision ────────────────────────────────────────────── Spacer(Modifier.height(24.dp)) val canProvision = fipsAck && keyId.isNotBlank() && usbPath.isNotBlank() && !busy + // `enabled` goes to the automation handler too (CIRISClient#69): + // without it `/click` ran provision() behind the greyed-out + // button and put the "confirm your YubiKey" banner on a form + // nobody had submitted (Windows leg, run 36600766576). Button( onClick = { viewModel.provision() }, enabled = canProvision, - modifier = Modifier.fillMaxWidth().testableClickable("btn_provision_holder_submit") { + modifier = Modifier.fillMaxWidth().testableClickable( + "btn_provision_holder_submit", + enabled = canProvision, + ) { viewModel.provision() }, ) { diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SettingsScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SettingsScreen.kt index 430ff99b..c8dc3b19 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SettingsScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SettingsScreen.kt @@ -2183,7 +2183,7 @@ private fun PreferencesSection() { ) } }, - modifier = Modifier.testableWithHandler("language_${language.code}", onSelectLanguage) + modifier = Modifier.testableWithHandler("language_${language.code}", onClick = onSelectLanguage) ) } } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SetupScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SetupScreen.kt index 287e0716..3ac8bfe8 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SetupScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SetupScreen.kt @@ -3368,7 +3368,7 @@ private fun CompleteStep( }, context = "first-run claim", onRetry = if (ownershipClaim.errorRecoverable) onRetryClaim else null, - modifier = Modifier.testable("setup_ownership_error"), + modifier = Modifier.testable("setup_ownership_error", text = ownershipClaim.error), ) if (onFinishUnclaimed != null) { Spacer(modifier = Modifier.height(12.dp)) diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/TrustRootScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/TrustRootScreen.kt index eac71b93..a4945e29 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/TrustRootScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/TrustRootScreen.kt @@ -125,7 +125,7 @@ fun TrustRootScreen( IconButton( onClick = { viewModel.refresh() }, enabled = !busy, - modifier = Modifier.testableWithHandler("btn_trust_root_refresh") { if (!busy) viewModel.refresh() }, + modifier = Modifier.testableWithHandler("btn_trust_root_refresh", enabled = !busy) { if (!busy) viewModel.refresh() }, ) { Glyph(GlyphName.REFRESH, tint = if (busy) t.mute else t.dim, contentDescription = localizedString("common_refresh")) } diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/platform/DisabledClickHandlerTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/platform/DisabledClickHandlerTest.kt index 383ec5a6..5048830e 100644 --- a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/platform/DisabledClickHandlerTest.kt +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/platform/DisabledClickHandlerTest.kt @@ -21,7 +21,20 @@ class DisabledClickHandlerTest { private val tag = "btn_disabled_probe" @AfterTest - fun cleanup() = TestAutomation.unregisterClickHandler(tag) + fun cleanup() = releaseClickHandler(tag) + + @Test + fun aDisabledControlIsKnownAsDisabledUntilItIsEnabledOrGone() { + // So `/click` can answer "disabled" rather than "no click handler" + // (iOS, run 36733112700) — the same answer on every platform. + bindClickHandler(tag, enabled = false) {} + assertTrue(DisabledControls.isDisabled(tag)) + bindClickHandler(tag, enabled = true) {} + assertFalse(DisabledControls.isDisabled(tag), "an enabled control is not disabled") + bindClickHandler(tag, enabled = false) {} + releaseClickHandler(tag) + assertFalse(DisabledControls.isDisabled(tag), "a disposed control is not anything") + } @Test fun aDisabledControlCannotBeClicked() { diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt index 377769a4..ebaba107 100644 --- a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt @@ -1,5 +1,6 @@ package ai.ciris.mobile.shared.testing +import ai.ciris.mobile.shared.platform.DisabledControls import ai.ciris.mobile.shared.platform.TestAutomation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -64,6 +65,7 @@ class MobileAutomationSurfaceTest { TestAutomationState.clearElements() TestAutomation.unregisterInputSink(field) TestAutomation.unregisterClickHandler(btn) + DisabledControls.mark(btn, disabled = false) } // ── /input must not claim to have typed into nothing ────────────────── @@ -231,6 +233,25 @@ class MobileAutomationSurfaceTest { } } + @Test + fun a_click_on_a_disabled_control_says_disabled_not_no_handler() = runTest { + // iOS, run 36733112700: the disabled Provision submit answered 404 "No + // click handler", which reads as a wiring defect. It is a state. + val exec = Executors.newSingleThreadExecutor { r -> Thread(r, "ciris-main-probe") } + Dispatchers.setMain(exec.asCoroutineDispatcher()) + try { + register(btn) + DisabledControls.mark(btn, disabled = true) + val r = TestAutomationHandler.handleClick(ClickRequest(btn)) + assertFalse(r.success) + assertEquals(DisabledControls.REFUSED_ACTION, r.action, "the servers map this action to 409") + assertTrue((r.error ?: "").contains("is disabled"), r.error ?: "") + } finally { + Dispatchers.resetMain() + exec.shutdownNow() + } + } + // ── /undrivable, the pre-flight ─────────────────────────────────────── @Test @@ -280,4 +301,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/components/FailurePanelAutomationTextTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanelAutomationTextTest.kt new file mode 100644 index 00000000..834f2617 --- /dev/null +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanelAutomationTextTest.kt @@ -0,0 +1,68 @@ +package ai.ciris.mobile.shared.ui.components + +import ai.ciris.mobile.shared.platform.TestAutomation +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.use +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A failure a driver cannot read is a failure it reports as silence. + * + * Android, run 36746575125: the first-run claim failed with "claim PIN not + * captured", the panel said so on screen, and `/tree` carried the panel's tags + * with NO text — `testable(tag)` registers text only when it is handed one. The + * session fixture then reported "(no reason on screen)" beside a tag list that + * included `failure_panel_detail`, and the cause took a logcat dig to find. + * + * Rendered headless and read back through the same registration a harness sees. + */ +class FailurePanelAutomationTextTest { + + private val registered = mutableMapOf() + + private fun armAutomation() = TestAutomation.configure( + onRegister = { tag, _, _, _, _, text -> registered[tag] = text }, + onUnregister = { registered.remove(it) }, + onSetScreen = {}, + onClear = { registered.clear() }, + isEnabled = { true }, + ) + + @AfterTest + fun disarm() = TestAutomation.configure( + onRegister = { _, _, _, _, _, _ -> }, + onUnregister = {}, + onSetScreen = {}, + onClear = {}, + isEnabled = { false }, + ) + + /** What was registered while the panel was on screen — read BEFORE the + * scene closes, because closing disposes every element (as it should). */ + private fun render(title: String, detail: String, kind: FailureKind): Map { + armAutomation() + return ImageComposeScene(width = 900, height = 1600) { + FailurePanel(title = title, detail = detail, kind = kind, context = "first-run claim") + }.use { + it.render() + it.render() // onGloballyPositioned lands after the first layout pass + registered.toMap() + } + } + + @Test + fun the_panel_registers_its_title_and_detail_as_text() { + val detail = "claim PIN not captured — this node's one-time ownership PIN never reached the app" + val registered = render("This node could not be claimed", detail, FailureKind.Unrecoverable) + + assertTrue("failure_panel_detail" in registered, "the detail must be on /tree at all: $registered") + assertEquals(detail, registered["failure_panel_detail"], + "the verbatim reason must be readable by a driver, not only by eye") + assertEquals("This node could not be claimed", registered["failure_panel_title"]) + assertTrue(!registered["failure_panel_guidance"].isNullOrBlank(), + "the guidance names the kind (retry or report) and must be readable too") + } +} diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/SheetCloseOnPhoneTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/SheetCloseOnPhoneTest.kt new file mode 100644 index 00000000..daaaffbb --- /dev/null +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/SheetCloseOnPhoneTest.kt @@ -0,0 +1,150 @@ +package ai.ciris.mobile.shared.ui.primitives + +import ai.ciris.mobile.shared.ceg.Dim +import ai.ciris.mobile.shared.platform.TestAutomation +import ai.ciris.mobile.shared.testing.TestAutomationState +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import androidx.compose.ui.use +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * A sheet's way out must be on screen on a phone, without scrolling. + * + * Android, matrix run 36752849889 (CSD-005 and CSD-006): the receipt sheet + * filled the emulator, Close sat below the fold at the end of the sheet's own + * scroll, and that scroll was a plain `verticalScroll` automation could not + * drive — `/scroll` moved the People list BEHIND the sheet instead. The open + * sheet's drivable list carried every receipt row and no `btn_receipt_close`, + * and `/click` answered 404 "composed but off screen". + * + * Rendered headless at a small phone (360 x 640 dp) and read back through the + * same registration a harness sees: the exit's bounds must lie in the window. + */ +class SheetCloseOnPhoneTest { + + private data class Box(val x: Int, val y: Int, val w: Int, val h: Int) + + private val registered = mutableMapOf() + + private val density = 3f + private val widthPx = (360 * density).toInt() + private val heightPx = (640 * density).toInt() + + private fun armAutomation() = TestAutomation.configure( + onRegister = { tag, x, y, w, h, _ -> registered[tag] = Box(x, y, w, h) }, + onUnregister = { registered.remove(it) }, + onSetScreen = {}, + onClear = { registered.clear() }, + isEnabled = { true }, + ) + + @AfterTest + fun disarm() = TestAutomation.configure( + onRegister = { _, _, _, _, _, _ -> }, + onUnregister = {}, + onSetScreen = {}, + onClear = {}, + isEnabled = { false }, + ) + + /** + * Render, let the sheet finish animating in, and read what registered. With + * [scrollContainer], then post the same `/scroll` request a harness posts — + * through [TestAutomationState], naming the container — and read again: the + * sheet's BODY must be the thing that moves. + */ + private fun render( + scrollContainer: String? = null, + content: @androidx.compose.runtime.Composable () -> Unit, + ): Pair, Map?> { + armAutomation() + return ImageComposeScene(width = widthPx, height = heightPx, density = Density(density), content = content).use { + var t = 0L + fun frames(n: Int) = repeat(n) { _ -> it.render(t); t += 16_000_000L } + frames(120) // ~2 s: the sheet slides in + val before = registered.toMap() + val after = scrollContainer?.let { name -> + TestAutomationState.requestScroll(testTag = "", direction = "down", amount = 20_000, container = name) + frames(120) + registered.toMap() + } + before to after + } + } + + private fun assertOnScreen(tag: String, seen: Map) { + val b = assertNotNull(seen[tag], "$tag must be composed and registered at all: ${seen.keys}") + assertTrue( + b.w > 0 && b.h > 0 && b.x >= 0 && b.y >= 0 && b.x + b.w <= widthPx && b.y + b.h <= heightPx, + "$tag must lie within a 360x640 dp phone (${widthPx}x$heightPx px); it is at $b (a zero box is how the harness sees composed-but-off-screen)", + ) + } + + /** The receipt Android showed: five facts, a for-agent row, notes, two acts. */ + private fun phoneReceipt() = Receipt( + id = "ciris-gate-peer-9893b757-user-riv6mtomfo", + subject = Fact.Wire("ciris-gate-peer-9893b757-user-riv6mtomfo-kxcf"), + attester = Fact.Wire( + "gate-gate-peer-22es", + "The person who consented — signed with their own identity, not by this node.", + ), + scope = Fact.Wire("everyone"), + dimension = Dim.consentKind, + dimensionValue = Fact.Wire("consent:replication:v1"), + rule = Fact.Wire("capacity:, chat:, ownership:, self:delegates_to:, trace:"), + forAgent = Fact.Wire("ciris-server-agent-s37q"), + holders = null, + notes = listOf(ReceiptNote("peer", "Met at the community meeting; confirmed the code in person.")), + acts = listOf( + ReceiptAct("Open chat", "btn_receipt_act_chat", {}), + ReceiptAct("Remove", "btn_receipt_act_remove", {}), + ), + scopeNote = "The grant itself is a public record. What you send each other is not.", + ) + + @Test + fun the_receipt_close_is_on_screen_on_a_phone() { + val (seen, _) = render { ReceiptSheet(receipt = phoneReceipt(), onDismiss = {}) } + assertOnScreen("btn_receipt_close", seen) + } + + @Test + fun the_receipt_body_scrolls_under_automation_and_close_stays() { + val (_, scrolled) = render(scrollContainer = "sheet_receipt") { + ReceiptSheet(receipt = phoneReceipt(), onDismiss = {}) + } + val after = assertNotNull(scrolled) + assertOnScreen("btn_receipt_act_remove", after) // the last thing in the body + assertOnScreen("btn_receipt_close", after) + } + + @Test + fun the_confirm_buttons_stay_and_every_fact_is_reachable_on_a_phone() { + val long = List(12) { "A fact long enough to wrap across the width of a small phone, line $it." } + .joinToString(" ") + val (seen, scrolled) = render(scrollContainer = "sheet_confirm") { + ConfirmSheet( + title = "Share this with the whole community?", + facts = listOf( + ConfirmFact("What is shared", long), + ConfirmFact("Who can see it", long), + ConfirmFact("How to undo it", long), + ), + confirmLabel = "Share", + onConfirm = {}, + onDismiss = {}, + note = long, + ) + } + assertOnScreen("btn_confirm_cancel", seen) + assertOnScreen("btn_confirm_confirm", seen) + // The facts are what the person confirms; the last of them must be reachable. + val after = assertNotNull(scrolled) + assertOnScreen("confirm_note", after) + assertOnScreen("btn_confirm_confirm", after) + } +} 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/shared/src/iosMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.ios.kt b/client/shared/src/iosMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.ios.kt index e166d3bc..fb87a9a2 100644 --- a/client/shared/src/iosMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.ios.kt +++ b/client/shared/src/iosMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.ios.kt @@ -261,7 +261,14 @@ class IOSTestAutomationServer(private val port: Int = 9091) { method == "POST" && path == "/click" -> { val req = json.decodeFromString(body) val resp = TestAutomationHandler.handleClick(req) - (if (resp.success) 200 else 404) to json.encodeToString(resp) + // 409 for a disabled control: it exists and refuses, which + // is not "not found" (desktop and Android answer the same). + val status = when { + resp.success -> 200 + resp.action == ai.ciris.mobile.shared.platform.DisabledControls.REFUSED_ACTION -> 409 + else -> 404 + } + status to json.encodeToString(resp) } method == "POST" && path == "/input" -> { val req = json.decodeFromString(body) diff --git a/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/docs/ROADMAP.md b/docs/ROADMAP.md index fcec8d44..c7fe94d2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,11 +15,12 @@ and how far along each part is. The item IDs (F, S, N, B, G) are the execution p `build.yml` is green on the merge. It carries everything below. Server **0.5.217** is the latest server release; **0.5.218 is not cut**. Agent **2.12.1** shipped CIRISAgent#1213 (the node proxy forwards every `/v1` path the agent does not serve itself), and six CSDs say so (#128). -- **77 CSDs on `main`** (`FSD/CSD`): **71 building, 5 sketched, 1 envisioned, none `testable`**. - New since the last pass: CSD-007 Files and CSD-008 Notes to self (#77), CSD-105 This node's +- **79 CSDs** (`FSD/CSD`): **7 testable, 65 building, 5 sketched, 2 envisioned**. New since the + last pass: CSD-106 membership invitations (envisioned, #137) and CSD-107 Where is this file (#139). + Before that: CSD-007 Files and CSD-008 Notes to self (#77), CSD-105 This node's trust root (#126), CSD-100/101 household and members (#122). CSD-092 (share contact code) is now building. **Sketched:** 037 My Identity, 042 Help, 044 Health & Reputation, and 093/094 - (the second-device cards, which wait for server 0.5.218). **Envisioned:** 030 Telemetry. + (the second-device cards, which wait for server 0.5.218). **Envisioned:** 030 Telemetry, 106 membership invitations. - **Both review batches are merged.** #124 was runtime, identity, interact, setup, network, moderation. **#126** is batch 2: **accord / trust root** (the two unpushed trust-root branches folded in, a `TrustRoot` detail with posture, import, un-trust and family history, all at the @@ -42,20 +43,17 @@ and how far along each part is. The item IDs (F, S, N, B, G) are the execution p each owner a contact of the other. Both ran **all five legs green in one run**; `main`'s latest Five-Platform Live QA is green (2026-09-29). One flow is live (`testing/flows/people.yaml`, CSD-005); **37 drafts** wait under `testing/flows/drafts/`. -- **Nothing is `testable` yet, and #128 says exactly why.** `CSD.md` §1: a card reaches `testable` - when its flow's floor is a released version **and** the flow runs on the matrix. On `main`, - **21 CSDs open §5 with "Spec complete and flow written"** — 005, 006, 008, 032, 033, 036, 040, - 045, 046, 047, 048, 049, 057, 068, 069, 081, 090, 091, 092, 100, 101 — with floors of - `>=0.5.224` (5), `>=0.5.225` (3) and `unreleased` (13). **One says "Flow not complete"** (082: - its claim step asserts a transient state behind an LLM key). The other 49 at `building` fail a - trial promotion for a `proposed:` tag, an unconfirmed field, or no flow (003, 026, 070, 110). -- **Next: promote.** Tag 0.5.225, flip the 13 `unreleased` floors to it, and run the 21 flows on - the matrix; each that goes green on every leg is `testable`. The drafts stay staged for one - reason: the runner does not navigate and every draft starts somewhere other than where sign-in - lands, so promotion is also the `nav_map` hop. Two things are known to stop flows before the - client is at fault: **CIRISServer#698** (two released 0.5.217 nodes never key a pair room, so the - CSD-091 chat flow cannot cross a message; recorded in `evidence/blocked_upstream.tsv`) and the - drafts floored `>=0.5.225`, which the matrix refuses until that version exists. +- **Seven CSDs are `testable`** (#133): 005 People, 006 Receipt, 008 Notes to self, 047 Network + content, 057 Wallet, 068 Provision an accord holder, 092 Share contact code. Their flows passed on + all five legs in run 36775704425 (Linux, macOS, Windows desktop; Android emulator; iOS simulator) + against released floors `>=0.5.224`/`>=0.5.225`. CSD-101's flow passed too; it stays `building` + on `x_private:membership_invitation` (CIRISPersist#955). Getting there took seven matrix runs and + fixed real client bugs on the way: a stale-circle tab hop, a disabled button that swallowed + automation clicks, a receipt sheet whose Close was below the fold on a phone, and a claim PIN the + Android app could not reach. +- **Next: the 30 drafts** under `testing/flows/drafts/` — the same route: a released floor, a nav + hop, a green matrix run. **CIRISServer#698** still stops CSD-091's chat flow (two released 0.5.217 + nodes never key a pair room; recorded in `evidence/blocked_upstream.tsv`). ## Dependencies diff --git a/testing/driver.py b/testing/driver.py index 02b4e184..fd8bde43 100644 --- a/testing/driver.py +++ b/testing/driver.py @@ -208,8 +208,11 @@ def tags(self) -> set[str]: # ---- writes ------------------------------------------------------- - def click(self, test_tag: str) -> None: - self._call("POST", "/click", {"testTag": test_tag}) + def click(self, test_tag: str) -> Any: + """Click, and return the app's answer: `action` says whether a + programmatic handler ran ("click") or desktop fell back to a + coordinate click ("mouse-click"), which `click_refused` tells apart.""" + return self._call("POST", "/click", {"testTag": test_tag}) def input(self, test_tag: str, text: str, clear_first: bool = True, verify: bool = True) -> None: diff --git a/testing/flows/README.md b/testing/flows/README.md index ce19cb8a..47dfefa5 100644 --- a/testing/flows/README.md +++ b/testing/flows/README.md @@ -17,13 +17,17 @@ 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 + - click: btn_contacts_add_open + when: card_contacts_add # only while the card is open: the toggle would open it steps: - step_id: landing title: Signing in on a bare node lands on People requires: # checked BEFORE the step; the first step's is the entry screen: Contacts - do: # click / input / scroll_to / wait (one per entry) + do: # click / click_refused / input / scroll_to / wait (one per entry) - wait: card_contacts_add wait_ms: 5000 # a `wait` waits wait_ms × 4 expect: # checked AFTER @@ -40,7 +44,9 @@ steps: The language is `testing/gate/flow_spec.py`'s — vendored from CIRISAgent, with the CSD/3 §3 predicates (`count`, `number`, `matches`, `one_of`, `each`, -`relation`, `state`) and one local addition, `csd:` (see +`relation`, `state`) and local additions — `csd:`, `cleanup:` with `when:`, and +`click_refused:` for a control the step says is disabled, which holds when the +platform refuses the click and fails when a handler runs (see `testing/gate/VENDORED.md`). Unknown keys anywhere are a load error. ### How a flow is tied to its CSD @@ -66,6 +72,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 +168,45 @@ 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 cleanup + action may carry `when: ` and then runs only while that tag is on + screen: a control that toggles (People's `btn_contacts_add_open` opens the + add card and closes it) would otherwise open the card on a run that failed + before it got there. `when:` is for `cleanup:` only — a step's action that + quietly does nothing asserts nothing. +- **A `visible:` that fails says which of two things went wrong.** "Not + composed (not in /tree)" is the client's: the tag is not drawn. "Composed + but off screen after scrolling" is the screen's or the previous flow's: the + runner scrolled both ways within its budget and quotes what `/scroll` + answered ("nothing on screen can scroll" names a container with no + `testableVerticalScroll`). The macOS csd_006 row (run 36600766576) was the + second kind, under an add card csd_005 had left open. - **A second node only when a flow asks.** The matrix stands up one node with no contacts, no agent and no peers. A flow that needs more says - `fixture: two_node` — see below. 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 +230,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 +276,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 76% rename from testing/flows/drafts/csd-005-people.yaml rename to testing/flows/csd-005-people.yaml index 49f87cbb..2d23410f 100644 --- a/testing/flows/drafts/csd-005-people.yaml +++ b/testing/flows/csd-005-people.yaml @@ -15,13 +15,30 @@ 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. +# Before it, `a_node_code_is_refused_by_name` leaves the add card open with a +# refusal in it, and on a short window (macOS, 1024x656) the expanded card +# pushes the list below the fold — csd_006 then found its row composed but +# off screen (run 36600766576). The refusal clears when the field changes +# (`onKeyIdChange` -> `clearAddError`) and the card closes on the header +# toggle; `when:` keeps the toggle from OPENING the card on a run that failed +# before it got there. +cleanup: + - click: btn_contact_code_close + - input: {input_contacts_add_key: ""} + when: contacts_add_refusal + - click: btn_contacts_add_open + when: card_contacts_add steps: - step_id: on_people @@ -111,12 +128,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 83% rename from testing/flows/drafts/csd-068-provision-accord-holder.yaml rename to testing/flows/csd-068-provision-accord-holder.yaml index 40d79a15..0581f5e0 100644 --- a/testing/flows/drafts/csd-068-provision-accord-holder.yaml +++ b/testing/flows/csd-068-provision-accord-holder.yaml @@ -33,12 +33,13 @@ steps: - step_id: empty_submit_does_nothing title: Submit with nothing filled in produces no banner at all description: >- - The button is disabled, so the click is swallowed by design. The assertion - is that NEITHER banner appears — a disabled control that still fires its - handler is CIRISClient#70's shape, and #92 added a disabled-click probe for - exactly this class. + The button is disabled, so the click is refused by design: `click_refused` + holds when the platform refuses it or no handler answers, and fails if the + handler runs. The assertion is also that NEITHER banner appears — a + disabled control that still fires its handler is CIRISClient#70's shape, + and #92 added a disabled-click probe for exactly this class. do: - - click: btn_provision_holder_submit + - click_refused: btn_provision_holder_submit expect: screen: ProvisionAccordHolder absent: [provision_holder_success, provision_holder_error] @@ -75,7 +76,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 75% rename from testing/flows/drafts/csd-101-household-members.yaml rename to testing/flows/csd-101-household-members.yaml index b64cb578..c0b2c1dd 100644 --- a/testing/flows/drafts/csd-101-household-members.yaml +++ b/testing/flows/csd-101-household-members.yaml @@ -7,22 +7,34 @@ 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. # CONSENT TO JOIN (ruling 2026-09-30, CIRISConstitution#133): once CIRISServer#700 # ships, confirming an add answers 409 membership.consent_required, and the # invitation replaces it (CSD-106, CIRISPersist#955). No step here confirms an # add, so nothing below changes; `add_picks_from_contacts` only opens the picker. -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..4a664ef6 100644 --- a/testing/gate/VENDORED.md +++ b/testing/gate/VENDORED.md @@ -80,6 +80,8 @@ 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 | +| `click_refused` added to `_ACTION_KEYS` (`_VERBS`); `FlowRunner._do` calls the helper's `click_refused` | a step whose claim is "clicking this disabled control does nothing" had only `click:`, which calls a refused click a failure. iOS (run 36733112700, csd_068 `empty_submit_does_nothing`) refused the disabled Provision submit and the step failed for the outcome it asserts. `click_refused` holds when the platform refuses the click or no handler answers it, and fails when a handler runs (CIRISClient#69); the step's `absent:` judges the effect | A flow without `csd:` still loads and runs exactly as before; `run_flows.py` is what requires the key for flows in this repo. Upstream needs the same key diff --git a/testing/gate/atlas_context.py b/testing/gate/atlas_context.py index a5eb5d64..a90504be 100644 --- a/testing/gate/atlas_context.py +++ b/testing/gate/atlas_context.py @@ -27,7 +27,7 @@ def namespaces() -> dict: """The constitutional family count, from the file that generates it.""" if not REGISTRY.exists(): return {} - meta = json.loads(REGISTRY.read_text()).get("_meta", {}) + meta = json.loads(REGISTRY.read_text(encoding="utf-8")).get("_meta", {}) return { "cc_version": meta.get("cc_version"), "families": meta.get("n_families"), diff --git a/testing/gate/bringup.py b/testing/gate/bringup.py index f855e07d..ae1b6e87 100644 --- a/testing/gate/bringup.py +++ b/testing/gate/bringup.py @@ -41,9 +41,13 @@ from __future__ import annotations +import json import os +import re import shutil import subprocess +import time +import urllib.request from dataclasses import dataclass, field from pathlib import Path @@ -84,6 +88,21 @@ ANDROID_TEST_SENTINEL = "/data/local/tmp/ciris_test_mode" +#: Where the Android app reads the node's one-time claim PIN: `CIRIS_HOME` is +#: `filesDir/ciris` (CirisVerify.setup) and PythonRuntime.android's +#: readLocalClaimPin() reads `File(CIRIS_HOME, "claim_pin")`. `/data/data/` +#: is the app's own data directory, writable through `run-as` on a debug build. +ANDROID_CLAIM_PIN = "files/ciris/claim_pin" + +#: What a claim PIN looks like (`FCZX-WTDT`), loosely: it is written into a +#: device shell command, so anything else is refused rather than quoted. +_PIN_SHAPE = re.compile(r"[A-Za-z0-9-]{4,32}") + + +def _android_pin_path(package: str) -> str: + return f"/data/data/{package}/{ANDROID_CLAIM_PIN}" + + class CannotRun(RuntimeError): """This platform is not available here. @@ -171,7 +190,8 @@ def _adb(serial: str | None = None) -> list[str]: def android_plan(apk: Path, package: str, serial: str | None = None, - host_port: int = 19091, activity: str = ANDROID_ACTIVITY) -> Plan: + host_port: int = 19091, activity: str = ANDROID_ACTIVITY, + claim_pin: str | None = None) -> Plan: """Emulator on this runner, node on the host, client reaching back to it. The node runs on the HOST and the app reaches it through `adb reverse`, so @@ -179,8 +199,31 @@ def android_plan(apk: Path, package: str, serial: str | None = None, in the REMOTE-node shape described by FSD/ONE_CLIENT_N_NODES.md and means no Android-specific node binary is needed — which is just as well, since CIRISServer publishes none. + + THE PIN DOES NOT CROSS `adb reverse`. A first-run node writes its one-time + claim PIN to `/claim_pin` on the HOST; the app looks for it in + ITS home, inside the emulator. Desktop and iOS share the host's filesystem + and read the file the node declares; Android cannot, and in run + 36746575125 it drove the whole wizard and then gave up with "claim PIN not + captured after wait" — the node never saw a claim. So with `claim_pin` + (from [node_claim_pin]) the plan writes it where the app reads it, after + install (the data directory exists) and before launch (setup reads it on + the final step, but a PIN that arrives late is a race nobody needs). The + harness is the operator at the node's console here, as two_node.py is for + the peer; the PIN still never crosses HTTP. """ adb = _adb(serial) + handover: list[Step] = [] + if claim_pin is not None: + if not _PIN_SHAPE.fullmatch(claim_pin): + raise CannotRun(f"refusing to hand over a claim PIN that does not look like one: {claim_pin!r}") + path = _android_pin_path(package) + handover.append(Step( + "hand-over-claim-pin", + adb + ["shell", f"run-as {package} sh -c " + f"'umask 077 && mkdir -p {path.rsplit('/', 1)[0]} && " + f"printf %s {claim_pin} > {path}'"], + )) return Plan( platform="android", test_url=f"http://127.0.0.1:{host_port}", @@ -193,6 +236,7 @@ def android_plan(apk: Path, package: str, serial: str | None = None, Step("force-stop", adb + ["shell", "am", "force-stop", package], optional=True), # INVARIANT 3: installed before anything is forwarded. Step("install", adb + ["install", "-r", str(apk)]), + *handover, # INVARIANT 2: the node is reachable before the app probes it. Step("reverse-node", adb + ["reverse", f"tcp:{NODE_API_PORT}", f"tcp:{NODE_API_PORT}"]), Step("forward-automation", adb + ["forward", f"tcp:{host_port}", f"tcp:{CLIENT_TEST_PORT}"]), @@ -243,12 +287,59 @@ def android_teardown(package: str, serial: str | None = None, steps=[ Step("stop-app", adb + ["shell", "am", "force-stop", package], optional=True), Step("disarm-test-mode", adb + ["shell", "rm", "-f", ANDROID_TEST_SENTINEL], optional=True), + # The node deletes ITS file when the claim consumes the PIN; the copy + # handed to the device would outlive it and be offered to the next + # first run — a stale PIN, which is CIRISClient#49 planted by the gate. + Step("remove-claim-pin", + adb + ["shell", f"run-as {package} rm -f {_android_pin_path(package)}"], optional=True), Step("remove-forward", adb + ["forward", "--remove", f"tcp:{host_port}"], optional=True), Step("remove-reverse", adb + ["reverse", "--remove", f"tcp:{NODE_API_PORT}"], optional=True), ], ) +def node_claim_pin(node_url: str, timeout: float = 30.0, poll: float = 1.0) -> str | None: + """The one-time claim PIN of the node at `node_url`, read from the file the + node itself declares (`GET /v1/setup/status` -> `claim_pin_file`, the same + declaration desktop's PythonRuntime reads), or None when the node is + already owned — the session fixture then logs in instead of claiming. + + Only the PATH crosses HTTP. The read is a same-host read of a 0600 file, + which is what makes this the operator's act and not a network one. + + A first-run node whose PIN cannot be read within `timeout` RAISES. None + would mean "owned", and the leg would fail at the claim minutes later for a + reason known here. The node writes the file a few seconds after /health + answers, hence the wait. + """ + deadline = time.monotonic() + timeout + last = "no answer yet" + while True: + try: + with urllib.request.urlopen(f"{node_url.rstrip('/')}/v1/setup/status", timeout=5) as r: + status = json.load(r) + data = status.get("data", status) if isinstance(status, dict) else {} + if not (data.get("setup_required") or data.get("is_first_run")): + return None + declared = data.get("claim_pin_file") + if not declared: + last = f"the node is first-run but declares no claim_pin_file: {data}" + else: + try: + pin = Path(declared).read_text(encoding="utf-8").strip() + except OSError as e: + last = f"cannot read the declared claim_pin_file {declared}: {e}" + else: + if pin: + return pin + last = f"the declared claim_pin_file {declared} is empty" + except (OSError, ValueError) as e: + last = f"GET {node_url}/v1/setup/status failed: {e}" + if time.monotonic() >= deadline: + raise CannotRun(f"no claim PIN to hand the app after {timeout:.0f}s: {last}") + time.sleep(poll) + + def ios_simulator_plan(app_bundle: Path, bundle_id: str, udid: str = "booted") -> Plan: """Simulator on a macOS runner, node on the same host. diff --git a/testing/gate/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/build_screen_atlas_site.py b/testing/gate/build_screen_atlas_site.py index 635aeb6c..55c5589b 100644 --- a/testing/gate/build_screen_atlas_site.py +++ b/testing/gate/build_screen_atlas_site.py @@ -497,7 +497,7 @@ def main() -> int: ap.add_argument("--out", required=True, type=Path, help="site directory to write") args = ap.parse_args() - manifest = json.loads((args.atlas / "atlas.json").read_text()) + manifest = json.loads((args.atlas / "atlas.json").read_text(encoding="utf-8")) # The SHAPE and the REASONS ride along with the pictures, so the page can # explain an arrangement instead of merely listing it. from testing.gate import atlas_context, nav_map @@ -518,8 +518,8 @@ def main() -> int: if shots_out.exists(): shutil.rmtree(shots_out) shutil.copytree(args.atlas / "shots", shots_out) - (args.out / "atlas.json").write_text(json.dumps(manifest, indent=2)) - (args.out / "index.html").write_text(PAGE) + (args.out / "atlas.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + (args.out / "index.html").write_text(PAGE, encoding="utf-8") kept = len(list(shots_out.glob("*.png"))) print(f"site -> {args.out} ({kept} shots, {manifest['captured']}/{manifest['total']} captured)") diff --git a/testing/gate/candidate_artifacts.py b/testing/gate/candidate_artifacts.py index 14c53001..4403dea9 100644 --- a/testing/gate/candidate_artifacts.py +++ b/testing/gate/candidate_artifacts.py @@ -81,7 +81,7 @@ def expected_jar_version() -> str: 1.5.201 where VERSION reads 0.5.201. Derived here rather than pattern-matched so the two cannot drift. """ - major, minor, patch = (ROOT / "VERSION").read_text().strip().split(".")[:3] + major, minor, patch = (ROOT / "VERSION").read_text(encoding="utf-8").strip().split(".")[:3] return f"{max(int(major), 1)}.{minor}.{patch}" @@ -128,7 +128,7 @@ def local(kind: str) -> Path: others = ", ".join(h.name for h in hits[:4]) raise Stale( f"{art.name} is not this tree's build -- VERSION is " - f"{(ROOT / 'VERSION').read_text().strip()}, so the jar should carry {want}.\n" + f"{(ROOT / 'VERSION').read_text(encoding='utf-8').strip()}, so the jar should carry {want}.\n" f" Found: {others}\n" f" Rebuild: ./gradlew {task}\n" f" Driving a stale jar reports a platform green for code that is not the\n" diff --git a/testing/gate/console.py b/testing/gate/console.py new file mode 100644 index 00000000..44cecc4a --- /dev/null +++ b/testing/gate/console.py @@ -0,0 +1,43 @@ +"""UTF-8 on the gate's console, whatever code page the host opened it with. + +Windows, run 36600766576 (2026-09-29): the two-node fixture had just seen the +peer become reachable and printed its note about the owner→node binding wait. +The runner's Python had opened stdout as cp1252 — the default when nothing +sets `PYTHONUTF8` — and `→` (U+2192) is not in cp1252, so `print` raised +`UnicodeEncodeError` inside `up()` and the runner reported + + fixture two_node: UNAVAILABLE — the `two_node` fixture could not be stood up: + UnicodeEncodeError: 'charmap' codec can't encode character '\\u2192' in position 69 + +which read as a node problem. Three flows could not start over a print. + +The runner's own `—` and `§` had survived the same stream only because cp1252 +happens to hold those two code points; they rendered as `�` in the UTF-8 job +log rather than crashing, which is why the crash looked like it came from +somewhere other than stdout. It did not: the whole difference was which +characters the code page has. + +THE MECHANISM, NOT THE CHARACTER. Stripping the arrow would fix one note and +leave every future one a coin flip. Every gate CLI reconfigures its console to +UTF-8 on entry instead (`sys.stdout.reconfigure`, Python 3.7+); the in-process +runner, the fixture and the flow printer all inherit it. `errors="replace"` +keeps a lone surrogate from being the next crash. +""" + +from __future__ import annotations + +import sys + + +def utf8_console() -> None: + """Put stdout and stderr in UTF-8. A stream that cannot be reconfigured + (a test's StringIO, a closed pipe) is left as it is: this must never be the + thing that fails a leg.""" + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except (ValueError, OSError): + continue diff --git a/testing/gate/flow_helper.py b/testing/gate/flow_helper.py index 7b5b12f1..03cd2f88 100644 --- a/testing/gate/flow_helper.py +++ b/testing/gate/flow_helper.py @@ -37,11 +37,30 @@ from __future__ import annotations +import time from dataclasses import dataclass from typing import List, Optional 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") +#: How long a plain `click:` waits for a control refused as disabled to enable. +#: `/input` returns before the frame that applies it, so a submit clicked right +#: after typing can still be disabled: iOS, run 36733112700, csd_005 clicked Add +#: in that frame, and (before the handler followed `enabled`) nothing was sent +#: and nothing said so. A control that stays disabled this long IS disabled, +#: and the click fails with that reason. +ENABLE_SETTLE_S = 5.0 + +#: How a platform refuses a click on a DISABLED control. From 0.5.225 every +#: platform says "is disabled" (409); before that iOS and Android answered +#: "No click handler" (404), because a disabled `testableClickable` has none. +_REFUSED_AS_DISABLED = ("is disabled", "No click handler") + @dataclass class _Element: @@ -65,6 +84,52 @@ 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 = "" + #: What the last `scroll_into_view` did, one note per direction tried + #: ("down: moved", "up: already at the top"), so a `visible:` that + #: fails after scrolling can say what the screen answered. + self.last_scroll: List[str] = [] + + # ---- reaching --------------------------------------------------------- + + 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 +161,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 +180,68 @@ 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: + """Click `tag`. Refused as disabled, it is retried until the control + enables or ENABLE_SETTLE_S runs out — the frame after an input, not a + padded flow. Every other refusal is final, as before.""" + deadline = time.monotonic() + ENABLE_SETTLE_S + while True: + if self._reach(tag, lambda: self._drv.click(tag)): + return True + if "is disabled" not in self.last_error or time.monotonic() >= deadline: + return False + time.sleep(0.25) + + async def click_refused(self, tag: str, timeout: int = 2000) -> bool: + """Click a control the flow says is DISABLED, and hold only if nothing + ran behind it: the platform refused the click, or (desktop before + 0.5.225) fell back to a coordinate click that no handler answered — + the step's `absent:` then judges whether anything happened. A + programmatic handler that ran is CIRISClient#69's shape and fails; + so does a control that is not there at all (that is not a refusal, + it is a missing control).""" + seen: dict = {} + + def act() -> None: + try: + seen["answer"] = self._drv.click(tag) + except DriverError as e: + if any(word in str(e) for word in _REFUSED_AS_DISABLED): + seen["refused"] = str(e) + return + raise + + if not self._reach(tag, act): return False + if "refused" in seen: + return True + answer = seen.get("answer") + if isinstance(answer, dict) and answer.get("action") == "mouse-click": + return True + self.last_error = (f"{tag} accepted the click and its handler ran: a disabled control " + f"that still fires is CIRISClient#69's shape") + return False async def input_text(self, tag: str, text: str) -> bool: - 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.""" + notes: List[str] = [] + for direction in ("down", "up"): + for _ in range(_SCROLL_STEPS): + if await self.is_element_visible(tag): + self.last_scroll = list(dict.fromkeys(notes)) + return True + msg = self._step(tag, direction) + notes.append(f"{direction}: {msg or 'moved'}") + if msg and ("no /scroll" in msg or any(word in msg for word in _SCROLL_END)): + break + self.last_scroll = list(dict.fromkeys(notes)) + return await self.is_element_visible(tag) async def wait_for_element(self, tag: str, timeout: int = 2000) -> bool: try: diff --git a/testing/gate/flow_spec.py b/testing/gate/flow_spec.py index 548c2b9d..c5a6edae 100644 --- a/testing/gate/flow_spec.py +++ b/testing/gate/flow_spec.py @@ -58,10 +58,17 @@ "screen", "visible", "absent", "text", "count", "number", "matches", "one_of", "each", "relation", "state", } -_ACTION_KEYS = {"click", "input", "scroll_to", "wait", "wait_ms"} +_ACTION_KEYS = {"click", "click_refused", "input", "scroll_to", "wait", "wait_ms", "when"} +#: The verbs, one per action. LOCAL DELTA: `click_refused: TAG` clicks a control +#: the step says is DISABLED and holds only when nothing ran behind it — the +#: platform refused the click, or no handler answered it. A handler that fires +#: fails the action (CIRISClient#69); the step's `absent:` judges the effect. +#: iOS, run 36733112700: a plain `click:` called the refusal of a disabled +#: submit a failure, on the step whose claim IS that the click does nothing. +_VERBS = ("click", "click_refused", "input", "scroll_to", "wait") #: LOCAL DELTA (VENDORED.md): `csd` names the CSD a flow tests, so the runner can #: read that CSD's `shows:` (for `relation` field ids) and `states:` (for `state:`). -_FLOW_KEYS = {"flow", "title", "description", "client", "steps", "csd", "fixture"} +_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 +81,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"} @@ -187,25 +202,39 @@ def describe(self) -> str: @dataclass class Action: - """One interaction. Exactly one of click/input/scroll_to/wait per entry.""" + """One interaction. Exactly one of click/click_refused/input/scroll_to/wait per entry.""" kind: str target: str value: Optional[str] = None wait_ms: int = 500 + #: LOCAL DELTA, `cleanup:` only: run this action only while `when` is on + #: screen. A cleanup closes what the flow opened, and a control that + #: TOGGLES (People's `btn_contacts_add_open` opens the add card and closes + #: it) would open on a flow that failed before it got there. `when:` names + #: the thing being closed, so "already gone" is decided by the card and + #: not by the toggle that is always on screen (macOS leg, run 36600766576). + when: Optional[str] = None @classmethod - def parse(cls, raw: Any, where: str) -> "Action": + def parse(cls, raw: Any, where: str, *, cleanup: bool = False) -> "Action": if not isinstance(raw, dict): raise SpecError(f"{where}: expected a mapping, got {type(raw).__name__}") unknown = set(raw) - _ACTION_KEYS if unknown: raise SpecError(f"{where}: unknown key(s) {sorted(unknown)}; allowed: {sorted(_ACTION_KEYS)}") wait_ms = int(raw.get("wait_ms", 500)) - verbs = [k for k in ("click", "input", "scroll_to", "wait") if k in raw] + when = raw.get("when") + if when is not None and not cleanup: + # A step's action that quietly does nothing is a step that asserts + # nothing; only a cleanup may be conditional. + raise SpecError(f"{where}: `when:` is for `cleanup:` actions only") + if when is not None and (not isinstance(when, str) or not when.strip()): + raise SpecError(f"{where}: `when:` names one tag") + verbs = [k for k in _VERBS if k in raw] if len(verbs) != 1: raise SpecError( - f"{where}: exactly one of click/input/scroll_to/wait per action, got {verbs or 'none'}" + f"{where}: exactly one of {'/'.join(_VERBS)} per action, got {verbs or 'none'}" ) verb = verbs[0] if verb == "input": @@ -213,8 +242,8 @@ def parse(cls, raw: Any, where: str) -> "Action": if not isinstance(spec, dict) or len(spec) != 1: raise SpecError(f"{where}: `input` takes one {{tag: text}} pair") tag, text = next(iter(spec.items())) - return cls("input", str(tag), str(text), wait_ms) - return cls(verb, str(raw[verb]), None, wait_ms) + return cls("input", str(tag), str(text), wait_ms, when) + return cls(verb, str(raw[verb]), None, wait_ms, when) def describe(self) -> str: if self.kind == "input": @@ -275,10 +304,21 @@ 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 "")) + names |= set(_VAR.findall(a.when or "")) + return sorted(names) @classmethod def load(cls, path: Path, csd_root: Optional[Path] = None) -> "FlowSpec": @@ -314,6 +354,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 ({' / '.join(_VERBS)})") + spec.cleanup = [Action.parse(a, f"{path}: cleanup[{i}]", cleanup=True) for i, a in enumerate(cleanup_raw)] # A `${NAME}` with no fixture to fill it would reach the app as the # literal text `${NAME}` and fail as "element not found" — the one # failure that looks exactly like a broken app. Refused at load. @@ -391,6 +435,13 @@ 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: + for tag in (action.target, action.when): + if tag in doc.proposed: + raise SpecError( + f"{where}: cleanup names {tag!r}, which {doc.csd_id} still " + f"marks `proposed:`" + ) class UnresolvedVariable(Exception): @@ -547,6 +598,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. @@ -573,6 +626,22 @@ async def _drivable(self) -> List[str]: out.append(e.test_tag) return sorted(out) + async def _not_on_screen(self, tag: str) -> str: + """LOCAL DELTA: WHY a `visible:` tag is not on screen — never composed, + or composed but off screen after the scroll budget. The two send a + reader to different places: the first to the client (the tag is not + drawn), the second to the flow before this one (what it left open) or + to the screen's scroll container (what `/scroll` answered). The macOS + leg's csd_006 (run 36600766576) said "is not on screen" for a row that + WAS composed, below an add card the previous flow had left open, on a + screen the harness cannot scroll; the words above are what it took a + screenshot to learn.""" + if await self.helper.get_element(tag) is None: + return f"{tag!r} is not composed (not in /tree)" + scrolls = getattr(self.helper, "last_scroll", None) or [] + answered = f" (scrolls: {'; '.join(scrolls)})" if scrolls else "" + return f"{tag!r} is composed but off screen after scrolling{answered}" + async def _check(self, cond: Condition, label: str) -> Optional[str]: """None if the condition holds, else the FIRST failure, named precisely.""" if cond.screen: @@ -586,7 +655,7 @@ async def _check(self, cond: Condition, label: str) -> Optional[str]: if not await self.helper.is_element_visible(tag): await self.helper.scroll_into_view(tag) if not await self.helper.is_element_visible(tag): - return f"{label}: {tag!r} is not on screen" + return f"{label}: {await self._not_on_screen(tag)}" for tag in cond.absent: if await self.helper.is_element_visible(tag): return f"{label}: {tag!r} is on screen but should not be" @@ -620,7 +689,7 @@ async def _check(self, cond: Condition, label: str) -> Optional[str]: if not await self.helper.is_element_visible(want): await self.helper.scroll_into_view(want) if not await self.helper.is_element_visible(want): - return f"{label}: state {cond.state!r} — its tag {want!r} is not on screen" + return f"{label}: state {cond.state!r} — its tag {await self._not_on_screen(want)}" for other, tag in sorted(self.state_tags.items()): if other != cond.state and tag != want and await self.helper.is_element_visible(tag): return (f"{label}: state {cond.state!r} expected, but {other!r}'s " @@ -683,6 +752,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 +812,27 @@ 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 == "click_refused": + ok = await self.helper.click_refused(action.target, timeout=action.wait_ms * 4) + return None if ok else (f"click_refused {action.target!r}: the disabled control " + f"did not refuse{self._why()}") if action.kind == "input": ok = await self.helper.input_text(action.target, action.value or "") - return None if ok else f"input into {action.target!r} did not succeed" + 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 +855,69 @@ 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 _composed(self, tag: str, *, settle: bool, budget: float = EXPECT_SETTLE_S) -> bool: + """Is `tag` in /tree — read once, or re-read for up to `budget` when a + cleanup action just changed the screen. The local Linux leg + (2026-09-29) closed the contact-code card and, in the same instant, + read People's add card as gone: the card had not recomposed yet, the + guarded toggle was skipped, and csd_006 started under the open card + after all (its row on screen only because the window was tall).""" + import asyncio # noqa: PLC0415 + + deadline = time.monotonic() + (budget if settle else 0.0) + while True: + if await self.helper.get_element(tag) is not None: + return True + if time.monotonic() >= deadline: + return False + await asyncio.sleep(0.25) + + async def _cleanup(self, spec: FlowSpec) -> None: + """LOCAL DELTA: close what the flow opened, whatever its verdict. A + failure here is recorded, never raised: it is not this flow's verdict, + and hiding the verdict behind it would help nobody.""" + # Whether a cleanup action has run: the tree read after one is the + # same-instant race `_settled` names, so "gone" is then judged on + # the frame that follows, not the frame the click was made on. + changed = False + for action in spec.cleanup: + try: + action = Action(action.kind, + 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, + substitute(action.when, self.variables, self.variable_notes) + if action.when is not None else None) + except UnresolvedVariable as exc: + self.cleanup_failures.append(str(exc)) + print(f" cleanup: {exc}") + continue + # Already gone is nothing to close: a flow whose own last step shut + # the card it opened must not then report its cleanup as a failure. + # `when:` says what "gone" means for a control that would otherwise + # open the thing it is there to close. + if action.when is not None and not await self._composed(action.when, settle=changed): + print(f" cleanup: nothing to close \u2014 {action.when!r} is not on screen") + continue + if action.kind in ("click", "click_refused", "input") and not await self._composed(action.target, settle=changed): + print(f" cleanup: nothing to close \u2014 {action.target!r} is not on screen") + continue + changed = True + 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 +982,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 +1015,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..faabbff8 100644 --- a/testing/gate/run_flows.py +++ b/testing/gate/run_flows.py @@ -52,6 +52,7 @@ from pathlib import Path from typing import Any, Callable, List, Optional, Sequence +from testing.gate.console import utf8_console from testing.gate.flow_spec import FlowRunner, FlowSpec, SpecError, check_client_floor, discover REPO = Path(__file__).resolve().parents[2] @@ -117,22 +118,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 +239,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 +286,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) @@ -387,6 +457,7 @@ def build(name: str): def main(argv: Optional[List[str]] = None) -> int: + utf8_console() ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--platform", default="desktop", choices=("desktop", "android", "ios")) diff --git a/testing/gate/run_platform.py b/testing/gate/run_platform.py index 056f40f9..a21df946 100644 --- a/testing/gate/run_platform.py +++ b/testing/gate/run_platform.py @@ -44,6 +44,7 @@ from testing.driver import DriverError, TestAutomationServer from testing.gate import bringup +from testing.gate.console import utf8_console from testing.gate.platforms import CaptureKind @@ -73,8 +74,11 @@ def plan_for(args) -> bringup.Plan: if args.platform == "android": if not args.apk: raise bringup.CannotRun("--apk is required for android") + # The node is on THIS host; the app is not. Carry its claim PIN across + # (bringup.android_plan) — None when the node is already owned. + pin = bringup.node_claim_pin(args.node_url) return bringup.android_plan(Path(args.apk), args.package, serial=args.serial, - activity=args.activity) + activity=args.activity, claim_pin=pin) if args.platform == "ios": if not args.app: raise bringup.CannotRun("--app is required for ios") @@ -235,6 +239,7 @@ def walk(drv: TestAutomationServer, rep: Report, shots: Path, platform, def main() -> int: + utf8_console() ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--platform", required=True, choices=("desktop", "android", "ios")) diff --git a/testing/gate/screen_atlas.py b/testing/gate/screen_atlas.py index b0c0f251..d661b0fc 100644 --- a/testing/gate/screen_atlas.py +++ b/testing/gate/screen_atlas.py @@ -370,7 +370,7 @@ def main() -> int: "flow_only": sorted(flow_only()), "screens": results, } - (args.out / "atlas.json").write_text(json.dumps(manifest, indent=2)) + (args.out / "atlas.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") print(f"\n{manifest['captured']}/{manifest['total']} captured -> {args.out}") return 0 if manifest["captured"] else 1 finally: diff --git a/testing/gate/session_fixture.py b/testing/gate/session_fixture.py index 078428b1..5773ec40 100644 --- a/testing/gate/session_fixture.py +++ b/testing/gate/session_fixture.py @@ -11,11 +11,18 @@ client actually does on a fresh node, observed step by step through /tree: Login (isFirstRun=true) `btn_local_login` + (isFirstRun=false: the same button reveals the LOGIN FORM instead — + the client's own verdict that the node is owned; the fixture takes + that verdict and signs in, and if the node then refuses, says which + of the two was wrong) -> Setup, step `you` username / password / confirm / device name, an age band, then `btn_next` -> Setup, step `join_federation` `trace_consent_yes`, consent toggles, `btn_next` - -> `setup_ownership_claimed` no advance control: the claim is work, - not a step, and it finishes on its own + -> `setup_ownership_claiming` no advance control and no active step: + the claim is work, not a step, and it + finishes on its own (CLAIM_TIMEOUT) + -> `setup_ownership_claimed` or `setup_ownership_error`, which + names why the node refused -> Login, now with `txt_owner_hint` Login `btn_local_login` reveals the form, username / password, `btn_login_submit` @@ -47,6 +54,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from testing.driver import DriverError, TestAutomationServer # noqa: E402 +from testing.gate.console import utf8_console # noqa: E402 class SessionUnavailable(RuntimeError): @@ -129,6 +137,90 @@ def _field_report(drv: TestAutomationServer) -> str: return "; ".join(sorted(parts)) +#: How a platform refuses a click on a disabled control: every platform from +#: 0.5.225 (DisabledControls, HTTP 409), and iOS/Android before it (HTTP 404). +DISABLED_ANSWERS = ("is disabled", "No click handler") + + +#: How long a wizard step may take to show the next one after Next. Windows +#: (run 36588619656) went blank for more than the 2 s the fixture used to +#: sleep — the fed-ID mint on Next and the next step's first composition on a +#: 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 + + +#: The claim's three faces (SetupScreen's completion step). Any of them means +#: the wizard is done asking and the fixture has nothing left to click. +CLAIM_TAGS = frozenset({"setup_ownership_claiming", "setup_ownership_claimed", "setup_ownership_error"}) + +#: How long the in-progress claim may run before the fixture calls it stuck. +CLAIM_TIMEOUT = 120.0 + + +def _claim_started(tags: set[str]) -> bool: + return bool(CLAIM_TAGS & tags) + + +#: Where a failed claim's reason is readable, most specific first. +#: `setup_ownership_error` is the FailurePanel's CONTAINER; the reason itself is +#: the panel's detail, under its title. Android, run 36746575125: the fixture +#: read only the container, found no text there, and printed "(no reason on +#: screen)" beside a list that included `failure_panel_detail`. +CLAIM_REASON_TAGS = ("setup_ownership_error", "failure_panel_title", "failure_panel_detail") + + +def _claim_failure_reason(drv: TestAutomationServer, tree) -> str: + """The failed claim's reason as the screen states it. When no element + carries text, say THAT — with what each element held — because "no + reason" reads as the node's silence when it is the client's.""" + texts = {e.test_tag: (e.text or "").strip() for e in tree if e.test_tag in CLAIM_REASON_TAGS} + said = list(dict.fromkeys(t for t in (texts.get(k, "") for k in CLAIM_REASON_TAGS) if t)) + if said: + return " — ".join(said) + return ("the error screen registered no text for " + f"{', '.join(k for k in CLAIM_REASON_TAGS if k in texts)}; fields: {_field_report(drv)}") + + +def _await_claim(drv: TestAutomationServer, timeout: float = CLAIM_TIMEOUT, poll: float = 1.5) -> None: + """Wait out `setup_ownership_claiming`. It has no control — Android (run + 36733112700) showed it with the step indicators and no active step, and a + fixture looking for Next there raised "offers no advance control". Returns + once the claim resolved (claimed, or the app left Setup); raises with the + node's reason on `setup_ownership_error`, and with the screen on timeout.""" + deadline = time.monotonic() + timeout + while True: + if drv.screen() != "Setup": + return + tree = drv.tree() + tags = {e.test_tag for e in tree} + if "setup_ownership_error" in tags: + raise SessionUnavailable( + f"the ownership claim failed: {_claim_failure_reason(drv, tree)}; " + f"on screen: {sorted(tags)}" + ) + if "setup_ownership_claiming" not in tags: + return + if time.monotonic() >= deadline: + raise SessionUnavailable( + f"the claim did not finish within {timeout:.0f}s (still claiming); " + f"on screen: {sorted(tags)}" + ) + time.sleep(poll) + + +def _advanced(drv: TestAutomationServer, before: tuple, timeout: float, poll: float = 1.0) -> bool: + """True once the wizard is past `before` (screen, active step) or the claim + has taken over; False when it is still there after `timeout`.""" + deadline = time.monotonic() + timeout + while True: + if (drv.screen(), _active_step(drv)) != before or _claim_started(_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: @@ -138,21 +230,58 @@ def _settle(drv: TestAutomationServer, want: str, timeout: float = 90.0) -> bool return False +#: The login FORM: what "Local login" reveals when the client has judged the +#: node OWNED (LoginScreen: `if (isFirstRun) onLocalLogin() else showLoginForm`). +LOGIN_FORM = frozenset({"input_username", "input_password", "btn_login_submit"}) + + +def _login_form_shown(drv: TestAutomationServer) -> bool: + return drv.screen() == "Login" and LOGIN_FORM <= _tags(drv) + + +def _after_local_login(drv: TestAutomationServer, timeout: float) -> str: + """Where `btn_local_login` took the client: "Setup" (the wizard — the + client read the node as fresh), "form" (the login form — the client read + it as owned), or "" when neither showed within `timeout`.""" + deadline = time.monotonic() + timeout + while True: + if drv.screen() == "Setup": + return "Setup" + if _login_form_shown(drv): + return "form" + if time.monotonic() >= deadline: + return "" + time.sleep(1.5) + + def run_setup(drv: TestAutomationServer, username: str, password: str, device: str = "gate") -> None: - """Drive the first-run wizard until the node has an owner.""" - if "txt_owner_hint" in _tags(drv): - return # already owned; nothing to do + """Drive the first-run wizard until the node has an owner — or find that + the client already holds the node to be owned, and leave it to `log_in`. + + THE CLIENT'S VERDICT, NOT A HINT. `txt_owner_hint` composes only when + `/v1/auth/owner-hint` returns a hint, which a node claimed by this very + fixture need not serve; the Android leg (run 36600766576) showed Login + without it, "Local login" opened the login FORM (the client's isFirstRun + was false), and the fixture — waiting for Setup — called that "did not + reach Setup" and blamed a missing hint. What the form says is that the + client judged the node owned; that is the thing to act on, and if the + node then refuses the credentials, `log_in` says which side was wrong.""" + if "txt_owner_hint" in _tags(drv) or _login_form_shown(drv): + return # the client holds the node to be owned; sign in # Desktop's first run shows Login; a client whose first run opens the wizard # directly is already where this click would take it, and clicking a # `btn_local_login` that is not on screen fails the fixture for nothing. if drv.screen() != "Setup": drv.click("btn_local_login") - if not _settle(drv, "Setup", timeout=30): + landed = _after_local_login(drv, timeout=30) + if landed == "form": + return + if landed != "Setup": raise SessionUnavailable( - f"btn_local_login did not reach Setup (on {drv.screen()!r}); on a node " - f"that already has an owner this fixture should have seen txt_owner_hint" + f"btn_local_login reached neither Setup (the wizard) nor the login form " + f"within 30s (on {drv.screen()!r}); on screen: {sorted(_tags(drv))}" ) for tag, value in (("input_username", username), @@ -192,7 +321,7 @@ def run_setup(drv: TestAutomationServer, username: str, password: str, # must say so rather than spin. for _ in range(12): tags = _tags(drv) - if "setup_ownership_claimed" in tags or drv.screen() != "Setup": + if _claim_started(tags) or drv.screen() != "Setup": break # Screen 2 asks whether to send traces and will not advance until # answered (no default, like the age band above). Yes is the fixture's @@ -224,8 +353,9 @@ def run_setup(drv: TestAutomationServer, username: str, password: str, before = (drv.screen(), _active_step(drv)) # iOS's /tree omits `canClick` when it is false (defaults are not # serialized), so `_wait_clickable` cannot see a disabled Next there. - # A disabled control answers the click with 404 "No click handler": - # treat that as "not yet", bounded, and name the step if it stays so. + # A disabled control refuses the click — 409 "is disabled" from 0.5.225 + # on every platform, 404 "No click handler" on older mobile clients: + # treat either as "not yet", bounded, and name the step if it stays so. clicked = False for _ in range(15): try: @@ -233,31 +363,43 @@ def run_setup(drv: TestAutomationServer, username: str, password: str, clicked = True break except DriverError as e: - if "No click handler" not in str(e): + if not any(w in str(e) for w in DISABLED_ANSWERS): raise + # A Next that refuses while the step's question is still on + # screen may be waiting on an answer that never landed (Android, + # run 36762606620: one click on `trace_consent_yes`, Next + # disabled for 30 s). Answering again is idempotent. + if "trace_consent_yes" in _tags(drv): + try: + drv.click("trace_consent_yes") + except DriverError: + pass time.sleep(2.0) if not clicked: raise SessionUnavailable( 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. + _await_claim(drv) if not _settle(drv, "Login", timeout=180): raise SessionUnavailable( f"setup never returned to Login (on {drv.screen()!r}) — the claim did not finish" @@ -283,7 +425,44 @@ def log_in(drv: TestAutomationServer, username: str, password: str, if screen != "Login": return screen time.sleep(1.5) - raise SessionUnavailable(f"still on Login after {timeout:.0f}s") + raise SessionUnavailable(f"still on Login after {timeout:.0f}s{_why_login_failed(drv)}") + + +def _node_setup_required(node_url: str) -> bool | None: + """The NODE's own first-run predicate (`GET /v1/setup/status`), or None + when it cannot be read. Best effort: this is a diagnosis, never a gate.""" + import json # noqa: PLC0415 + import urllib.request # noqa: PLC0415 + try: + with urllib.request.urlopen(f"{node_url.rstrip('/')}/v1/setup/status", timeout=5) as r: + body = json.loads(r.read().decode("utf-8", "replace")) + except Exception: # noqa: BLE001 — unreadable is "cannot say" + return None + data = body.get("data", body) if isinstance(body, dict) else {} + value = data.get("setup_required") if isinstance(data, dict) else None + return value if isinstance(value, bool) else None + + +def _why_login_failed(drv: TestAutomationServer) -> str: + """Which side was wrong when the client's login form refused the fixture's + owner: the node (it has an owner and these are not its credentials) or + the client (the node says setup is required — it has NO owner — and the + client offered a password form instead of the wizard). Read off the + node's own predicate, from the node URL the client reports in `/state`.""" + try: + node_url = str(drv.state().get("nodeUrl") or "") + except Exception: # noqa: BLE001 — an older client serves no /state + node_url = "" + if not node_url: + return "" + required = _node_setup_required(node_url) + if required is True: + return (f"; the node at {node_url} says setup_required=true (it has no owner), yet the " + f"client offered a login form instead of the wizard: the client's first-run " + f"check is wrong, not these credentials (CIRISApp.checkFirstRunStatus, NODE-only branch)") + if required is False: + return f"; the node at {node_url} has an owner, and these are not its credentials" + return f"; the node at {node_url} could not say whether it has an owner" def establish(drv: TestAutomationServer, username: str, password: str) -> str: @@ -296,6 +475,7 @@ def establish(drv: TestAutomationServer, username: str, password: str) -> str: def main(argv: list[str]) -> int: + utf8_console() ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--url", default="http://127.0.0.1:9091") diff --git a/testing/gate/two_node.py b/testing/gate/two_node.py index 5fade171..3e281a27 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: @@ -100,6 +116,8 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple +from testing.gate.console import utf8_console + #: The flow-level key a flow sets to ask for this fixture (`fixture: two_node`). FIXTURE = "two_node" @@ -515,10 +533,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 +566,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 +618,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 +644,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})") @@ -705,6 +763,7 @@ def __exit__(self, *exc: Any) -> None: def main(argv: Optional[List[str]] = None) -> int: + utf8_console() ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) u = sub.add_parser("up", help="start, claim, peer and seed; leave the peer running") diff --git a/testing/test_bringup.py b/testing/test_bringup.py index 42b90ff9..19d8abaf 100644 --- a/testing/test_bringup.py +++ b/testing/test_bringup.py @@ -280,3 +280,130 @@ def test_the_mobile_launches_are_NOT_backgrounded(): # them background would drop the only synchronisation those plans have. android = bringup.android_plan(Path("/tmp/a.apk"), "pkg") assert not android.steps[android.index_of("launch")].background + + +# ---- the claim PIN crosses from the host's node to the device --------------- +# +# Android, run 36746575125: the app reached first-run Setup, drove the wizard, +# and gave up with "claim PIN not captured after wait — leaving node unclaimed" +# (logcat, SetupViewModel). The node never saw a claim. The app reads the PIN +# from `/claim_pin` — `files/ciris` INSIDE the emulator — while +# the node it attached to runs on the HOST and wrote its PIN there. Desktop and +# iOS share the host's filesystem and read the file the node declares; Android +# cannot, so the harness carries it, standing in for the operator at the node's +# console, exactly as two_node.py does for the peer. + +PIN = "FCZX-WTDT" + + +def test_android_hands_the_claim_pin_over_after_install_and_before_launch(): + p = android_plan(APK, PKG, claim_pin=PIN) + assert p.index_of("install") < p.index_of("hand-over-claim-pin") < p.index_of("launch") + + +def test_the_pin_lands_in_the_home_the_app_reads(): + step = android_plan(APK, PKG, claim_pin=PIN).steps[ + android_plan(APK, PKG, claim_pin=PIN).index_of("hand-over-claim-pin")] + cmd = " ".join(step.cmd) + # CirisVerify.setup(): CIRIS_HOME = filesDir/ciris; PythonRuntime.android + # readLocalClaimPin() reads File(CIRIS_HOME, "claim_pin"). + assert f"run-as {PKG}" in cmd, "only the app's own uid can write its private files" + assert f"/data/data/{PKG}/files/ciris/claim_pin" in cmd + assert PIN in cmd + + +def test_no_pin_no_handover_step(): + # An owned node has no PIN; the fixture logs in instead of claiming. + assert "hand-over-claim-pin" not in android_plan(APK, PKG).names() + + +@pytest.mark.parametrize("bad", ["ABCD-EFGH; rm -rf /", "$(id)", "a'b", "", "X" * 200]) +def test_a_pin_that_is_not_a_pin_is_refused_before_it_reaches_a_shell(bad): + with pytest.raises(CannotRun): + android_plan(APK, PKG, claim_pin=bad) + + +def test_teardown_takes_the_handed_over_pin_back(): + # A copy left on the device outlives the claim that consumed the node's own + # file — the stale-PIN shape CIRISClient#49 was about, planted by the gate. + td = android_teardown(PKG) + assert "remove-claim-pin" in td.names() + step = td.steps[td.index_of("remove-claim-pin")] + assert step.optional and f"/data/data/{PKG}/files/ciris/claim_pin" in " ".join(step.cmd) + + +class _StatusNode: + """A node's read API, serving only `/v1/setup/status`.""" + + def __init__(self, body: dict): + import http.server + import json + import threading + + payload = json.dumps(body).encode() + + class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + if self.path != "/v1/setup/status": + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *a): + pass + + self.srv = http.server.HTTPServer(("127.0.0.1", 0), H) + threading.Thread(target=self.srv.serve_forever, daemon=True).start() + self.url = f"http://127.0.0.1:{self.srv.server_address[1]}" + + def close(self): + self.srv.shutdown() + + +def test_the_pin_is_read_from_the_file_the_node_declares(tmp_path): + pin_file = tmp_path / "claim_pin" + pin_file.write_text(PIN + "\n") + node = _StatusNode({"data": {"is_first_run": True, "setup_required": True, + "claim_pin_file": str(pin_file)}}) + try: + assert bringup.node_claim_pin(node.url, timeout=2) == PIN + finally: + node.close() + + +def test_an_owned_node_has_no_pin_to_hand_over(): + node = _StatusNode({"data": {"is_first_run": False, "setup_required": False}}) + try: + assert bringup.node_claim_pin(node.url, timeout=2) is None + finally: + node.close() + + +def test_a_first_run_node_whose_pin_cannot_be_read_fails_loudly(tmp_path): + # Not None: None means "owned, log in", and the leg would then fail at the + # claim two minutes later for a reason this line already knew. + missing = tmp_path / "nowhere" / "claim_pin" + node = _StatusNode({"data": {"is_first_run": True, "setup_required": True, + "claim_pin_file": str(missing)}}) + try: + with pytest.raises(CannotRun) as e: + bringup.node_claim_pin(node.url, timeout=1, poll=0.2) + assert str(missing) in str(e.value) + finally: + node.close() + + +def test_the_android_leg_asks_its_node_for_the_pin(monkeypatch): + from testing.gate import run_platform + + asked = [] + monkeypatch.setattr(bringup, "node_claim_pin", lambda url, **kw: asked.append(url) or PIN) + args = type("A", (), dict(platform="android", apk=str(APK), package=PKG, serial=None, + activity=bringup.ANDROID_ACTIVITY, + node_url="http://127.0.0.1:4243"))() + plan = run_platform.plan_for(args) + assert asked == ["http://127.0.0.1:4243"] + assert "hand-over-claim-pin" in plan.names() diff --git a/testing/test_console.py b/testing/test_console.py new file mode 100644 index 00000000..d835ebf7 --- /dev/null +++ b/testing/test_console.py @@ -0,0 +1,125 @@ +"""The gate prints UTF-8 whatever code page the host opened its console with. + +Windows, run 36600766576 (2026-09-29): the two-node fixture printed its note +about the owner→node binding wait to a cp1252 stdout, `→` is not in cp1252, +and `print` raised `UnicodeEncodeError` inside `up()`. The runner reported the +fixture as one that could not be stood up and three flows never ran — over a +print. `testing/gate/console.py` says why the runner's own `—` survived the +same stream. + +Three pins: the offending function on a cp1252 console (red without the +reconfigure, green with it); every gate CLI reconfigures on entry; every +text-mode file under `testing/gate/` names its encoding, so a note written to +a file is not the next code-page crash. +""" + +from __future__ import annotations + +import ast +import io +import pathlib +import re +import sys + +import pytest + +from testing.gate import console +from testing.gate import two_node as tn +from testing.test_two_node import _Clock, _contacts_http + +GATE = pathlib.Path(__file__).resolve().parent / "gate" + +#: The gate's command lines: what the matrix legs invoke, and what a developer +#: runs by hand. Each must put the console in UTF-8 before it prints anything. +CLIS = ("run_platform.py", "run_flows.py", "two_node.py", "session_fixture.py") + + +def _cp1252_console(monkeypatch) -> io.TextIOWrapper: + """A stdout opened the way the Windows runner opens it.""" + stream = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", stream) + return stream + + +def _binding_wait(monkeypatch): + """`add_contact` on the path that prints the `→` note: reachable after two asks.""" + posts: list = [] + monkeypatch.setattr(tn, "http", _contacts_http([0, 1], posts)) + monkeypatch.setattr(tn, "time", _Clock()) + host = tn.Party("local", "http://h", "t") + guest = tn.Party("peer", "http://g", "t", owner_key_id="peer-user", node_key_id="peer-node") + return lambda: tn.add_contact(host, guest, wait=0, notes=[], reachable_wait=60) + + +def test_the_binding_note_is_what_a_cp1252_console_cannot_print(monkeypatch): + """The red half: the crash the Windows leg saw, reproduced without Windows.""" + _cp1252_console(monkeypatch) + with pytest.raises(UnicodeEncodeError): + _binding_wait(monkeypatch)() + + +def test_the_gate_console_prints_the_note_whatever_the_code_page(monkeypatch): + stream = _cp1252_console(monkeypatch) + console.utf8_console() + key, via = _binding_wait(monkeypatch)() + assert (key, via) == ("peer-user", "owner") + sys.stdout.flush() + out = stream.buffer.getvalue().decode("utf-8") + assert "owner→node binding" in out, out + + +def test_a_console_that_cannot_be_reconfigured_is_left_alone(monkeypatch): + """A StringIO (a test's capture) has no `reconfigure`; a leg must not fail there.""" + monkeypatch.setattr(sys, "stdout", io.StringIO()) + console.utf8_console() + print("still prints →") + assert "→" in sys.stdout.getvalue() + + +@pytest.mark.parametrize("cli", CLIS) +def test_every_gate_cli_puts_its_console_in_utf8_on_entry(cli): + src = (GATE / cli).read_text(encoding="utf-8") + m = re.search(r"^def main\([^)]*\)[^:]*:\n((?: .*\n|\n)+?)", src, re.M) + assert m, f"{cli}: no main()" + first_lines = [ln.strip() for ln in m.group(1).splitlines() if ln.strip() and not ln.strip().startswith(('"""', "#"))] + assert first_lines and first_lines[0] == "utf8_console()", ( + f"{cli}: main() must call utf8_console() before anything prints; got {first_lines[:2]}" + ) + + +def _text_opens_without_encoding(path: pathlib.Path) -> list[str]: + """`open`/`read_text`/`write_text` calls in text mode with no `encoding=`.""" + out = [] + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = func.id if isinstance(func, ast.Name) else (func.attr if isinstance(func, ast.Attribute) else "") + if name not in ("open", "read_text", "write_text"): + continue + kws = {k.arg: k.value for k in node.keywords} + if "encoding" in kws: + continue + # A positional encoding: Path.read_text("utf-8", "replace"). + positional = 1 if name == "read_text" else 2 if name == "write_text" else None + if positional is not None and len(node.args) >= positional: + continue + if name == "open": + owner = func.value if isinstance(func, ast.Attribute) else None + # Other modules' `open` (tarfile.open, urllib's urlopen is not named open). + if isinstance(owner, ast.Name) and owner.id in ("tarfile", "zipfile", "gzip"): + continue + mode_index = 1 if isinstance(func, ast.Name) else 0 + mode = kws.get("mode") + if mode is None and len(node.args) > mode_index: + mode = node.args[mode_index] + if isinstance(mode, ast.Constant) and "b" in str(mode.value): + continue + out.append(f"{path.name}:{node.lineno} {name}(...) names no encoding") + return out + + +def test_every_text_mode_file_under_the_gate_names_its_encoding(): + offenders = [line for p in sorted(GATE.glob("*.py")) for line in _text_opens_without_encoding(p)] + assert not offenders, "\n".join(offenders) 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..49fb29d3 --- /dev/null +++ b/testing/test_flow_helper.py @@ -0,0 +1,210 @@ +"""`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, + response=None): + #: What an ACCEPTED /click answers (the driver returns the body). + self.response = response if response is not None else {"success": True, "action": "click"} + self.scrolls: list = [] + self.clicks: list = [] + self.inputs: list = [] + 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) + return self.response + + 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" + + +# ── click_refused: a disabled control's click does nothing ──────────────── +# iOS, run 36733112700, csd_068 `empty_submit_does_nothing`: a disabled +# Provision submit answered /click with a refusal (1dcf0b0c), the driver +# raised, and the step whose claim IS "clicking this does nothing" failed. + + +def test_a_click_refused_as_disabled_is_the_outcome_click_refused_asks_for(): + d = _Drv(refuse="POST /click -> HTTP 409: btn_submit is disabled: it refuses /click, " + "as it refuses a press") + h = SyncFlowHelper(d) + assert asyncio.run(h.click_refused("btn_submit")) is True + assert d.clicks == ["btn_submit"] + + +def test_an_older_mobile_clients_no_click_handler_is_a_refusal_too(): + d = _Drv(refuse="POST /click -> HTTP 404: No click handler for: btn_submit") + assert asyncio.run(SyncFlowHelper(d).click_refused("btn_submit")) is True + + +def test_an_older_desktops_coordinate_click_is_accepted_and_judged_by_the_expect(): + """Desktop before 0.5.225 fell back to a mouse click on a control with no + handler; Compose ignores it on a disabled button. No handler ran, so the + action holds and the step's `absent:` judges the effect.""" + d = _Drv(response={"success": True, "action": "mouse-click", + "error": "no programmatic handler for btn_submit; used a coordinate click"}) + assert asyncio.run(SyncFlowHelper(d).click_refused("btn_submit")) is True + + +def test_a_handler_that_fires_behind_a_disabled_control_fails_click_refused(): + d = _Drv(response={"success": True, "action": "click"}) + h = SyncFlowHelper(d) + assert asyncio.run(h.click_refused("btn_submit")) is False + assert "handler ran" in h.last_error and "#69" in h.last_error + + +def test_click_refused_on_a_missing_control_is_a_failure_not_a_refusal(): + d = _Drv(refuse="POST /click -> HTTP 404: Element not found: btn_submit; on screen: []") + h = SyncFlowHelper(d) + assert asyncio.run(h.click_refused("btn_submit")) is False + assert "Element not found" in h.last_error + + +def test_click_refused_scrolls_an_off_screen_control_first(): + d = _Drv(off_screen_until=2, response={"success": True, "action": "click"}) + d.refuse_after_scroll = True + real_gate = d._gate + + def gate(verb, tag): + real_gate(verb, tag) + raise DriverError(f"POST /click -> HTTP 409: {tag} is disabled") + d._gate = gate + assert asyncio.run(SyncFlowHelper(d).click_refused("btn_submit")) is True + assert len(d.scrolls) == 2 + + +# ── A click refused as disabled waits for the frame, bounded ───────────── +# iOS, run 36733112700, csd_005: the Add submit was clicked before the frame +# that applied the typed key enabled it. With the handler bound to `enabled` +# the click is refused as "is disabled"; the helper waits for the control to +# enable, as `expect:` waits for its frame, and fails if it never does. + + +class _FakeClock: + def __init__(self): + self.t = 0.0 + + def monotonic(self): + return self.t + + def sleep(self, s): + self.t += s + + +def _disabled_for(n: int) -> _Drv: + d = _Drv() + d.disabled_left = n + + def gate(verb, tag): + if d.disabled_left > 0: + d.disabled_left -= 1 + raise DriverError(f"POST /click -> HTTP 409: {tag} is disabled: it refuses /click") + d._gate = gate + return d + + +def test_a_click_refused_as_disabled_is_retried_until_the_control_enables(monkeypatch): + from testing.gate import flow_helper + monkeypatch.setattr(flow_helper, "time", _FakeClock()) + d = _disabled_for(3) + assert asyncio.run(SyncFlowHelper(d).click("btn_contacts_add_submit")) is True + assert d.clicks.count("btn_contacts_add_submit") == 4 + + +def test_a_control_that_never_enables_fails_with_its_reason_and_is_bounded(monkeypatch): + from testing.gate import flow_helper + clock = _FakeClock() + monkeypatch.setattr(flow_helper, "time", clock) + d = _disabled_for(10 ** 6) + h = SyncFlowHelper(d) + assert asyncio.run(h.click("btn_contacts_add_submit")) is False + assert "is disabled" in h.last_error + assert clock.t <= flow_helper.ENABLE_SETTLE_S + 1, "bounded" + + +def test_scrolling_is_bounded_and_the_bottom_is_reported(): + d = _Drv(off_screen_until=10 ** 6, bottom_after=3) + h = SyncFlowHelper(d) + assert asyncio.run(h.click("tile_far_away")) is False + assert len(d.scrolls) < 40, "bounded" + assert "off screen" in h.last_error and "already at the bottom" in h.last_error + + +def test_scroll_into_view_keeps_what_the_screen_answered(): + """A `visible:` that fails after scrolling quotes the screen's answer, so + "composed but off screen" says whether there was anything to scroll.""" + d = _Drv(refuse="") + d.scroll_to = lambda tag, direction="down", amount=300: { + "success": False, + "error": f"nothing on screen 'Contacts' can scroll (no testableVerticalScroll registered)", + } + h = SyncFlowHelper(d) + assert asyncio.run(h.scroll_into_view("contacts_row_peer")) is False + assert h.last_scroll == [ + "down: nothing on screen 'Contacts' can scroll (no testableVerticalScroll registered)", + "up: nothing on screen 'Contacts' can scroll (no testableVerticalScroll registered)", + ], h.last_scroll diff --git a/testing/test_flows.py b/testing/test_flows.py index 1be8ba70..5441ff5e 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") @@ -323,21 +336,46 @@ async def get_screen(self): return self.screen async def is_element_visible(self, tag): - return tag in self.els + # Geometry, as the real helper reads it: composed with zero size is + # off screen (a row below the fold), not on screen. + e = self.els.get(tag) + if e is None: + return False + return e.visible if e.visible is not None else (e.width > 0 and e.height > 0) async def scroll_into_view(self, tag): - return tag in self.els + self.calls.append(f"scroll {tag}") + # This screen has nothing the harness can scroll; say so, as the real + # helper keeps what `/scroll` answered. + self.last_scroll = ["down: nothing on screen can scroll (no testableVerticalScroll registered)"] + return await self.is_element_visible(tag) async def click(self, tag, timeout=2000): self.calls.append(f"click {tag}") - 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] self.els = {t: _El(t, "") for t in shown} return True + async def click_refused(self, tag, timeout=2000): + self.calls.append(f"click_refused {tag}") + if tag not in self.els: + self.last_error = f"no such element {tag!r}" + return False + if tag not in self.refuse: + self.last_error = f"{tag} accepted the click and its handler ran" + return False + return True + async def input_text(self, tag, text): self.calls.append(f"input {tag}") return tag in self.els @@ -360,6 +398,226 @@ 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 + + +CLEANUP_WHEN = GOOD + """\ + cleanup: + - click: btn_toggle + when: card_open +""" + + +def test_a_cleanup_guarded_by_when_runs_only_while_its_card_is_open(tmp_path): + """macOS, run 36600766576: csd_005 left People's add card open (its last + step provokes a refusal in it) and csd_006's row sat below the fold. The + control that closes the card is the header toggle that also OPENS it, so + "already gone" has to be decided by the card, not by the toggle.""" + h = FakeHelper("Thing", {"thing_list": "", "btn_toggle": "", "card_open": ""}) + out = _run(_spec(tmp_path, CLEANUP_WHEN), h) + assert out.status == run_flows.PASS + assert h.calls[-1] == "click btn_toggle" + assert "cleanup" not in out.detail, out.detail + + h = FakeHelper("Thing", {"thing_list": "", "btn_toggle": ""}) # the toggle is there, the card is not + out = _run(_spec(tmp_path, CLEANUP_WHEN), h) + assert out.status == run_flows.PASS + assert "click btn_toggle" not in h.calls, "a guarded cleanup must not open what it is there to close" + assert "cleanup" not in out.detail, out.detail + + +CLEANUP_AFTER_CLOSE = GOOD + """\ + cleanup: + - click: btn_code_close + - click: btn_toggle + when: card_open +""" + + +def test_a_cleanup_guard_reads_the_frame_after_the_previous_close(tmp_path): + """Local Linux leg, 2026-09-29: closing the contact-code card brought + People's add card back on the NEXT frame; the guard read the same + instant, saw no card, skipped the toggle, and csd_006 started under the + open card after all.""" + h = _NextFrame("Thing", {"thing_list": "", "btn_code_close": "", "btn_toggle": ""}) + # Closing the code card lands a frame later, and only then is the add card back. + h.leads = {"btn_code_close": ("Thing", ["thing_list", "btn_toggle", "card_open"])} + out = _run(_spec(tmp_path, CLEANUP_AFTER_CLOSE), h) + assert out.status == run_flows.PASS + assert h.calls[-1] == "click btn_toggle", h.calls + assert "cleanup" not in out.detail, out.detail + + +def test_when_is_for_cleanup_actions_only(tmp_path): + """A step's action that quietly does nothing is a step that asserts nothing.""" + body = GOOD.replace(" expect:\n", " do:\n - wait: thing_list\n when: thing_list\n expect:\n") + assert "when: thing_list" in body + with pytest.raises(SpecError, match="cleanup"): + _spec(tmp_path, body) + + +def test_a_visible_tag_that_was_never_composed_says_so(tmp_path): + h = FakeHelper("Thing", {}) # thing_list is not in /tree at all + out = _run(_spec(tmp_path), h) + assert out.status == run_flows.FAIL + assert "'thing_list' is not composed" in out.detail, out.detail + + +def test_a_visible_tag_composed_below_the_fold_says_off_screen_after_scrolling(tmp_path): + """The macOS csd_006 shape: the row is in /tree with clipped, zero-size + bounds. "Is not on screen" sent the reader to the client; the row was + there, under a card the previous flow had left open, on a screen the + harness could not scroll — which is what the message now says.""" + h = FakeHelper("Thing", {"thing_list": ""}) + h.els["thing_list"] = _El("thing_list", "", visible=None, width=0, height=0) + out = _run(_spec(tmp_path), h) + assert out.status == run_flows.FAIL + assert "'thing_list' is composed but off screen after scrolling" in out.detail, out.detail + assert "no testableVerticalScroll" in out.detail, "what /scroll answered belongs in the verdict" + assert "scroll thing_list" in h.calls, "the runner tried to bring it on screen first" + + +class _NextFrame(FakeHelper): + """A click whose effect lands on the next frame — 0.3 s later on the wall + clock, as a Compose recomposition does after `/click` returns. Reading the + 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_element(self, tag): + self._land() + return await super().get_element(tag) + + 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 + + +REFUSED = GOOD.replace(" expect:\n", " do:\n - click_refused: btn_submit\n expect:\n", 1) + + +def test_click_refused_parses_as_its_own_verb(tmp_path): + spec = _spec(tmp_path, REFUSED) + act = spec.steps[0].do[0] + assert (act.kind, act.target) == ("click_refused", "btn_submit") + assert act.describe() == "click_refused 'btn_submit'" + + +def test_click_refused_is_one_verb_among_the_others(tmp_path): + body = GOOD.replace(" expect:\n", + " do:\n - {click_refused: btn_submit, click: btn_submit}\n expect:\n", 1) + with pytest.raises(SpecError, match="exactly one of"): + _spec(tmp_path, body) + + +def test_a_refused_click_passes_the_step_that_claims_it_does_nothing(tmp_path): + """csd_068 `empty_submit_does_nothing` on iOS, run 36733112700: the + platform refused the disabled submit, and a plain `click:` called that a + failure. The refusal is the outcome the step claims.""" + h = FakeHelper("Thing", {"thing_list": "", "btn_submit": ""}) + h.refuse = {"btn_submit"} + out = _run(_spec(tmp_path, REFUSED), h) + assert out.status == run_flows.PASS, out.detail + assert "click_refused btn_submit" in h.calls + + +def test_a_disabled_control_whose_handler_fires_fails_click_refused(tmp_path): + h = FakeHelper("Thing", {"thing_list": "", "btn_submit": ""}) + out = _run(_spec(tmp_path, REFUSED), h) + assert out.status == run_flows.FAIL + assert "handler ran" in out.detail, out.detail + + def test_a_failing_expect_fails_the_flow_and_the_leg(tmp_path): out = _run(_spec(tmp_path), FakeHelper("Thing", {"something_else": ""})) assert out.status == run_flows.FAIL @@ -516,9 +774,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 +872,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_platform_automation_wiring.py b/testing/test_platform_automation_wiring.py index 324dee3f..a0cb738c 100644 --- a/testing/test_platform_automation_wiring.py +++ b/testing/test_platform_automation_wiring.py @@ -186,3 +186,205 @@ def test_every_csd_surface_is_reachable(): assert m, f"{doc.name}: no csd:surface block" screen = _re.search(r"screen:\s*(\w+)", m.group(1)).group(1) assert screen in hops, f"{doc.name}: no sidebar route to Screen.{screen}" + + +# ── A disabled control a flow clicks must be disabled to `/click` too (#69) ─── +# +# Windows, run 36600766576 (2026-09-29), csd_068 `fips_and_a_path`: the +# "Confirm your YubiKey…" banner was on screen before anything had been +# submitted. The flow's second step had clicked `btn_provision_holder_submit` +# while it was greyed out, and the click RAN `provision()`: the Button passed +# `enabled = canProvision` but its `testableClickable` did not, so the +# automation handler stayed registered — CIRISClient#69's shape, which +# `bindClickHandler` exists to close and which every call site has to opt into. +# +# Linux and macOS passed the same step by accident: the runner had scrolled +# down to reach the submit, the banner composed above the fold with a clipped, +# zero-size bounds, and the geometry-based `absent:` read it as gone. Windows' +# shorter window scrolled back up for the checkbox and the banner was there. +# +# Pinned for the tags flows CLICK (a flow asserting "a disabled submit does +# nothing" is asserting exactly this), parsed with `re`, per AGENTS.md. + +FLOWS_DIR = pathlib.Path(__file__).resolve().parents[1] / "testing" / "flows" +COMMON = SHARED / "commonMain" / "kotlin" +BUTTONS = re.compile( + r"\b(?:Button|OutlinedButton|TextButton|FilledTonalButton|ElevatedButton|IconButton|FilledIconButton)\(" +) + + +def _flow_click_targets() -> set[str]: + """Every literal tag a shipped flow clicks (`${...}` tags cannot be grepped), + including `click_refused:` — the control a flow asserts is disabled is the + one whose handler most needs to follow `enabled`.""" + tags: set[str] = set() + for flow in FLOWS_DIR.glob("*.yaml"): + for tag in re.findall(r"^\s*-\s*click(?:_refused)?:\s*\"?([A-Za-z0-9_]+)\"?\s*$", flow.read_text(encoding="utf-8"), re.M): + tags.add(tag) + return tags + + +def _args_of(src: str, open_paren: int) -> str: + """The text inside the parentheses that open at `open_paren`.""" + depth = 0 + for i in range(open_paren, len(src)): + if src[i] == "(": + depth += 1 + elif src[i] == ")": + depth -= 1 + if depth == 0: + return src[open_paren + 1:i] + return src[open_paren + 1:] + + +def _top_level(text: str) -> str: + """`text` with nested braces and parentheses blanked, so a Button's own + `enabled =` is found and a lambda's or a nested call's is not.""" + out, depth = [], 0 + for ch in text: + if ch in "({": + depth += 1 + elif ch in ")}": + depth -= 1 + elif depth == 0: + out.append(ch) + return "".join(out) + + +def _buttons_that_do_not_disable_their_click_handler() -> dict[str, str]: + """tag -> file:line for every Button whose `enabled =` is not mirrored into + its `testableClickable(...)`, restricted to the tags flows click.""" + wanted = _flow_click_targets() + found: dict[str, str] = {} + for path in sorted(COMMON.rglob("*.kt")): + src = path.read_text(encoding="utf-8") + for m in BUTTONS.finditer(src): + args = _args_of(src, m.end() - 1) + if not re.search(r"(?:^|,)\s*enabled\s*=", _top_level(args), re.M): + continue + tc = re.search(r"testableClickable\(", args) + if not tc: + continue + tc_args = _args_of(args, tc.end() - 1) + tag = re.search(r'"([A-Za-z0-9_]+)"', tc_args) + if not tag or tag.group(1) not in wanted: + continue + if re.search(r"\benabled\s*=", _top_level(tc_args)): + continue + line = src[:m.start()].count("\n") + 1 + found[tag.group(1)] = f"{path.relative_to(SHARED)}:{line}" + return found + + +def test_the_probe_sees_the_flows_click_targets(): + """A parser that finds nothing where the construct plainly exists must fail loudly.""" + tags = _flow_click_targets() + assert "btn_provision_holder_submit" in tags and "btn_nav_back" in tags, tags + + +def test_a_button_a_flow_clicks_disables_its_click_handler_with_itself(): + offenders = _buttons_that_do_not_disable_their_click_handler() + assert not offenders, ( + "a Button's `enabled =` must be passed to its testableClickable too, or " + "`/click` presses what the person cannot (CIRISClient#69): " + + ", ".join(f"{t} at {where}" for t, where in sorted(offenders.items())) + ) + + +# ── A disabled control's handler is REMOVED, not silenced ───────────────────── +# +# iOS, run 36733112700, csd_005 `a_node_code_is_refused_by_name`: the flow typed +# a node code and clicked Add. The click answered success, no request was ever +# made (no `[addContact] POST` in the app log), and no refusal composed. +# `CirisButton` registered its handler unconditionally as +# `{ if (enabled) onClick() }`: the click landed before the frame that applied +# the typed key, the handler still held `enabled = false`, did nothing, and +# said it had clicked. A guard INSIDE the handler turns "disabled" into a silent +# success; the handler has to follow `enabled` (bindClickHandler) so `/tree` +# says canClick=false and `/click` says "is disabled". + +_SILENT_GUARD = re.compile(r"testableWithHandler\(([^)]*)\)\s*\{\s*if\s*\(") + + +def _handlers_that_silently_ignore_a_click() -> list[str]: + found = [] + for path in sorted(COMMON.rglob("*.kt")): + src = path.read_text(encoding="utf-8") + for m in _SILENT_GUARD.finditer(src): + if "enabled" in m.group(1): + continue # the guard is belt-and-braces behind a bound handler + found.append(f"{path.relative_to(SHARED)}:{src[:m.start()].count(chr(10)) + 1}") + return found + + +def test_the_silent_guard_probe_sees_the_construct(): + """Red path: the probe finds the shape it exists to find.""" + src = 'Modifier.testableWithHandler(tag) { if (enabled) onClick() }' + assert _SILENT_GUARD.search(src) + assert not _SILENT_GUARD.search('Modifier.testableWithHandler(tag, enabled = enabled) { onClick() }') + + +def test_no_click_handler_silently_ignores_a_click_while_disabled(): + offenders = _handlers_that_silently_ignore_a_click() + assert not offenders, ( + "a handler guarded by `{ if (...) }` answers /click with success while " + "doing nothing; pass `enabled =` to testableWithHandler instead: " + + ", ".join(offenders) + ) + + +# ── A node that ANSWERS is not a node that is OWNED (Android leg) ──────────── +# +# Run 36600766576: the Android leg's node was fresh (the desktop leg's seeded +# node had been stopped, as the workflow intends), the client's +# `checkFirstRunStatus` took its NODE-only branch, and that branch returned +# "setup complete" the moment the node answered `/health` — the #48 shortcut, +# written for the run-without-AI hand-off, where the node HAD been claimed. +# Login rendered with isFirstRun=false, "Local login" opened a password form +# for an owner that did not exist, and the session fixture called it "did not +# reach Setup". Desktop never saw it because its ActiveBackend was still the +# agent pin at that point, so it took the /v1/setup/status path and got FRESH. +# +# Pinned at the source: the NODE-only branch must ask the node whether it has +# an owner (`probeNodeOwnership` / `nodeHasOwner`) before it may answer false. + +CIRISAPP = SHARED / "commonMain" / "kotlin" / "ai" / "ciris" / "mobile" / "shared" / "CIRISApp.kt" + + +def _block(src: str, open_brace: int) -> str: + depth = 0 + for i in range(open_brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[open_brace:i + 1] + return src[open_brace:] + + +def _node_only_branch_of_first_run_check() -> str: + src = CIRISAPP.read_text(encoding="utf-8") + start = src.index("private suspend fun checkFirstRunStatus(") + body = _block(src, src.index("{", src.index(")", start))) + # The function opens with `val nodeUrl = if (ActiveBackend.endpoint == …)`, + # which is the same test on a different question; the branch wanted is the + # one that decides on the node answering. + blocks = [_block(body, m.end() - 1) + for m in re.finditer(r"if \(ActiveBackend\.endpoint == NODE_ONLY_ENDPOINT\) \{", body)] + deciding = [b for b in blocks if "isNodeReachable(" in b] + assert deciding, "checkFirstRunStatus has no NODE-only branch that decides on the node answering (did it move?)" + return deciding[0] + + +def test_the_node_only_first_run_check_asks_the_node_whether_it_is_owned(): + branch = _node_only_branch_of_first_run_check() + asks = re.search(r"probeNodeOwnership\(|nodeHasOwner\(", branch) + first_false = re.search(r"return false", branch) + assert asks, "the NODE-only branch never asks whether the node has an owner: a fresh node is called configured" + assert first_false and asks.start() < first_false.start(), ( + "the NODE-only branch answers 'configured' before asking whether the node has an owner" + ) + assert re.search(r"NodeOwnership\.FRESH[^\n]*\n[^\n]*\n?[^\n]*return true|FRESH.*?return true", branch, re.S), ( + "a FRESH node must be a first run (return true) on the NODE-only branch" + ) diff --git a/testing/test_session_fixture.py b/testing/test_session_fixture.py new file mode 100644 index 00000000..df038786 --- /dev/null +++ b/testing/test_session_fixture.py @@ -0,0 +1,330 @@ +"""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, claim_takes: float = 0.0, + claim_error: str | None = None, claim_error_on: str = "container"): + self.clock, self.advance_after = clock, advance_after + # WHERE the reason is readable in /tree. "container": on + # `setup_ownership_error` itself. "panel": only on the FailurePanel's + # own texts — the container carries none. "nowhere": no element on the + # error screen carries text, which is what a client whose panel + # registered its tags without their texts served (Android, run + # 36746575125: "(no reason on screen)" with the reason on screen). + self.claim_error_on = claim_error_on + # The claim is WORK: `setup_ownership_claiming` for `claim_takes` + # seconds (step indicators up, no step active, no advance control), + # then claimed — or the error panel, which never returns to Login. + self.claim_takes, self.claim_error = claim_takes, claim_error + self.refuse_next = 0 + # Clicks on `trace_consent_yes` that land but change nothing: the + # answer's handler was not live yet (Android, run 36762606620). + self.lose_consent_clicks = 0 + self.on = "Login" + self.step = "you" + self.next_at: float | None = None + 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 _claim_phase(self) -> str: + if self.claimed_at is None: + return "" + spent = self.clock.t - self.claimed_at + if spent < self.claim_takes: + return "claiming" + if self.claim_error is not None: + return "error" + return "Login" if spent >= self.claim_takes + 1.0 else "claimed" + + def screen(self): + if self._claim_phase() == "Login": + return "Login" + return self.on + + def tree(self): + self._tick() + if self.screen() == "Login": + return [_el("btn_local_login")] + if self.step == "claimed": + phase = self._claim_phase() + # Android, run 36733112700: the indicators stay, none is active. + indicators = [_el("setup_step_indicators"), _el("step_indicator_you", ""), + _el("step_indicator_join_federation", "")] + if phase == "claiming": + return [_el("setup_ownership_claiming")] + indicators + if phase == "error": + on = self.claim_error_on + panel = [_el("failure_panel"), + _el("failure_panel_title", "This node could not be claimed" if on == "panel" else None), + _el("failure_panel_detail", self.claim_error if on == "panel" else None), + _el("btn_failure_report"), _el("btn_setup_finish_unclaimed")] + return ([_el("setup_ownership_error", self.claim_error if on == "container" else None)] + + panel + indicators) + return [_el("setup_ownership_claimed")] + indicators = [_el("setup_step_indicators"), + _el("step_indicator_you", "active" if self.step == "you" else ""), + _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_next" and self.refuse_next > 0: + # Next still disabled (iOS: the fields reach the ViewModel late), + # answered the way every platform answers from 0.5.225. + self.refuse_next -= 1 + raise DriverError(f"POST /click -> HTTP 409: {tag} is disabled: it refuses /click") + if tag == "btn_local_login": + self.on = "Setup" + elif tag == "trace_consent_yes": + if self.lose_consent_clicks > 0: + self.lose_consent_clicks -= 1 + else: + self.consented = True + elif tag == "btn_next" and self.step == "join_federation" and not self.consented: + # SetupState.canProceedFromCurrentStep: unanswered -> Next disabled. + raise DriverError(f"POST /click -> HTTP 409: {tag} is disabled: it refuses /click") + elif tag == "btn_next": + if self.step == "you": + self.next_at = self.clock.t + 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, **kw): + clock = _Clock() + monkeypatch.setattr(sf, "time", clock) + w = _SlowWizard(clock, advance_after, **kw) + 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" + + +def test_a_claim_in_progress_is_waited_for_not_asked_for_a_control(monkeypatch): + """Android, run 36733112700: after the last Next the wizard showed + `setup_ownership_claiming` with no step active and no advance control, and + the fixture raised "wizard step '' offers no advance control". The claim + is work that finishes on its own; the fixture waits for it.""" + clock, w = _drive(monkeypatch, advance_after=1.0, claim_takes=40.0) + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert w.screen() == "Login" + assert w.clicks.count("btn_next") == 2 + + +def test_a_claim_that_never_finishes_is_reported_with_the_screen(monkeypatch): + clock, w = _drive(monkeypatch, advance_after=1.0, claim_takes=10 ** 6) + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + msg = str(e.value) + assert "claim did not finish" in msg and "setup_ownership_claiming" in msg, msg + assert clock.t < 300, "bounded" + + +def test_a_refused_claim_is_reported_with_its_reason(monkeypatch): + clock, w = _drive(monkeypatch, advance_after=1.0, claim_takes=5.0, + claim_error="claim PIN was not captured") + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert "claim PIN was not captured" in str(e.value) + assert clock.t < 60, "a refused claim is reported when it is refused, not at the timeout" + + +def test_a_reason_on_the_failure_panel_is_read_from_the_panel(monkeypatch): + """Android, run 36746575125: `setup_ownership_error` is the panel's + CONTAINER and carries no text; the reason is the panel's detail. The + fixture reported "(no reason on screen)" with `failure_panel_detail` in + the very list it printed.""" + clock, w = _drive(monkeypatch, advance_after=1.0, claim_takes=5.0, + claim_error="claim PIN not captured", claim_error_on="panel") + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + msg = str(e.value) + assert "claim PIN not captured" in msg, msg + assert "This node could not be claimed" in msg, msg + assert "no reason on screen" not in msg, msg + + +def test_a_reason_nobody_registered_says_what_each_element_held(monkeypatch): + """When no element carries text, say so per element — the field report — + so the next red run shows that the CLIENT withheld the text rather than + reading as though the node gave no reason.""" + clock, w = _drive(monkeypatch, advance_after=1.0, claim_takes=5.0, + claim_error="claim PIN not captured", claim_error_on="nowhere") + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + msg = str(e.value) + assert "failure_panel_detail[" in msg, msg + assert "registered no text" in msg, msg + + +def test_a_next_that_answers_disabled_is_waited_for_like_one_with_no_handler(monkeypatch): + """From 0.5.225 a disabled control answers "is disabled" (409) on every + platform, not "No click handler"; the fixture's wait for a late-enabling + Next must read both.""" + clock, w = _drive(monkeypatch, advance_after=1.0) + w.refuse_next = 3 + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert w.screen() == "Login" + + +def test_a_consent_answer_that_did_not_land_is_given_again(monkeypatch): + """Android, run 36762606620: the fixture clicked `trace_consent_yes` + once, the answer never reached the ViewModel, and Next stayed disabled + for 30 s while the question sat unanswered on screen. The fixture only + re-answered after a Next that CLICKED but did not advance, never after a + Next that refused. Answering again is idempotent; waiting is not.""" + clock, w = _drive(monkeypatch, advance_after=1.0) + w.lose_consent_clicks = 2 + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert w.screen() == "Login" + assert w.clicks.count("trace_consent_yes") >= 3 + + +def test_a_consent_that_never_lands_is_still_reported_by_step(monkeypatch): + clock, w = _drive(monkeypatch, advance_after=1.0) + w.lose_consent_clicks = 10 ** 6 + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert "'join_federation'" in str(e.value) and "stayed disabled" in str(e.value) + assert clock.t < 120, "bounded" + + +class _OwnedLogin: + """A client that has judged the node OWNED: `btn_local_login` reveals the + login FORM (LoginScreen: `if (isFirstRun) onLocalLogin() else + showLoginForm`), never the wizard, and no `txt_owner_hint` composes + (the node serves no owner hint). The Android leg's shape, run + 36600766576.""" + + def __init__(self): + self.form = False + self.clicks: list[str] = [] + self.inputs: dict[str, str] = {} + + def screen(self): + return "Login" + + def tree(self): + if self.form: + return [_el(t) for t in ("input_username", "input_password", "btn_login_submit")] + return [_el("btn_local_login")] + + def click(self, tag): + self.clicks.append(tag) + if tag == "btn_local_login": + self.form = True + + def input(self, tag, text): + self.inputs[tag] = text + + def state(self): + return {"screen": "Login", "clientMode": "NODE", "nodeUrl": "http://127.0.0.1:4243"} + + +def test_the_login_form_is_the_clients_verdict_that_the_node_is_owned(monkeypatch): + """No wizard and no owner hint, but a password form: the client holds the + node to be owned. That is not "did not reach Setup"; it is "sign in".""" + monkeypatch.setattr(sf, "time", _Clock()) + w = _OwnedLogin() + sf.run_setup(w, "qaadmin", "QaAdmin!2345") # must not raise + assert w.clicks == ["btn_local_login"] + assert w.form, "the form the client offered is what the fixture judged by" + + +def test_a_client_that_shows_neither_wizard_nor_form_is_still_reported(monkeypatch): + monkeypatch.setattr(sf, "time", _Clock()) + w = _OwnedLogin() + w.click = lambda tag: w.clicks.append(tag) # the click lands nowhere + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert "neither Setup" in str(e.value) and "login form" in str(e.value) + + +def test_a_refused_sign_in_says_which_side_was_wrong(monkeypatch): + """The Android leg's actual state: the node has NO owner (its own + /v1/setup/status says setup_required=true) and the client offered a + password form anyway. The fixture names the client's first-run check, + not its credentials.""" + monkeypatch.setattr(sf, "time", _Clock()) + monkeypatch.setattr(sf, "_node_setup_required", lambda url: True) + w = _OwnedLogin() + w.form = True + with pytest.raises(sf.SessionUnavailable) as e: + sf.log_in(w, "qaadmin", "QaAdmin!2345", timeout=10) + msg = str(e.value) + assert "setup_required=true" in msg and "first-run check is wrong" in msg, msg + assert "127.0.0.1:4243" in msg + + monkeypatch.setattr(sf, "_node_setup_required", lambda url: False) + with pytest.raises(sf.SessionUnavailable) as e: + sf.log_in(w, "qaadmin", "QaAdmin!2345", timeout=10) + assert "has an owner, and these are not its credentials" in str(e.value) 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"