diff --git a/.coverage-visibility-allowlist b/.coverage-visibility-allowlist index 0b54e96ad..a7443984d 100644 --- a/.coverage-visibility-allowlist +++ b/.coverage-visibility-allowlist @@ -27,5 +27,12 @@ lib/screens/restore_wallet/cubit/validate_seed/validate_seed_state.dart # Const-only data (covered as values by default_assets_test.dart, no lines): lib/packages/utils/default_assets.dart +# Const-only WalletConnect metadata (project id, chains, methods); no bodies: +lib/packages/walletconnect/walletconnect_config.dart + +# Reown WalletKit plugin/relay adapter (`coverage:ignore-file`); FakeEngine +# unit tests cover the port, so this file emits no SF: record: +lib/packages/walletconnect/reown_walletconnect_engine.dart + # Drift table schema (column getters carry coverage:ignore-line): lib/packages/storage/node_storage.dart diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index ec6e3d453..74546ab93 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -50,15 +50,22 @@ jobs: # always run — the condition is "anything that isn't a PR, or a PR # that isn't a draft". if: github.event_name != 'pull_request' || github.event.pull_request.draft == false - runs-on: macos-latest - timeout-minutes: 30 + # Same self-hosted runner as Visual Regression and Coverage collect. + # macos-latest cancelled uninstrumented `flutter test --exclude-tags + # golden` at 91 and 120 minutes; this runner finishes the suite in + # ~2 minutes. One slot: do not collect coverage here — that step + # cancelled at 30 minutes and starved Visual Regression in the queue. + runs-on: [self-hosted, macOS, ARM64, m3-ultra, realunit-app] + # Uninstrumented suite finishes in ~2 minutes here. 15 covers + # checkout, codegen, analyze, and that pass/fail run. + timeout-minutes: 15 steps: - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: flutter-version: "3.41.6" channel: "stable" - cache: true + cache: false - run: flutter pub get - run: dart run tool/generate_localization.dart - run: dart run tool/generate_release_info.dart @@ -66,13 +73,107 @@ jobs: - run: bash scripts/run-handbook-flows.sh --matcher-self-test - run: flutter analyze # Excludes the `golden` tag: visual-regression tests live under - # `test/goldens/` and are validated on the self-hosted runner - # in the parallel `golden-tests` job (Hardware-Determinismus, see - # `docs/visual-regression-tests.md`). Running them here too would - # both duplicate work and erroneously red this job on macos-latest - # where the Skia/font-rendering does not match the committed - # baselines. - - run: flutter test --coverage --exclude-tags golden + # `test/goldens/` and are validated in the `golden-tests` job + # (see `docs/visual-regression-tests.md`). Running them here + # too would duplicate that work. Floor coverage lives in + # `coverage-collect` so this job can free the one slot. + - run: flutter test --exclude-tags golden + + # Hard-fails the build when scoped coverage drops below the committed floor. + # Two flat repo-root files hold the integers: `.coverage-floor-lines` and + # `.coverage-floor-functions`. They are diffable, grep-able, and require + # no `yq`/JSON tooling in the runner — same rationale as the rest of this + # workflow: keep the gate readable in a `git blame`, not buried in YAML. + # + # Lives in its own job (not inline in `Analyze & Test`) so it can be set + # as a separately required status check in branch protection — a single + # job name on `Analyze & Test` would let the floor regress without + # blocking merge if it stayed inline. The job graph is: + # build ║ golden-tests (one self-hosted slot; either may start first) + # └► coverage-collect (after both; needs the runner next) + # └► coverage-floor + # bitbox-audit (parallel; informational only) + # + # Ratchet protocol (also documented under README "Coverage infrastructure + # roadmap"): + # * Raising the floor is encouraged on every PR that raises measured + # coverage — bump the file in the same commit and the gate moves up. + # * Lowering the floor needs a reviewer's explicit OK. PR convention is + # the `coverage:lower-floor` label so the regression is visible at + # a glance in the PR list rather than being smuggled in. + # + # Why pure bash + awk instead of `lcov --fail-under-*`: + # the `--fail-under-lines` flag arrived in lcov 2.0 and earlier runner + # images may still pull 1.x out of the package manager. Comparing + # `52.9` against `51` with awk sidesteps that and stays portable. + # + # On the "no data found" path for the functions metric: + # `flutter test --coverage` emits LF/LH (line execution) and BRF/BRH + # (branch) records, but not FN/FNF/FNH (function execution) — that's a + # Flutter limitation, not a repo bug. When the summary reports + # "no data found" for functions, the gate emits a workflow warning + # instead of comparing against an empty value. This is intentionally + # NOT a silent skip: the warning surfaces in the run summary so a + # future Flutter release adding FN records doesn't go unnoticed (and + # the floor file stays committed so the gate activates the moment + # real data appears). + # + # On the missing `coverage/lcov.summary` path: + # the gate fails CLOSED with `exit 1`. Previously this was a silent + # `exit 0` warning, which made the gate effectively advisory — a broken + # upstream filter (e.g. lcov.info missing, brew install failure) would + # skip the gate without anyone noticing. The new behaviour is: if + # `Coverage collect` did not produce a summary, the gate is red, the PR + # is blocked, and the reviewer sees exactly where the pipeline broke. + coverage-collect: + name: Coverage collect + needs: [build, golden-tests] + # Draft guard PLUS `success()`. A custom `if:` replaces the implicit + # skip-on-failed-needs, so without `success()` this job would still + # start after a red Analyze or Visual and occupy the one slot for + # up to 90 minutes. + if: (github.event_name != 'pull_request' || github.event.pull_request.draft == false) && success() + # Same self-hosted runner as Analyze & Test / Visual Regression. + # Waits until both finish so goldens are not queued behind + # instrumented tests. One slot: queue wait is not job timeout. + runs-on: [self-hosted, macOS, ARM64, m3-ultra, realunit-app] + # Instrumented floor-surface run cancelled at 30 minutes inside + # Analyze & Test (uninstrumented suite had already passed in ~2 + # minutes). 90 is the collect+filter budget on this runner. + timeout-minutes: 90 + steps: + - uses: actions/checkout@v6 + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.41.6" + channel: "stable" + cache: false + - run: flutter pub get + - run: dart run tool/generate_localization.dart + - run: dart run tool/generate_release_info.dart + - run: flutter pub run build_runner build + - name: Collect coverage for the floor surface + run: | + set -euo pipefail + cubit_dirs=$(find test -type d \( -name cubit -o -name cubits -o -name bloc \) | tr '\n' ' ') + sibling_tests=$(find test/screens \( \ + -name '*cubit*_test.dart' -o \ + -name '*bloc*_test.dart' -o \ + -name '*_state_test.dart' -o \ + -name '*_event_test.dart' \ + \) ! -path '*/cubit/*' ! -path '*/cubits/*' ! -path '*/bloc/*' | tr '\n' ' ') + states_tests=$(find test -name '*_states_test.dart' | tr '\n' ' ') + echo "cubit_dirs=${cubit_dirs}" + echo "sibling_tests=${sibling_tests}" + echo "states_tests=${states_tests}" + # Packages + cubit/bloc folders + sibling cubit specs that + # live next to pages. Do not pass feature parent dirs (those + # pull widget tests and hung `--coverage` past 58 minutes) + # and do not explode every cubit file (that hung 87 minutes + # with no log). Expanded reporter so a hang names the spec. + # shellcheck disable=SC2086 + flutter test --coverage --reporter expanded --exclude-tags golden \ + test/packages/ ${cubit_dirs} ${sibling_tests} ${states_tests} # Narrow the coverage report to the README-defined activated surface: # lib/packages/** — services, repositories, signers, utils @@ -101,10 +202,12 @@ jobs: run: | set -euo pipefail if [ ! -f coverage/lcov.info ]; then - echo "::warning::coverage/lcov.info not found — skipping coverage filter" - exit 0 + echo "::error::coverage/lcov.info not found after the floor-surface run" + exit 1 + fi + if ! command -v lcov >/dev/null; then + brew install lcov >/dev/null fi - brew install lcov >/dev/null lcov --extract coverage/lcov.info \ 'lib/packages/*' \ 'lib/screens/*/cubit/*' \ @@ -168,60 +271,23 @@ jobs: path: coverage/lcov.summary if-no-files-found: error - # Hard-fails the build when scoped coverage drops below the committed floor. - # Two flat repo-root files hold the integers: `.coverage-floor-lines` and - # `.coverage-floor-functions`. They are diffable, grep-able, and require - # no `yq`/JSON tooling in the runner — same rationale as the rest of this - # workflow: keep the gate readable in a `git blame`, not buried in YAML. - # - # Lives in its own job (not inline in `Analyze & Test`) so it can be set - # as a separately required status check in branch protection — a single - # job name on `Analyze & Test` would let the floor regress without - # blocking merge if it stayed inline. The job graph is: - # build ─► coverage-floor (sequential; needs the summary artifact) - # build ║ bitbox-audit (parallel; informational only) - # - # Ratchet protocol (also documented under README "Coverage infrastructure - # roadmap"): - # * Raising the floor is encouraged on every PR that raises measured - # coverage — bump the file in the same commit and the gate moves up. - # * Lowering the floor needs a reviewer's explicit OK. PR convention is - # the `coverage:lower-floor` label so the regression is visible at - # a glance in the PR list rather than being smuggled in. - # - # Why pure bash + awk instead of `lcov --fail-under-*`: - # the `--fail-under-lines` flag arrived in lcov 2.0 and earlier runner - # images may still pull 1.x out of the package manager. Comparing - # `52.9` against `51` with awk sidesteps that and stays portable. - # - # On the "no data found" path for the functions metric: - # `flutter test --coverage` emits LF/LH (line execution) and BRF/BRH - # (branch) records, but not FN/FNF/FNH (function execution) — that's a - # Flutter limitation, not a repo bug. When the summary reports - # "no data found" for functions, the gate emits a workflow warning - # instead of comparing against an empty value. This is intentionally - # NOT a silent skip: the warning surfaces in the run summary so a - # future Flutter release adding FN records doesn't go unnoticed (and - # the floor file stays committed so the gate activates the moment - # real data appears). - # - # On the missing `coverage/lcov.summary` path: - # the gate fails CLOSED with `exit 1`. Previously this was a silent - # `exit 0` warning, which made the gate effectively advisory — a broken - # upstream filter (e.g. lcov.info missing, brew install failure) would - # skip the gate without anyone noticing. The new behaviour is: if - # `Analyze & Test` did not produce a summary, the gate is red, the PR - # is blocked, and the reviewer sees exactly where the pipeline broke. coverage-floor: name: Coverage Floor Gate - needs: build - # Same draft guard as `build`: skip drafts, always run on push/dispatch. - # `build` already enforces this, but mirroring it here keeps the gate's - # behaviour locally readable instead of inferred from `needs:`. - if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + needs: coverage-collect + # `always()` so a failed/cancelled collect does not skip this + # required check (skipped counts as passing). `success()` stays on + # `coverage-collect` so a red Analyze/Visual does not occupy the + # one self-hosted slot. Drafts still skip. + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) runs-on: ubuntu-latest timeout-minutes: 5 steps: + - name: Require a successful collect + if: needs.coverage-collect.result != 'success' + run: | + echo "::error::Coverage collect did not succeed (result=${{ needs.coverage-collect.result }}) — floor stays red instead of skipped" + exit 1 + - uses: actions/checkout@v6 - name: Download coverage summary @@ -240,7 +306,7 @@ jobs: run: | set -euo pipefail if [ ! -f coverage/lcov.summary ]; then - echo "::error::coverage/lcov.summary not found after artifact download — the build job did not produce a scoped coverage summary" + echo "::error::coverage/lcov.summary not found after artifact download — Coverage collect did not produce a scoped coverage summary" exit 1 fi if [ ! -f .coverage-floor-lines ] || [ ! -f .coverage-floor-functions ]; then @@ -294,28 +360,21 @@ jobs: # and PRs that drift from them fail this job. Bootstrap and re-generation # is documented in `docs/visual-regression-tests.md`. # - # Runs parallel to `build` (which uses GitHub-hosted macos-latest). The - # separation is intentional: - # * `build` covers analyze + unit/widget tests + coverage — needs no - # hardware determinism. - # * `golden-tests` covers pixel-exact rendering — must run on the self-hosted runner so - # baselines and validation use identical Skia/font-rendering state. + # Shares the one-slot self-hosted runner with `build` and + # `coverage-collect`. Job purpose stays split: `build` is analyze + + # uninstrumented unit/widget tests; this job is pixel-exact goldens; + # `coverage-collect` waits for both so goldens are not starved. + # Queue wait does not count toward `timeout-minutes`. # - # On self-hosted runner outage: temporarily flip `runs-on:` to `macos-15` and regenerate + # On self-hosted runner outage: temporarily flip `runs-on:` on this + # job, `build`, and `coverage-collect` to `macos-15` and regenerate # baselines in the same PR (see `docs/visual-regression-tests.md`). golden-tests: name: Visual Regression if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: [self-hosted, macOS, ARM64, m3-ultra, realunit-app] - # 30 was sized for the era when `subosito/flutter-action@v2` still pulled - # the SDK through the Actions cache and burned ~18 minutes on a stalled - # restore plus the trailing cache-save. Without that overhead the job - # finishes in ~1 minute, so 15 is generous headroom — it still covers the - # worst normal case, where the SDK is absent from the runner's persistent - # tool cache and has to be downloaded from scratch. The lower ceiling is - # what matters here: the self-hosted runner has exactly ONE slot for this - # repo, so a wedged job blocks every other PR until it is killed. 15 hands - # the slot back twice as fast as 30. + # Keep in sync with `golden-regenerate.yaml`. A wedged golden job must + # not hold the one slot past 15 minutes. timeout-minutes: 15 steps: - uses: actions/checkout@v6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58765c8ca..4db92a177 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,7 @@ The auto-opened promotion PRs are idempotent — only one is open per branch pai - If a feature needs on-chain data (e.g. native ETH balance, transaction status, token balance), add a new endpoint to [`DFXswiss/api`](https://github.com/DFXswiss/api) and let the app call that endpoint. The API is the single gateway. - All network calls must go through `AppStore.httpClient` with `buildUri(_host, …)` — `_host` resolves to the DFX API host via `ApiConfig`. Do not instantiate `http.Client`/`Dio`/`Web3Client` against other hosts. - **One scoped exception — crash reporting.** Builds that inject `--dart-define=SENTRY_DSN=...` deliver crash reports to the company-operated crash-reporting service ([`lib/setup/error_handling/crash_reporting.dart`](lib/setup/error_handling/crash_reporting.dart)). This is first-party infrastructure telemetry, not a third-party service: without an injected DSN (all local and test builds) the SDK never starts and produces no network traffic, and the delivered data is limited to error events — no PII, no screenshots, no performance tracing, no session telemetry (the exact pinned option surface lives in `crash_reporting.dart`). Widening what is sent (breadcrumbs with request URLs, user context, attachments) is a review-blocking change, not a config tweak. +- **Second scoped exception — WalletConnect.** Reown WalletKit may talk to the WalletConnect relay (`wss://relay.walletconnect.org`) and verify (`https://verify.walletconnect.org`) **only** after the user starts a pairing, and **only** for sessions whose **Verify-attested** origin is `aktionariat.com` or `frankencoin.com` (including subdomains such as `tokeninfo.aktionariat.com`) or the Aktionariat tenant host `shares.realunit.ch`. `metadata.url` is attacker-controlled and is never the allowlist input. Verify `VALID` is required; `UNKNOWN`, `INVALID`, and `isScam` are rejected and the pairing is disconnected. Any other origin is rejected in-app with a user-visible “not supported for this provider” message. The SDK is not initialized at boot (no relay traffic with zero sessions). This is not a general-purpose dApp connector: do not add Ethereum JSON-RPC, block-explorer APIs, Pulse/analytics hosts, or other WalletConnect-adjacent endpoints. `eth_sendTransaction` is rejected (no broadcast path). Production `SecureStorage` uses an isolated Android/iOS namespace because WalletKit uses the default FlutterSecureStorage namespace and has been observed to wipe sibling keys; a one-shot migrate copies PIN/mnemonic/DB keys into the isolated store. The Cloud project id is a public client id; override via `--dart-define=WALLETCONNECT_PROJECT_ID=...` if needed. Implementation: [`lib/packages/walletconnect/`](lib/packages/walletconnect/). Widening the allowlist, skipping Verify, or initializing WalletKit at process start is a review-blocking change. ## API as Decision Authority — CRITICAL diff --git a/README.md b/README.md index ed86394f6..4ac736370 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ The transport is USB on Android and Bluetooth on iOS; the original BitBox 02 has | Receive — address + QR code | always | mvp | widget (`receive/widgets/qr_address_widget_test.dart`) + golden (`receive/receive_golden_test.dart`) | | Transaction history | always | mvp | widget (`transaction_history/transaction_history_page_test.dart`) + golden (`transaction_history/transaction_history_golden_test.dart`) | | Sell to BitBox (on-chain transfer) | hardware | defer | golden (`sell_bitbox/sell_bitbox_golden_test.dart`) | +| WalletConnect — QR scan | always | mvp | widget (`walletconnect/walletconnect_scan_scanner_navigation_test.dart`) + golden (`walletconnect/walletconnect_scan_golden_test.dart`) | +| WalletConnect — session confirm | always | mvp | widget (`walletconnect/walletconnect_session_responsive_matrix_test.dart`) + golden (`walletconnect/walletconnect_session_golden_test.dart`) | ### DFX backend integration @@ -143,7 +145,7 @@ Out of scope of the gate and tracked elsewhere: [#314](https://github.com/RealUnitCH/app/issues/314) defines a 5-tier model for BitBox-touching code: - **Tier 0 — Cubit unit tests** (`bloc_test` + `mocktail`). Fast, no platform, no BitBox. Covers every state transition. -- **Tier 1 — FakeBitbox integration tests** (`FakeBitboxCredentials` at the BitBox boundary, runs under `flutter test --coverage`). Drives multi-layer flows without hardware. Specs live under `test/integration/`. +- **Tier 1 — FakeBitbox integration tests** (`FakeBitboxCredentials` at the BitBox boundary, runs under uninstrumented `flutter test --exclude-tags golden`). Drives multi-layer flows without hardware. Specs live under `test/integration/`. - **Tier 2 — Firmware simulator** (TCP transport + Docker `bitbox02-firmware/simulator`). End-to-end with real crypto, no hardware. Planned. - **Tier 3 — Maestro flows** (`.maestro/handbook/*.yaml` for software-only flows; the BitBox02-hardware variant is deferred and has no flow files committed yet). The handbook flows run on a fresh iOS Simulator, automated via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml) — opt-in on PRs via the `tier3:full` label, always runs on push to `develop`. An upstream Maestro driver-hang/death regression on `macos-latest` runners makes intermittent first-attempt failures expected (hang, or XCUITest-driver death with ConnectException on `:7001`); `scripts/run-handbook-flows.sh` retries that class up to 3× per flow by matching the CLI tee-log and `--debug-output` `maestro.log` (CI-hardening work originally tracked in [#487](https://github.com/RealUnitCH/app/issues/487), now closed). The hardware variant remains manually triggered before each release until Phase 3 of [#314](https://github.com/RealUnitCH/app/issues/314) lands. - **Tier 4 — BLE VCR / replay** (capture on hardware once, replay deterministically). Stretch — most of its value is covered by Tier 2 + Tier 3 in tandem. @@ -158,13 +160,13 @@ Non-BitBox code only needs Tier 0 + widget tests; Tier 1+ are reserved for hardw | Coverage | `flutter test --coverage` | Writes `coverage/lcov.info`. CI narrows it to the activated surface and hard-fails when scoped coverage drops below the floor in `.coverage-floor-lines` / `.coverage-floor-functions`. See "Coverage infrastructure roadmap" above for the ratchet protocol. | | Analyzer | `flutter analyze` | Dart static analysis per `analysis_options.yaml` | -Tier 1 specs live under `test/integration/**` and run inside the same `flutter test --coverage` invocation as Tier 0 — no separate `integration_test/` harness today (that Flutter-convention directory is reserved for on-device runs that are not yet wired up). Referral widget flows live under `test/screens/referral/` (including the former `integration_test/referral_*_e2e_test.dart` specs, now at `test/screens/referral/flows/`) and run in that same coverage invocation. Tier 3 handbook flows (iOS Simulator) are wired via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml); the BitBox02 hardware variant remains deferred. +Tier 1 specs live under `test/integration/**` and run inside the same uninstrumented `flutter test --exclude-tags golden` invocation as Tier 0 — no separate `integration_test/` harness today (that Flutter-convention directory is reserved for on-device runs that are not yet wired up). Referral widget flows live under `test/screens/referral/` (including the former `integration_test/referral_*_e2e_test.dart` specs, now at `test/screens/referral/flows/`) and run in that same pass/fail invocation. CI then collects `lcov` from `test/packages/`, cubit/bloc directories, and sibling cubit specs under `test/screens/`. Tier 3 handbook flows (iOS Simulator) are wired via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml); the BitBox02 hardware variant remains deferred. ## CI/CD | Workflow | Trigger | Action | | ---------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `pull-request.yaml` | Any PR except PRs to `main` · push `develop` · manual | Runs `flutter analyze` + `flutter test --coverage --exclude-tags golden`, enforces scoped coverage, validates Goldens on the self-hosted runner, and uploads diagnostics. Jobs: `Analyze & Test`, `Coverage Floor Gate`, `Visual Regression`, `BitBox quirks audit`. | +| `pull-request.yaml` | Any PR except PRs to `main` · push `develop` · manual | Runs `flutter analyze` + uninstrumented `flutter test --exclude-tags golden` on `Analyze & Test`, validates Goldens on `Visual Regression`, then `--coverage` on `test/packages/`, cubit/bloc directories, and sibling cubit specs in `Coverage collect` (after both, so the one self-hosted slot is not starved), and enforces scoped coverage. Jobs: `Analyze & Test`, `Visual Regression`, `Coverage collect`, `Coverage Floor Gate`, `BitBox quirks audit`. | | `staging-ci-fallback.yaml` | Push `staging` · manual | Routes staging CI without cross-event cancellation: a ready `staging → develop` PR owns canonical CI; otherwise it dispatches `pull-request.yaml` on `staging` as a fail-safe. | | `tier3-handbook.yaml` | Any PR except PRs to `main`, with label `tier3:full` · push `develop` · manual | Tier-3 navigation/tap-routing smoke: runs every `.maestro/handbook/*.yaml` flow on a fresh `iPhone 17` simulator and uploads diagnostic captures (`build/handbook-captures/`) as a build artifact. Pixel drift on the page renders is owned by `Visual Regression` in `pull-request.yaml`, not this job. | | `bitbox-simulator.yml` | Any PR except PRs to `main` touching `lib/packages/hardware_wallet/**`, `lib/packages/wallet/**`, `lib/screens/hardware_connect_bitbox/**`, their test mirrors, `pubspec.yaml`, or the workflow itself · manual | Runs the BitBox02 firmware simulator with `bitbox-testkit` baselines (Tier 2) | diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ad11633a0..e152d98aa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ +