Skip to content
Open
13 changes: 9 additions & 4 deletions .github/workflows/tier3-handbook.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ name: Tier 3 — Handbook flows
# reflow elements out of the tap-coordinate path or shift safe-area
# insets in a way that breaks the assertions. When the macOS runner
# image stops shipping iPhone 17 by default, bump this here AND
# re-verify all 26 flows on the new device (run
# re-verify all 28 flows on the new device (run
# `scripts/run-handbook-flows.sh` locally; assertion failures point
# at flows that need their coordinates / waits updated).
#
Expand Down Expand Up @@ -133,8 +133,8 @@ jobs:
|| contains(github.event.pull_request.labels.*.name, 'tier3:full')
runs-on: macos-latest
# ~12 min setup (Flutter + tooling, build, sim erase/boot/install) plus
# ~26-33 min for the 26 handbook flows — each flow restarts the XCUITest
# driver (~40-60 s). A full run lands around 40-46 min; 60 min gives
# ~28-36 min for the 28 handbook flows — each flow restarts the XCUITest
# driver (~40-60 s). A full run lands around 42-50 min; 60 min gives
# headroom for runner-speed variance and the per-flow retry budget.
timeout-minutes: 60
steps:
Expand Down Expand Up @@ -182,7 +182,12 @@ jobs:
ios-derived-data-${{ runner.os }}-${{ steps.xcode.outputs.version }}-

- name: Build iOS simulator app
run: flutter build ios --simulator --debug
# MAESTRO_MOCK enables in-process HTTP mocking for authenticated
# flows (KYC, add-wallet) that need canned API responses. Only
# KYC/registration paths are intercepted; all other requests
# (auth, dashboard, prices, balances) pass through to the real
# DFX API so existing handbook flows work without modification.
run: flutter build ios --simulator --debug --dart-define=MAESTRO_MOCK=true

# `scripts/run-handbook-flows.sh` does its own `simctl shutdown / erase /
# boot / bootstatus / install` once it has a booted UDID. We just need
Expand Down
47 changes: 47 additions & 0 deletions .maestro/handbook/11a-kyc-link-wallet.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Tier-3 E2E: KYC "add wallet" (link wallet) flow — part 1 of 2.
#
# Prerequisite: flow 11-dashboard must have run (app is on dashboard with
# a software wallet, PIN verified). This flow relies on the MAESTRO_MOCK
# build flag: tapping "RealUnit kaufen" opens /buy, and the mock mode
# redirects /buy → /kyc immediately. The mock API then returns
# `AddWallet` registration info (after the legal disclaimer is accepted),
# routing the app to the KycLinkWalletPage.
#
# Regression guard for PR #720: this flow verifies that the link-wallet
# page renders the "shareholder confirmed" UI and is ready for the
# BitBox-connect sheet test in flow 11b.
appId: swiss.realunit.app
---
# Navigate from dashboard to KYC via the buy-page redirect (mock mode only).
- repeat:
times: 3
commands:
- runFlow:
when:
notVisible: 'Rechtlicher Hinweis'
commands:
- tapOn:
text: 'RealUnit kaufen'
optional: true
- waitForAnimationToEnd

# The legal disclaimer has 5 steps (0-4). Each step shows a "Zustimmen" / "Ablehnen"
# button pair — "Zustimmen" advances, on the last step it completes and triggers
# checkKyc(). With the mock API the next route is KycLinkWalletPage.
- extendedWaitUntil:
visible: 'Rechtlicher Hinweis'
timeout: 15000

- repeat:
times: 5
commands:
- tapOn:
text: 'Zustimmen'
- waitForAnimationToEnd

# After accepting the legal disclaimer, checkKyc() fetches registration
# info from the mock → state=AddWallet → KycLinkWalletPage renders with
# the shareholder-confirmation description.
- extendedWaitUntil:
visible: 'Sie sind als Aktionär eingetragen'
timeout: 30000
84 changes: 84 additions & 0 deletions .maestro/handbook/11b-kyc-link-wallet-bitbox-sheet.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Tier-3 E2E: KYC "add wallet" (link wallet) BitBox-connect sheet — part 2 of 2.
#
# Prerequisite: flow 11a-kyc-link-wallet must have run (app is on
# KycLinkWalletPage with the shareholder-confirmation body visible).
#
# Regression guard for PR #720: verifies that tapping "Wallet hinzufügen"
# WITHOUT a connected BitBox opens the ConnectBitboxPage bottom sheet
# instead of surfacing a dead-end red "BitBox is not connected" error.
#
# The MaestroRegistrationService (active under MAESTRO_MOCK) throws
# BitboxNotConnectedException on the first registerWallet() call, which
# the page catches and translates into the connect-sheet UI.
# Retry (after a successful connect) calls through to the real service
# and the mock API returns {"status":"completed"} — covered by widget
# tests, not repeated here.
#
# The sheet is dismissed by tapping the background scrim (top ~20% of
# screen where the sheet does not reach). After dismissal the page must
# still show the link-wallet body so the user can try again.
#
# Finally, the flow navigates back to the dashboard (through the buy
# page that redirected here) so the next handbook flow (12-settings)
# starts from the expected state.
appId: swiss.realunit.app
---
# Tap the submit button. The title "Wallet hinzufügen" (AppBar) and the
# submit button share the same i18n string; we use a bottom-center
# coordinate to reliably hit the button.
- repeat:
times: 3
commands:
- runFlow:
when:
notVisible: 'BitBox verbinden'
commands:
- tapOn:
point: '50%,88%'
optional: true
- waitForAnimationToEnd

# The connect sheet slides up with title "BitBox verbinden". The initial
# state is BitboxConnecting (scanning for devices). Just seeing the
# sheet confirms the regression guard — the bug (#720) would have shown
# a SnackBar error instead.
- extendedWaitUntil:
visible: 'BitBox verbinden'
timeout: 15000

# Dismiss the sheet by tapping outside (top portion of the screen where
# the scrim is visible). The sheet covers ~80% of the screen height, so
# the top ~10% is safe scrim area.
- tapOn:
point: '50%,10%'
- waitForAnimationToEnd

# After dismissal, the link-wallet body must still be visible — the user
# is not stuck on an error page and can try again.
- extendedWaitUntil:
visible: 'Sie sind als Aktionär eingetragen'
timeout: 10000

# Navigate back to the dashboard for the next flow (12-settings).
# Single retry-gated loop: taps the back button (top-left) until
# "RealUnit kaufen" becomes visible. The first tap pops the KYC route
# back to the buy page; the second tap pops the buy page back to the
# dashboard where the buy-action button is visible and the gate stops.
# Up to 5 attempts covers tap-loss on Apple Silicon + iOS 26
# (mobile-dev-inc/maestro#3137).
- repeat:
times: 5
commands:
- runFlow:
when:
notVisible: 'RealUnit kaufen'
commands:
- tapOn:
point: '8%,8%'
optional: true
- waitForAnimationToEnd

# Confirm we landed on the dashboard.
- extendedWaitUntil:
visible: 'RealUnit kaufen'
timeout: 15000
5 changes: 3 additions & 2 deletions lib/packages/service/app_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import 'package:realunit_wallet/packages/wallet/wallet.dart';
class AppStore {
final ApiConfig Function() getApiConfig;
final SessionCache sessionCache;
final Client httpClient = RealUnitApiClient();
final Client httpClient;

AWallet? _wallet;

AppStore(this.getApiConfig, this.sessionCache);
AppStore(this.getApiConfig, this.sessionCache, [Client? httpClient])
: httpClient = httpClient ?? RealUnitApiClient();

set wallet(AWallet wallet_) => _wallet = wallet_;

Expand Down
175 changes: 175 additions & 0 deletions lib/packages/service/dfx/maestro_mock_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import 'dart:async';
import 'dart:convert';

import 'package:http/http.dart';

/// Intercepts HTTP calls when the app is compiled with
/// `--dart-define=MAESTRO_MOCK=true` and returns canned JSON responses for
/// the DFX API endpoints the KYC / link-wallet flow needs.
///
/// Auth (`POST /v1/auth`) is intercepted and returns a synthetic token so
/// authenticated KYC / registration calls succeed without hitting the real
/// DFX API. Only KYC and registration endpoints that the mock must control
/// are intercepted; all other calls pass through to the real API.
///
/// Unknown paths are forwarded to the real DFX API via [_inner].
class MaestroMockClient extends BaseClient {
static const _mockKycHash = 'maestro-kyc-hash';

final Client _inner;

MaestroMockClient([Client? inner]) : _inner = inner ?? Client();

/// Whether the current build was compiled with `MAESTRO_MOCK=true`.
static bool get inMaestroMockMode =>
const bool.fromEnvironment('MAESTRO_MOCK', defaultValue: false);

@override
Future<StreamedResponse> send(BaseRequest request) async {
final path = request.url.path;
final method = request.method;

final resp = _response(method, path);
if (resp != null) return resp;

// Pass through to the real DFX API. The app has a real auth token
// (we don't intercept /v1/auth), so dashboard / price / balance
// calls hit api.dfx.swiss with valid credentials.
return _inner.send(request);
}

StreamedResponse? _response(String method, String path) {
switch (path) {
case '/v1/auth':
if (method == 'POST') {
return _json(201, {'accessToken': 'maestro-mock-token'});
}
break;
case '/v2/user':
if (method == 'GET') {
return _json(200, _user());
}
if (method == 'PUT') {
return _json(200, _user());
}
break;
case '/v2/kyc':
if (method == 'GET') {
return _json(200, _kycStatus());
}
if (method == 'PUT') {
return _json(200, _continueKycResponse());
}
break;
case '/v1/realunit/registration':
if (method == 'GET') {
return _json(200, {
'state': 'AddWallet',
'userData': _userData(),
});
}
break;
case '/v1/realunit/register/wallet':
if (method == 'POST') {
return _json(201, {'status': 'completed'});
}
break;
case '/v1/realunit/register/email':
if (method == 'POST') {
return _json(201, {'status': 'completed'});
}
break;
}
return null;
}

Map<String, dynamic> _user() => {
'mail': 'shareholder@example.com',
'kyc': {
'hash': _mockKycHash,
'level': 10,
'dataComplete': true,
},
'capabilities': <String, dynamic>{},
};

Map<String, dynamic> _kycStatus() => {
'kycLevel': 10,
'kycSteps': _kycSteps(),
'processStatus': 'InProgress',
};

Map<String, dynamic> _continueKycResponse() => {
'kycLevel': 10,
'kycSteps': _kycSteps(),
'processStatus': 'InProgress',
'currentStep': {
'name': 'ContactData',
'status': 'Completed',
'sequenceNumber': 0,
'isCurrent': true,
'session': {
'url': 'https://localhost:3000/v2/kyc/session/1',
'type': 'API',
},
},
};

List<Map<String, dynamic>> _kycSteps() => [
{
'name': 'ContactData',
'status': 'Completed',
'sequenceNumber': 0,
'isCurrent': false,
'isRequired': true,
},
{
'name': 'RealUnitRegistration',
'status': 'Completed',
'sequenceNumber': 1,
'isCurrent': false,
'isRequired': true,
},
];

Map<String, dynamic> _userData() => {
'email': 'shareholder@example.com',
'name': 'Max Mustermann',
'type': 'Personal',
'phoneNumber': '+41791234567',
'birthday': '1990-01-01',
'nationality': 'CH',
'addressStreet': 'Bahnhofstrasse 1',
'addressPostalCode': '8001',
'addressCity': 'Zürich',
'addressCountry': 'CH',
'swissTaxResidence': true,
'lang': 'DE',
'kycData': {
'accountType': 'Personal',
'firstName': 'Max',
'lastName': 'Mustermann',
'phone': '+41791234567',
'address': {
'street': 'Bahnhofstrasse',
'houseNumber': '1',
'zip': '8001',
'city': 'Zürich',
'country': 1,
},
},
};

StreamedResponse _json(int code, Object body) {
final bytes = utf8.encode(jsonEncode(body));
return StreamedResponse(
Stream.value(bytes),
code,
contentLength: bytes.length,
headers: {'content-type': 'application/json'},
);
}

@override
void close() => _inner.close();
}
27 changes: 27 additions & 0 deletions lib/packages/service/dfx/maestro_registration_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart';
import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart';

/// Overrides [RealUnitRegistrationService.registerWallet] so the first call
/// throws [BitboxNotConnectedException] — exactly the scenario the KYC
/// link-wallet connect sheet guards against (bug fix PR #720).
///
/// Subsequent calls delegate to the real service. The state resets when the
/// app process restarts, matching the Maestro handbook flow lifecycle.
class MaestroRegistrationService extends RealUnitRegistrationService {
MaestroRegistrationService(super.appStore, super.walletService);

bool _firstCall = true;

@override
Future<RegistrationStatus> registerWallet(
RealUnitUserDataDto userData,
) async {
if (_firstCall) {
_firstCall = false;
throw const BitboxNotConnectedException();
}
return super.registerWallet(userData);
}
}
Loading
Loading