diff --git a/.github/workflows/swift-example-app-ui-smoke.yml b/.github/workflows/swift-example-app-ui-smoke.yml index 46fe77ac076..af837c0ac7e 100644 --- a/.github/workflows/swift-example-app-ui-smoke.yml +++ b/.github/workflows/swift-example-app-ui-smoke.yml @@ -2,6 +2,20 @@ name: Swift Example App UI Smoke 'on': workflow_dispatch: + schedule: + # 23:00 UTC daily. Aligns with the main `Tests` workflow's nightly + # cron (also 23:00 UTC) but runs on a different runner pool + # (self-hosted macOS), so they don't compete for compute. The Swift + # discovery test hits testnet DAPI; if that becomes a contention + # signal, shift to 02:00 UTC. + - cron: "0 23 * * *" + +concurrency: + # Prevents an in-flight manual dispatch from being clobbered by the + # cron firing (or vice versa) on the single self-hosted Mac. Cancels + # the older run in favor of the newer one. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true permissions: contents: read @@ -161,6 +175,12 @@ jobs: env: SIM_UDID: ${{ steps.simulator.outputs.udid }} RESULT_BUNDLE_PATH: ${{ runner.temp }}/SwiftExampleAppUITests.xcresult + # Forwarded into the XCUITest runner as `UI_TEST_MNEMONIC` — + # `xcodebuild test` strips the `TEST_RUNNER_` prefix before + # passing env vars through to the test process. Empty on PRs + # from forks (GitHub withholds secrets there), and the test + # self-skips when the value is empty. + TEST_RUNNER_UI_TEST_MNEMONIC: ${{ secrets.UI_TEST_MNEMONIC }} run: | set -euo pipefail xcodebuild test \ @@ -168,18 +188,28 @@ jobs: -scheme SwiftExampleApp \ -destination "platform=iOS Simulator,id=$SIM_UDID" \ -only-testing:SwiftExampleAppUITests/SwiftExampleAppUITests/testCreateGeneratedWalletFlow \ + -only-testing:SwiftExampleAppUITests/WalletPersistenceTests/testWalletPersistsAcrossRelaunch \ + -only-testing:SwiftExampleAppUITests/WalletPersistenceTests/testWalletDeletionCleanupSurvivesRelaunch \ + -only-testing:SwiftExampleAppUITests/CreditTransferTest/testImportWalletAndDiscoverIdentity \ -parallel-testing-enabled NO \ -maximum-concurrent-test-simulator-destinations 1 \ -resultBundlePath "$RESULT_BUNDLE_PATH" - name: Upload XCUITest result bundle - if: always() + # `failure()` only — xcresult bundles include the XCUITest activity + # log (which records `typeText` arguments) and may include failure + # screenshots. `importWallet` types the testnet mnemonic into a + # plain TextField, so a successful run's artifact would archive + # the mnemonic in the activity log. Restricting to failures + # narrows the leak surface (you only get the artifact when there's + # something to debug). 7-day retention also reduces exposure. + if: failure() uses: actions/upload-artifact@v4 with: name: SwiftExampleAppUITests-xcresult path: ${{ runner.temp }}/SwiftExampleAppUITests.xcresult if-no-files-found: ignore - retention-days: 14 + retention-days: 7 - name: Delete disposable simulator if: always() && steps.simulator.outputs.udid != '' diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index b56e46c4175..c86e93ed62e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -9,12 +9,27 @@ class AppState: ObservableObject { @Published var showError = false @Published var errorMessage = "" + /// `true` from the moment a network change is requested until the + /// new SDK is bound. Spans the full async cycle (didSet → Task → + /// `switchNetwork` → `sdk = newSDK`), so consumers can wait on it + /// as a real readiness signal. UI bindings should treat + /// `appState.sdk != nil && !isSwitchingNetwork` as "connected on + /// the current network" — `appState.sdk != nil` alone is true even + /// while `switchNetwork` is still tearing down the previous SDK. + @Published var isSwitchingNetwork: Bool = false + + /// Monotonic request id for in-flight switches. If two switches + /// overlap (user taps mainnet → testnet before the first lands), the + /// earlier task's completion would otherwise clear `isSwitchingNetwork` + /// while the later switch is still running. Each new request bumps + /// this counter and the spawned task only clears the flag when its + /// captured id still matches. + private var networkSwitchRequestID: UInt64 = 0 + @Published var currentNetwork: Network { didSet { - UserDefaults.standard.set(Int(currentNetwork.rawValue), forKey: "currentNetwork") - Task { - await switchNetwork(to: currentNetwork) - } + UserDefaults.standard.set(currentNetwork.rawValue, forKey: "currentNetwork") + beginNetworkSwitch() } } @@ -27,7 +42,23 @@ class AppState: ObservableObject { UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhostPlatform") UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhostCore") UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhost") - Task { await switchNetwork(to: currentNetwork) } + beginNetworkSwitch() + } + } + + /// Bumps `networkSwitchRequestID`, raises `isSwitchingNetwork`, and + /// spawns the SDK-rebuild task. Only the task that owns the latest + /// request id may lower `isSwitchingNetwork` again — overlapping + /// switches' earlier tasks no-op on completion. + private func beginNetworkSwitch() { + networkSwitchRequestID &+= 1 + let requestID = networkSwitchRequestID + isSwitchingNetwork = true + Task { + await switchNetwork(to: currentNetwork) + if requestID == networkSwitchRequestID { + isSwitchingNetwork = false + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 2bb6b5cb9b2..e45c2e4f365 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -86,6 +86,7 @@ struct ContentView: View { // Tab 3: Identities IdentitiesTabView() + .accessibilityIdentifier("rootTab.identities") .tabItem { Label("Identities", systemImage: "person.crop.circle") } @@ -107,6 +108,7 @@ struct ContentView: View { // Tab 5: Settings (includes Platform section) SettingsView() + .accessibilityIdentifier("rootTab.settings") .tabItem { Label("Settings", systemImage: "gearshape") } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index e8a9171605c..e995c1f8670 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -136,6 +136,7 @@ struct CreateWalletView: View { Section { Toggle("Import Existing Wallet", isOn: $showImportOption) + .accessibilityIdentifier("createWallet.importToggle") } header: { Text("Options") } @@ -164,6 +165,7 @@ struct CreateWalletView: View { .autocorrectionDisabled() .lineLimit(3...6) .focused($focusedField, equals: .mnemonic) + .accessibilityIdentifier("createWallet.mnemonicField") } header: { Text("Recovery Phrase") } footer: { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 36164a25941..48cdef5dca0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -136,9 +136,11 @@ struct IdentitiesContentView: View { } label: { Label("Re-scan for Identities", systemImage: "magnifyingglass") } + .accessibilityIdentifier("identities.searchWalletsMenuItem") } label: { Image(systemName: "plus") } + .accessibilityIdentifier("identities.addMenu") } } .sheet(isPresented: $showingLoadIdentity) { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift index c08b024f1af..cb8adb8e5a3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift @@ -100,6 +100,7 @@ struct IdentityRow: View { } .padding(.vertical, 4) } + .accessibilityIdentifier("identities.row.\(identity.identityIdBase58)") } private func refreshBalance() async { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index 1c9b614e3ed..d48aa259461 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -126,6 +126,12 @@ struct IdentityDetailView: View { Text(identity.formattedBalance) .foregroundColor(.blue) .fontWeight(.medium) + .accessibilityIdentifier("identityDetail.balanceLabel") + // Display string is "%.8f DASH" — rounding hides + // sub-1000-credit deltas. Expose the raw credit + // count via accessibilityValue for tests that + // need exact numbers. + .accessibilityValue("\(UInt64(bitPattern: identity.balance))") } // Top-up entry point. Hidden for purely-local rows diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 62272919947..4363c78a7de 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -9,7 +9,6 @@ struct OptionsView: View { @State private var showingDataManagement = false @State private var showingAbout = false @State private var showingContracts = false - @State private var isSwitchingNetwork = false @State private var sdkStatus: SDKStatus? @State private var isLoadingStatus = false @@ -50,27 +49,22 @@ struct OptionsView: View { get: { appState.currentNetwork }, set: { newNetwork in if newNetwork != appState.currentNetwork { - isSwitchingNetwork = true - Task { - // Auto-disable Docker when leaving Local - if newNetwork != .regtest && appState.useDockerSetup { - appState.useDockerSetup = false - } - - // Update platform state (which will trigger SDK switch) - appState.currentNetwork = newNetwork - - // Reset per-network services. TODO(platform-wallet): - // Once PlatformWalletManager supports network - // switching cleanly, call into it here. - try? walletManager.stopSpv() - platformBalanceSyncService.reset() - shieldedService.reset() - - await MainActor.run { - isSwitchingNetwork = false - } + // Auto-disable Docker when leaving Local + if newNetwork != .regtest && appState.useDockerSetup { + appState.useDockerSetup = false } + + // `currentNetwork.didSet` (in AppState) flips + // `isSwitchingNetwork` for us and awaits the + // SDK rebind, so the status label below stays + // in the switching state across the entire + // async cycle. Reset per-network services + // alongside the switch — these don't gate + // readiness, they just clean up stale UI. + appState.currentNetwork = newNetwork + try? walletManager.stopSpv() + platformBalanceSyncService.reset() + shieldedService.reset() } } )) { @@ -79,17 +73,14 @@ struct OptionsView: View { } } .pickerStyle(SegmentedPickerStyle()) - .disabled(isSwitchingNetwork) + .disabled(appState.isSwitchingNetwork) + .accessibilityIdentifier("options.networkPicker") if appState.currentNetwork == .regtest { + // `useDockerSetup.didSet` (in AppState) drives the + // SDK rebuild and `isSwitchingNetwork`; no view-side + // onChange is needed. Toggle("Use Docker Setup", isOn: $appState.useDockerSetup) - .onChange(of: appState.useDockerSetup) { _, _ in - isSwitchingNetwork = true - Task { - await appState.switchNetwork(to: appState.currentNetwork) - await MainActor.run { isSwitchingNetwork = false } - } - } .help("Connect to local dashmate Docker network.") if appState.useDockerSetup { @@ -129,23 +120,29 @@ struct OptionsView: View { HStack { Text("Network Status") Spacer() - if isSwitchingNetwork { - HStack(spacing: 4) { - ProgressView() - .scaleEffect(0.8) - Text("Switching...") + Group { + if appState.isSwitchingNetwork { + HStack(spacing: 4) { + ProgressView() + .scaleEffect(0.8) + Text("Switching...") + .font(.caption) + .foregroundColor(.secondary) + } + } else if appState.sdk != nil { + Label("Connected", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundColor(.green) + } else { + Label("Disconnected", systemImage: "xmark.circle.fill") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.red) } - } else if appState.sdk != nil { - Label("Connected", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundColor(.green) - } else { - Label("Disconnected", systemImage: "xmark.circle.fill") - .font(.caption) - .foregroundColor(.red) } + // Tests wait on this label transitioning to "Connected" + // after a network switch (signal-based, not sleep-based). + .accessibilityElement(children: .combine) + .accessibilityIdentifier("options.networkStatusLabel") } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift index bdf78340f81..d5abe8a4eac 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift @@ -147,6 +147,7 @@ struct SearchWalletsForIdentitiesView: View { } .pickerStyle(.menu) .disabled(isSearching || hdWallets.count < 1) + .accessibilityIdentifier("searchWallets.walletPicker") } } } @@ -169,6 +170,7 @@ struct SearchWalletsForIdentitiesView: View { Text("+\(finding.foundCount)") .fontWeight(.semibold) .foregroundColor(finding.foundCount > 0 ? .green : .secondary) + .accessibilityIdentifier("searchWallets.foundCountLabel") } if let err = finding.error { // No `.lineLimit` — identity-derivation errors can @@ -298,6 +300,7 @@ struct SearchWalletsForIdentitiesView: View { || selectedWalletId == nil || selectedManagedWallet == nil ) + .accessibilityIdentifier("searchWallets.searchButton") if selectedWalletId != nil && selectedManagedWallet == nil { Text("This wallet isn't loaded in the wallet manager yet. " + "Restore it from the Wallets tab and try again.") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index bd66ced4f58..bc8f6cff596 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -158,6 +158,7 @@ struct TransitionDetailView: View { ForEach(identities, id: \.identityIdBase58) { identity in Text(identity.displayName) .tag(identity.identityIdBase58) + .accessibilityIdentifier("transition.senderIdentityOption.\(identity.identityIdBase58)") } } .pickerStyle(MenuPickerStyle()) @@ -165,6 +166,7 @@ struct TransitionDetailView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color.gray.opacity(0.1)) .cornerRadius(8) + .accessibilityIdentifier("transition.senderIdentityPicker") } } } @@ -188,6 +190,7 @@ struct TransitionDetailView: View { .foregroundColor(.white) .cornerRadius(10) .disabled(!enabled) + .accessibilityIdentifier("transition.executeButton") } private var resultView: some View { @@ -197,6 +200,7 @@ struct TransitionDetailView: View { .foregroundColor(isError ? .red : .green) Text(isError ? "Error" : "Success") .font(.headline) + .accessibilityIdentifier("transition.resultStatusLabel") Spacer() Button("Copy") { UIPasteboard.general.string = resultText diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionInputView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionInputView.swift index ca901e08174..64062c90413 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionInputView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionInputView.swift @@ -191,6 +191,7 @@ struct TransitionInputView: View { } } .padding(.vertical, 4) + .accessibilityIdentifier("transition.input.\(input.name)") } @ViewBuilder @@ -539,6 +540,7 @@ struct TransitionInputView: View { .foregroundColor(.white) .cornerRadius(8) } + .accessibilityIdentifier("transition.input.\(input.name).manualEntryButton") } } else { Picker("Select Identity", selection: $value) { @@ -560,11 +562,13 @@ struct TransitionInputView: View { useManualEntry = true } } + .accessibilityIdentifier("transition.input.\(input.name).recipientPicker") } } else { VStack(alignment: .leading, spacing: 8) { TextField("Enter recipient identity ID", text: $value) .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier("transition.input.\(input.name).manualEntryField") if !identities.isEmpty { Button(action: { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift index fff8bd68551..144f300b914 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift @@ -175,7 +175,7 @@ final class KeyManagerTests: XCTestCase { ]) // Encode to WIF - guard let wif = KeyFormatter.toWIF(originalKey, isTestnet: true) else { + guard let wif = KeyFormatter.toWIF(originalKey, network: .testnet) else { XCTFail("Failed to encode to WIF") return } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift new file mode 100644 index 00000000000..473a6a35737 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift @@ -0,0 +1,144 @@ +// +// CreditTransferTest.swift +// SwiftExampleAppUITests +// +// Imports a wallet from a known testnet mnemonic that already has a +// registered identity, runs identity discovery, and asserts that the +// expected identity surfaces with a non-zero balance. The fixture +// identity is intentionally pre-funded; if it ever drains, top it up +// rather than weaken the assertion (otherwise a regression that breaks +// balance discovery would render `0` and silently pass — +// IdentityDetailView.onAppear doesn't refresh balance). The credit- +// transfer assertion itself is deferred to a follow-up. +// +// Skipped automatically when the env var is unset, so the rest of the +// suite can run locally without test-network credentials. +// +// Env var: +// * UI_TEST_MNEMONIC — sender wallet's 12-word phrase +// +// Note on env var forwarding: a previous run on this branch showed that +// `xcodebuild test ENV=...` did not propagate env vars to the XCUITest +// runner — only the prefix form `TEST_RUNNER_` reached the test +// process (Xcode strips the prefix). Try the unprefixed form first; if +// the env var doesn't reach the test, use the prefixed form. +// + +import XCTest + +final class CreditTransferTest: XCTestCase { + // The two constants below are deterministic functions of the + // mnemonic stored in the `UI_TEST_MNEMONIC` GitHub Actions secret. + // **They MUST be regenerated whenever the secret is rotated** — a + // mismatched fixture will surface as an opaque + // `waitForIdentityRow` timeout ("identity row not found within + // 60s") with no breadcrumb pointing here. + // + // Regeneration steps (manual, one-time per rotation): + // 1. `simctl erase` a fresh simulator. + // 2. Run `testImportWalletAndDiscoverIdentity` locally with the + // new mnemonic in `TEST_RUNNER_UI_TEST_MNEMONIC`. + // 3. Watch the Wallets tab for the `wallets.walletRow.` + // identifier on the imported wallet — that hex is + // `expectedSenderWalletIdHex`. + // 4. After discovery, watch the Identities tab for the + // `identities.row.` identifier on the discovered + // identity — that base58 is `expectedSenderIdentityIdBase58`. + // 5. Paste both here and update the PR. + + /// The pre-registered identity behind the sender mnemonic. Discovery + /// must surface this exact ID — that's the regression check on the + /// discovery path. + private let expectedSenderIdentityIdBase58 = "3ou98WEERy6ExmmHWYWsFtyhgW8rmr1giceZYTFqdAAA" + + /// walletId derived from the test mnemonic (deterministic). Lets us + /// detect an already-imported wallet from a prior run and reuse it + /// instead of failing on `Wallet operation: Wallet already exists`. + private let expectedSenderWalletIdHex = "2450ec6b6dc2b1b0476875305a6870dee743d47474e3838642b655b68a600793" + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testImportWalletAndDiscoverIdentity() throws { + guard let mnemonic = ProcessInfo.processInfo.environment["UI_TEST_MNEMONIC"], + !mnemonic.isEmpty + else { + throw XCTSkip("Set UI_TEST_MNEMONIC to run this test.") + } + XCTAssertEqual( + mnemonic.split(separator: " ").count, + 12, + "UI_TEST_MNEMONIC must be a 12-word phrase." + ) + + let app = XCUIApplication() + app.launch() + failIfRecoveryPromptVisible(in: app, timeout: 2) + + // Force testnet — the simulator may have been left on a non-testnet + // network by previous runs. Idempotent if already on Testnet. + switchAppNetworkToTestnet(in: app) + // Re-run the recovery-prompt guard. A leftover testnet wallet on a + // simulator booted to a different default network only triggers + // the orphan-mnemonic prompt after the network switch rebinds + // wallet-scoped services, so the pre-switch guard above misses it. + failIfRecoveryPromptVisible(in: app, timeout: 2) + + openWalletsTab(in: app) + + // Sweep wallets from prior failed runs of this test. Each run uses + // a random `ImportTransfer-` name, so the deterministic + // walletId-based check below misses them — they accumulate on the + // simulator and eventually clash with `importWallet` (`Wallet + // already exists`). + cleanupWalletsByPrefix("ImportTransfer-", in: app) + + // Wallets restored from the persister on cold launch come back + // watch-only — the SwiftExampleApp comment in SwiftExampleAppApp.swift + // notes that biometric unlock to rehydrate signing keys is "future + // work". Identity discovery needs private keys, so a leftover + // wallet from a prior run is unusable. Delete it (if present) and + // re-import to get a hot, signing-capable wallet. + let existingRow = app.descendants(matching: .any) + .matching(identifier: "wallets.walletRow.\(expectedSenderWalletIdHex)") + .firstMatch + if existingRow.waitForExistence(timeout: 5) { + // accessibilityLabel == wallet.label, so .label gives us the name. + let staleName = existingRow.label + bestEffortDeleteWallet(named: staleName, in: app) + } + + let walletName = "ImportTransfer-\(UUID().uuidString.prefix(6))" + addTeardownBlock { + MainActor.assumeIsolated { + let cleanupApp = XCUIApplication() + cleanupApp.launch() + bestEffortDeleteWallet(named: walletName, in: cleanupApp) + cleanupApp.terminate() + } + } + importWallet(named: walletName, mnemonic: mnemonic, in: app) + assertWalletRowVisible(named: walletName, in: app, exists: true, timeout: 30) + + openIdentitiesTab(in: app) + runIdentityDiscovery(forWalletNamed: walletName, in: app) + + let senderRow = waitForIdentityRow(idBase58: expectedSenderIdentityIdBase58, in: app) + senderRow.tap() + + // The fixture identity is pre-funded; assert > 0 so a regression + // that breaks balance discovery (returns 0) actually fails this + // test. `IdentityDetailView.onAppear` only refreshes + // DPNS/DashPay/tokens — it doesn't refresh balance — so without + // this floor the assertion would only check parseability and a + // 0-credit render would silently pass. + let credits = readIdentityBalanceCredits(in: app) + XCTAssertGreaterThan( + credits, + 0, + "Expected discovered identity \(expectedSenderIdentityIdBase58) to surface a non-zero balance. If the testnet fixture identity has drained, top it up rather than weaken this assertion." + ) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/README.md b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/README.md new file mode 100644 index 00000000000..0573317b76b --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/README.md @@ -0,0 +1,64 @@ +# SwiftExampleApp UI Tests + +XCUITest suite that drives the SwiftExampleApp through real user flows on the iOS Simulator. + +## Tests in the suite + +- `SwiftExampleAppUITests.testCreateGeneratedWalletFlow` — generates a fresh wallet end-to-end (local-only). +- `WalletPersistenceTests.testWalletPersistsAcrossRelaunch` — wallet survives an app relaunch. +- `WalletPersistenceTests.testWalletDeletionCleanupSurvivesRelaunch` — deleted wallet stays gone after relaunch. +- `CreditTransferTest.testImportWalletAndDiscoverIdentity` — imports a known testnet mnemonic, runs DIP-9 identity discovery, asserts the registered identity exposes a readable balance. **Self-skips without `UI_TEST_MNEMONIC`.** + +The first three are local-only and hermetic; the last hits public testnet DAPI. + +## Running locally + +### From Xcode + +Pick the `SwiftExampleApp` scheme, run `Product → Test`. To pass the testnet mnemonic, edit the Test scheme's Environment Variables and add `UI_TEST_MNEMONIC` with your 12-word phrase. No `TEST_RUNNER_` prefix needed when set via the scheme — Xcode forwards it directly. + +### From the command line + +```bash +rm -rf /tmp/ui-tests.xcresult +TEST_RUNNER_UI_TEST_MNEMONIC="your 12 word phrase" \ +xcodebuild test \ + -project packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj \ + -scheme SwiftExampleApp \ + -destination 'platform=iOS Simulator,name=iPhone 17,arch=arm64' \ + -resultBundlePath /tmp/ui-tests.xcresult +``` + +**The `TEST_RUNNER_` prefix is mandatory on the command line.** `xcodebuild` strips it before forwarding the env var to the XCUITest runner process; without the prefix, `ProcessInfo.processInfo.environment["UI_TEST_MNEMONIC"]` returns nil and the test self-skips. + +To target a single test, append `-only-testing:SwiftExampleAppUITests//`. + +## Simulator state hygiene + +The suite expects a clean simulator. Two pre-existing-state failure modes you'll hit if you skip the wipe: + +1. **SwiftData migration crash.** Old persistent stores from previous app builds may have schema rows incompatible with the current model (e.g. mandatory fields added). Symptom: app crashes on launch with `SwiftDataError._Error.loadIssueModelContainer`; the test times out at "Expected root tab bar". +2. **Orphan-mnemonic recovery prompt.** The iOS Keychain persists across app uninstalls — uninstalling alone won't clear it. If a previous run left mnemonics behind and the SwiftData store is fresh, the app pops a "Recover Wallet?" alert on launch. `failIfRecoveryPromptVisible` catches this loudly, but you can't proceed without resolving it. + +Recommended reset before a fresh session: + +```bash +udid=$(xcrun simctl list devices booted | awk '/iPhone/ {print $NF}' | tr -d '()' | head -1) +xcrun simctl shutdown "$udid" +xcrun simctl erase "$udid" +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b +``` + +`simctl erase` is the only way to clear leftover Keychain entries. + +## CI + +[`.github/workflows/swift-example-app-ui-smoke.yml`](../../../../.github/workflows/swift-example-app-ui-smoke.yml) runs all four tests on a self-hosted macOS ARM64 runner: + +- **Manually** via `workflow_dispatch` (the "Run workflow" button on the workflow page). +- **Nightly** at 23:00 UTC. + +The `UI_TEST_MNEMONIC` GitHub Actions secret must be set for `testImportWalletAndDiscoverIdentity` to actually exercise discovery; otherwise it self-skips. Fork PRs never receive secrets, so the discovery test always self-skips on forks (intentional). + +The cron only fires when the self-hosted Mac is online — there's no GitHub-hosted macOS fallback. If two runs collide (e.g. a manual dispatch during the cron), the workflow's `concurrency:` block cancels the older one. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift new file mode 100644 index 00000000000..d8c7cb2d548 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift @@ -0,0 +1,55 @@ +// +// Identifiers.swift +// SwiftExampleAppUITests +// +// Accessibility identifiers used across the UI test suite. Relocated from +// SwiftExampleAppUITests.swift so multiple test classes can share them. +// Identifier strings are byte-for-byte the same as before; do not edit +// without auditing every test that matches against them. +// + +import Foundation + +enum Identifier { + static let walletsTab = "rootTab.wallets" + static let identitiesTab = "rootTab.identities" + static let settingsTab = "rootTab.settings" + static let walletsScreen = "wallets.screen" + static let addWalletButton = "wallets.addWalletButton" + static let emptyCreateWalletButton = "wallets.empty.createWalletButton" + static let walletNameField = "createWallet.walletNameField" + static let pinField = "createWallet.pinField" + static let confirmPinField = "createWallet.confirmPinField" + static let createWalletButton = "createWallet.createButton" + static let importToggle = "createWallet.importToggle" + static let mnemonicField = "createWallet.mnemonicField" + static let wroteItDownToggle = "seedBackup.wroteItDownToggle" + static let confirmSeedCreateWalletButton = "seedBackup.createWalletButton" + static let walletInfoButton = "walletDetail.infoButton" + static let deleteWalletButton = "walletInfo.deleteWalletButton" + + enum Options { + static let networkPicker = "options.networkPicker" + static let networkStatusLabel = "options.networkStatusLabel" + } + + enum Identities { + static let addMenu = "identities.addMenu" + static let searchWalletsMenuItem = "identities.searchWalletsMenuItem" + + /// Interpolate with a base58 identity ID — e.g. `Identifier.Identities.row("3ou…AAA")`. + static func row(_ identityIdBase58: String) -> String { + "identities.row.\(identityIdBase58)" + } + } + + enum SearchWallets { + static let walletPicker = "searchWallets.walletPicker" + static let searchButton = "searchWallets.searchButton" + static let foundCountLabel = "searchWallets.foundCountLabel" + } + + enum IdentityDetail { + static let balanceLabel = "identityDetail.balanceLabel" + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift new file mode 100644 index 00000000000..236e34321af --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -0,0 +1,893 @@ +// +// WalletFlow.swift +// SwiftExampleAppUITests +// +// Wallet-specific UI flows shared across test classes. The create/delete +// flows are exactly what `testCreateGeneratedWalletFlow` ran inline before +// the extraction; `assertWalletRowVisible` mirrors `scrollToWalletRow`'s +// buttons-first / staticTexts-fallback strategy so it works against a +// `NavigationLink` row whose accessibility label is the wallet name. +// + +import XCTest + +// MARK: - Tab navigation + +@MainActor +func openWalletsTab( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line +) { + let walletsScreen = element(Identifier.walletsScreen, in: app) + if walletsScreen.exists { + return + } + + let tabBar = app.tabBars.firstMatch + XCTAssertTrue( + tabBar.waitForExistence(timeout: 60), + "Expected root tab bar to appear after app initialization.", + file: file, + line: line + ) + failIfRecoveryPromptVisible(in: app, timeout: 0, file: file, line: line) + + let walletsTab = app.tabBars.buttons + .matching(identifier: Identifier.walletsTab) + .firstMatch + if walletsTab.waitForExistence(timeout: 2) { + walletsTab.tap() + } else { + let labeledWalletsTab = app.tabBars.buttons["Wallets"] + if labeledWalletsTab.waitForExistence(timeout: 2) { + labeledWalletsTab.tap() + } else { + let indexedWalletsTab = app.tabBars.buttons.element(boundBy: 1) + XCTAssertTrue( + indexedWalletsTab.waitForExistence(timeout: 5), + "Expected Wallets tab button to exist.", + file: file, + line: line + ) + indexedWalletsTab.tap() + } + } + + XCTAssertTrue( + walletsScreen.waitForExistence(timeout: 10) + || app.navigationBars["Wallets"].waitForExistence(timeout: 1), + "Expected Wallets screen after selecting Wallets tab.", + file: file, + line: line + ) +} + +// MARK: - Create / delete flows + +@MainActor +func createGeneratedWallet( + named walletName: String, + pin: String = "1234", + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line +) { + let addWalletButton = button(Identifier.addWalletButton, in: app) + if addWalletButton.waitForExistence(timeout: 5) { + addWalletButton.tap() + } else { + let emptyCreateButton = button(Identifier.emptyCreateWalletButton, in: app) + XCTAssertTrue( + emptyCreateButton.waitForExistence(timeout: 5), + "Expected either the toolbar add button or empty-state create wallet button.", + file: file, + line: line + ) + emptyCreateButton.tap() + } + + XCTAssertTrue( + app.navigationBars["Create Wallet"].waitForExistence(timeout: 10), + "Expected Create Wallet sheet to open.", + file: file, + line: line + ) + + let walletNameField = textField(Identifier.walletNameField, in: app) + XCTAssertTrue(walletNameField.waitForExistence(timeout: 5), file: file, line: line) + walletNameField.tap() + walletNameField.typeText(walletName) + + let pinField = secureTextField(Identifier.pinField, in: app) + XCTAssertTrue(pinField.waitForExistence(timeout: 5), file: file, line: line) + pinField.tap() + pinField.typeText(pin) + + let confirmPinField = secureTextField(Identifier.confirmPinField, in: app) + XCTAssertTrue(confirmPinField.waitForExistence(timeout: 5), file: file, line: line) + confirmPinField.tap() + confirmPinField.typeText(pin) + + let createButton = button(Identifier.createWalletButton, in: app) + XCTAssertTrue( + waitForElementToBeEnabled(createButton, timeout: 5), + "Expected Create button to become enabled after valid wallet form input.", + file: file, + line: line + ) + createButton.tap() + + XCTAssertTrue( + app.navigationBars["Backup Seed"].waitForExistence(timeout: 10), + "Expected Backup Seed screen after creating a generated recovery phrase.", + file: file, + line: line + ) + + let wroteItDownToggle = switchControl(Identifier.wroteItDownToggle, in: app) + XCTAssertTrue(wroteItDownToggle.waitForExistence(timeout: 5), file: file, line: line) + scrollUntilHittable(wroteItDownToggle, in: app) + XCTAssertTrue( + wroteItDownToggle.isHittable, + "Expected seed backup confirmation switch to be hittable.", + file: file, + line: line + ) + if !isSwitchOn(wroteItDownToggle) { + wroteItDownToggle + .coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)) + .tap() + } + XCTAssertTrue( + waitForSwitchToTurnOn(wroteItDownToggle, timeout: 5), + "Expected seed backup confirmation switch to turn on.", + file: file, + line: line + ) + + let confirmCreateButton = button(Identifier.confirmSeedCreateWalletButton, in: app) + XCTAssertTrue( + waitForElementToBeEnabled(confirmCreateButton, timeout: 5), + "Expected final Create Wallet button to enable after confirming seed backup.", + file: file, + line: line + ) + confirmCreateButton.tap() +} + +@MainActor +func deleteWallet( + named walletName: String, + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line +) { + let walletRow = scrollToWalletRow(named: walletName, in: app) + XCTAssertTrue( + walletRow.waitForExistence(timeout: 10), + "Expected wallet row named \(walletName) before cleanup.", + file: file, + line: line + ) + walletRow.tap() + + let infoButton = button(Identifier.walletInfoButton, in: app) + XCTAssertTrue(infoButton.waitForExistence(timeout: 10), file: file, line: line) + infoButton.tap() + + let deleteButton = button(Identifier.deleteWalletButton, in: app) + scrollUntilHittable(deleteButton, in: app) + XCTAssertTrue( + deleteButton.exists && deleteButton.isHittable, + "Expected Delete Wallet button to be reachable in Wallet Info.", + file: file, + line: line + ) + deleteButton.tap() + + let deleteAlert = app.alerts["Delete Wallet"] + XCTAssertTrue(deleteAlert.waitForExistence(timeout: 5), file: file, line: line) + deleteAlert.buttons["Delete"].tap() + + XCTAssertTrue( + waitForNonExistence(walletRow, timeout: 10), + "Expected created wallet row named \(walletName) to disappear after cleanup.", + file: file, + line: line + ) +} + +// MARK: - Import + +/// Drives `CreateWalletView` with the import toggle on. The import path +/// skips the seed-backup screen and goes straight to wallet creation. +@MainActor +func importWallet( + named walletName: String, + mnemonic: String, + pin: String = "1234", + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line +) { + let addWalletButton = button(Identifier.addWalletButton, in: app) + if addWalletButton.waitForExistence(timeout: 5) { + addWalletButton.tap() + } else { + let emptyCreateButton = button(Identifier.emptyCreateWalletButton, in: app) + XCTAssertTrue( + emptyCreateButton.waitForExistence(timeout: 5), + "Expected toolbar add or empty-state create button.", + file: file, line: line + ) + emptyCreateButton.tap() + } + + XCTAssertTrue( + app.navigationBars["Create Wallet"].waitForExistence(timeout: 10), + "Expected Create Wallet sheet.", + file: file, line: line + ) + + let nameField = textField(Identifier.walletNameField, in: app) + XCTAssertTrue(nameField.waitForExistence(timeout: 5), file: file, line: line) + nameField.tap() + nameField.typeText(walletName) + + let pinFieldEl = secureTextField(Identifier.pinField, in: app) + XCTAssertTrue(pinFieldEl.waitForExistence(timeout: 5), file: file, line: line) + pinFieldEl.tap() + pinFieldEl.typeText(pin) + + let confirmPinFieldEl = secureTextField(Identifier.confirmPinField, in: app) + XCTAssertTrue(confirmPinFieldEl.waitForExistence(timeout: 5), file: file, line: line) + confirmPinFieldEl.tap() + confirmPinFieldEl.typeText(pin) + + let importToggle = switchControl(Identifier.importToggle, in: app) + XCTAssertTrue( + importToggle.waitForExistence(timeout: 5), + "Expected Import Existing Wallet toggle.", + file: file, line: line + ) + scrollUntilHittable(importToggle, in: app) + + // SwiftUI Toggle in a Form is flaky to tap reliably — the + // accessibility frame spans the whole row but only the switch + // handle on the right toggles state. Try a couple of strategies in + // sequence: right-edge coordinate (handle area), then a plain + // .tap() (center of element). Bail as soon as the switch flips on. + let tapStrategies: [() -> Void] = [ + { + importToggle + .coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)) + .tap() + }, + { importToggle.tap() }, + { + importToggle + .coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)) + .tap() + }, + ] + var toggled = isSwitchOn(importToggle) + for strategy in tapStrategies where !toggled { + strategy() + toggled = waitForSwitchToTurnOn(importToggle, timeout: 3) + } + XCTAssertTrue( + toggled, + "Expected Import toggle to turn on after \(tapStrategies.count) attempts.", + file: file, line: line + ) + + let mnemonicField = textField(Identifier.mnemonicField, in: app) + XCTAssertTrue( + mnemonicField.waitForExistence(timeout: 5), + "Expected mnemonic field after toggling Import.", + file: file, line: line + ) + mnemonicField.tap() + mnemonicField.typeText(mnemonic) + // Don't swipe down to dismiss the keyboard — the Create button lives + // in the navigation bar, not under the keyboard, so the swipe is + // unnecessary AND a sheet-on-app swipeDown will dismiss the sheet. + + let createButton = button(Identifier.createWalletButton, in: app) + XCTAssertTrue( + waitForElementToBeEnabled(createButton, timeout: 5), + "Expected Create button to enable after filling import form.", + file: file, line: line + ) + createButton.tap() +} + +// MARK: - Tab navigation (additional) + +@MainActor +func openIdentitiesTab( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line +) { + openTabByIdentifierOrLabel( + idIdentifier: Identifier.identitiesTab, + labelFallback: "Identities", + boundByIndexFallback: 2, + in: app, + file: file, line: line + ) +} + +@MainActor +func openSettingsTab( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line +) { + openTabByIdentifierOrLabel( + idIdentifier: Identifier.settingsTab, + labelFallback: "Settings", + boundByIndexFallback: 4, + in: app, + file: file, line: line + ) +} + +@MainActor +private func openTabByIdentifierOrLabel( + idIdentifier: String, + labelFallback: String, + boundByIndexFallback: Int, + in app: XCUIApplication, + file: StaticString, + line: UInt +) { + let tabBar = app.tabBars.firstMatch + XCTAssertTrue( + tabBar.waitForExistence(timeout: 60), + "Expected root tab bar.", + file: file, line: line + ) + + let byId = app.tabBars.buttons.matching(identifier: idIdentifier).firstMatch + if byId.waitForExistence(timeout: 2) { + byId.tap() + return + } + let byLabel = app.tabBars.buttons[labelFallback] + if byLabel.waitForExistence(timeout: 2) { + byLabel.tap() + return + } + let byIndex = app.tabBars.buttons.element(boundBy: boundByIndexFallback) + XCTAssertTrue( + byIndex.waitForExistence(timeout: 5), + "Expected \(labelFallback) tab button.", + file: file, line: line + ) + byIndex.tap() +} + +// MARK: - Network + +/// Drives Settings → Network segmented picker to "Testnet". Idempotent. +/// Waits for `options.networkStatusLabel` to read "Connected" before +/// returning. Fails the test if the label reads "Disconnected" within +/// the timeout window. +@MainActor +func switchAppNetworkToTestnet( + in app: XCUIApplication, + timeout: TimeInterval = 30, + file: StaticString = #filePath, + line: UInt = #line +) { + openSettingsTab(in: app, file: file, line: line) + + // Try the identifier-scoped picker first, fall back to a generic + // segmented control. SwiftUI exposes the segmented options as + // children (buttons or NSSegmentedControl-equivalent), and the + // outer identifier may not propagate to them on every OS version. + let testnetSegmentInPicker = app.descendants(matching: .any) + .matching(identifier: Identifier.Options.networkPicker) + .firstMatch + .buttons["Testnet"] + let segmentedTestnet = app.segmentedControls.buttons["Testnet"] + let testnetButton: XCUIElement = testnetSegmentInPicker.waitForExistence(timeout: 5) + ? testnetSegmentInPicker + : segmentedTestnet + + XCTAssertTrue( + testnetButton.waitForExistence(timeout: 10), + "Expected Testnet segment in network picker.", + file: file, line: line + ) + + let tappedToSwitch = !testnetButton.isSelected + if tappedToSwitch { + testnetButton.tap() + } + + // Belt-and-braces: the AppState change makes the status label + // honest about the rebind-in-progress window, but if the segmented- + // control tap itself never landed (animation interrupted, picker + // disabled mid-frame), `appState.currentNetwork` never changes, + // `isSwitchingNetwork` stays false, and the label keeps reading + // "Connected" against the *previous* network's SDK. Wait for the + // segment to latch before trusting the connected predicate below. + let selectedResult = XCTWaiter.wait( + for: [XCTNSPredicateExpectation( + predicate: NSPredicate(format: "isSelected == true"), + object: testnetButton + )], + timeout: 10 + ) + XCTAssertEqual( + selectedResult, + .completed, + "Testnet segment did not latch as selected within 10s. Did the segmented-control tap miss?", + file: file, line: line + ) + + let statusLabel = app.descendants(matching: .any) + .matching(identifier: Identifier.Options.networkStatusLabel) + .firstMatch + XCTAssertTrue( + statusLabel.waitForExistence(timeout: 10), + "Expected network status label.", + file: file, line: line + ) + + // When we actually tapped to switch, observe the "Switching..." state + // before trusting "Connected". The status label isn't network-aware + // (it cycles between "Connected", "Switching...", "Disconnected"), so + // a stale "Connected" from the *previous* network can satisfy the + // predicate before the AppState chain (`currentNetwork.didSet` → + // `beginNetworkSwitch` → `isSwitchingNetwork = true` → SwiftUI + // rerender) has flipped the label. Observing "Switching..." first + // proves that chain ran. Idempotent path (already on testnet) skips + // this — there's no transition to wait for. + if tappedToSwitch { + let switchingPredicate = NSPredicate { object, _ in + guard let element = object as? XCUIElement, element.exists else { return false } + return element.label.contains("Switching") + } + let switchingResult = XCTWaiter.wait( + for: [XCTNSPredicateExpectation(predicate: switchingPredicate, object: statusLabel)], + timeout: 10 + ) + XCTAssertEqual( + switchingResult, + .completed, + "Status label never showed 'Switching...' after the testnet tap. Either the AppState chain didn't fire or the switch completed faster than the XCUITest poll cadence. Last label: \(statusLabel.label).", + file: file, line: line + ) + } + + let connectedPredicate = NSPredicate { object, _ in + guard let element = object as? XCUIElement, element.exists else { return false } + return element.label.contains("Connected") + } + let result = XCTWaiter.wait( + for: [XCTNSPredicateExpectation(predicate: connectedPredicate, object: statusLabel)], + timeout: timeout + ) + XCTAssertEqual( + result, + .completed, + "Network status did not reach 'Connected' within \(Int(timeout))s. Last label: \(statusLabel.label).", + file: file, line: line + ) + XCTAssertFalse( + statusLabel.label.contains("Disconnected"), + "Network status reported Disconnected after switching to Testnet.", + file: file, line: line + ) +} + +// MARK: - Identity discovery + +@MainActor +func runIdentityDiscovery( + forWalletNamed walletName: String, + in app: XCUIApplication, + timeout: TimeInterval = 60, + file: StaticString = #filePath, + line: UInt = #line +) { + let addMenu = app.descendants(matching: .any) + .matching(identifier: Identifier.Identities.addMenu) + .firstMatch + XCTAssertTrue( + addMenu.waitForExistence(timeout: 10), + "Expected Identities add menu.", + file: file, line: line + ) + + // SwiftUI Menu popovers are flaky to drive — XCUITest sometimes + // computes a `{-1, -1}` hit point on freshly-shown menu items, the + // auto-retry then taps a stale element, and the sheet never opens. + // Wrap "open menu, tap item, verify sheet" in a retry loop driven + // by the actual signal (Search Wallets nav bar appears). + // + // Re-tap `addMenu` unconditionally on each retry. A previous attempt + // to skip the re-tap when the menu item was already visible turned + // out to lock us into the same bad hit point — if the item-tap + // missed but the menu stayed open, the next iteration re-tapped the + // same dead spot. Closing-and-reopening the menu forces a fresh + // accessibility-tree snapshot with a new hit point. + let searchSheetNavBar = app.navigationBars["Search Wallets"] + var sheetOpened = false + for _ in 0..<3 where !sheetOpened { + addMenu.tap() + + let searchMenuItem = app.descendants(matching: .any) + .matching(identifier: Identifier.Identities.searchWalletsMenuItem) + .firstMatch + if searchMenuItem.waitForExistence(timeout: 3) { + searchMenuItem.tap() + } else { + // Fallback: match the menu item by visible label. + let labeled = app.buttons["Search Wallets for Identities"] + if labeled.waitForExistence(timeout: 3) { + labeled.tap() + } + } + sheetOpened = searchSheetNavBar.waitForExistence(timeout: 5) + } + XCTAssertTrue( + sheetOpened, + "Expected Search Wallets sheet to open after tapping the Add menu item.", + file: file, line: line + ) + + // Drive the picker explicitly — we can't trust the default-first + // auto-selection. SearchWalletsForIdentitiesView's `@Query` over + // PersistentWallet is unfiltered and sorted by createdAt, so any + // older wallet on the simulator (e.g. one a developer created + // outside this test) wins the default selection. + // + // Generous timeout: the SwiftData write → @Query update → view + // rerender takes a moment after a fresh import. During that window + // the view shows the "No wallets loaded" branch instead of the + // picker. 20s comfortably covers the propagation lag. + let walletPicker = app.descendants(matching: .any) + .matching(identifier: Identifier.SearchWallets.walletPicker) + .firstMatch + XCTAssertTrue( + walletPicker.waitForExistence(timeout: 20), + "Expected the wallet picker. (Did SwiftData propagate the imported wallet to @Query?)", + file: file, line: line + ) + if !walletPicker.label.contains(walletName) { + // Open the .menu popover and tap the row whose accessibility + // label starts with our wallet name. `walletPickerRow` renders + // `HStack { Text(label), Text(fingerprint) }`, which combines + // into `" "` on the row's button. Match + // with a trailing space so a longer wallet name that shares a + // prefix can't accidentally win firstMatch. + walletPicker.tap() + let walletOption = app.buttons + .matching(NSPredicate(format: "label BEGINSWITH %@", "\(walletName) ")) + .firstMatch + XCTAssertTrue( + walletOption.waitForExistence(timeout: 5), + "Expected wallet menu option for \(walletName).", + file: file, line: line + ) + walletOption.tap() + } + // SwiftUI takes a frame to update the picker's collapsed label after + // the menu option is tapped — a bare `.label.contains` check races + // on slower simulators. Wait for the propagation explicitly. + let selectedPredicate = NSPredicate { object, _ in + guard let element = object as? XCUIElement, element.exists else { return false } + return element.label.contains(walletName) + } + let selectedResult = XCTWaiter.wait( + for: [XCTNSPredicateExpectation(predicate: selectedPredicate, object: walletPicker)], + timeout: 5 + ) + XCTAssertEqual( + selectedResult, + .completed, + "Picker shows \"\(walletPicker.label)\" but the test imported \(walletName).", + file: file, line: line + ) + + let searchButton = app.descendants(matching: .any) + .matching(identifier: Identifier.SearchWallets.searchButton) + .firstMatch + XCTAssertTrue( + waitForElementToBeEnabled(searchButton, timeout: 15), + "Expected Search Wallet button to enable.", + file: file, line: line + ) + searchButton.tap() + + let foundCount = app.staticTexts + .matching(identifier: Identifier.SearchWallets.foundCountLabel) + .firstMatch + let foundPredicate = NSPredicate { object, _ in + guard let element = object as? XCUIElement, element.exists else { return false } + let label = element.label + // SearchWalletsForIdentitiesView renders `"+\(foundCount)"`, so + // require literally "+" (excluding "+0") rather than + // accepting any "+"-prefixed string. Defends against future + // label format drift without coupling to a specific count. + return label.hasPrefix("+") + && label.dropFirst().allSatisfy(\.isNumber) + && label != "+0" + } + XCTAssertEqual( + XCTWaiter.wait( + for: [XCTNSPredicateExpectation(predicate: foundPredicate, object: foundCount)], + timeout: timeout + ), + .completed, + "Expected discovery to find at least one identity within \(Int(timeout))s.", + file: file, line: line + ) + + let doneButton = app.buttons["Done"] + if doneButton.waitForExistence(timeout: 5) { + doneButton.tap() + } +} + +@MainActor +func waitForIdentityRow( + idBase58: String, + in app: XCUIApplication, + timeout: TimeInterval = 60, + file: StaticString = #filePath, + line: UInt = #line +) -> XCUIElement { + let row = app.descendants(matching: .any) + .matching(identifier: Identifier.Identities.row(idBase58)) + .firstMatch + XCTAssertTrue( + row.waitForExistence(timeout: timeout), + "Expected identity row \(idBase58) within \(Int(timeout))s.", + file: file, line: line + ) + return row +} + +// MARK: - Identity detail / balance + +/// Reads the raw credit balance from `identityDetail.balanceLabel`'s +/// `accessibilityValue` (set to `"\(identity.balance)"` in IdentityDetailView). +/// Fails the test loudly if `.value` is empty or non-numeric — the rounded +/// display label hides sub-1000-credit deltas, and silent fallback there +/// would mask regressions. +@MainActor +func readIdentityBalanceCredits( + in app: XCUIApplication, + timeout: TimeInterval = 30, + file: StaticString = #filePath, + line: UInt = #line +) -> UInt64 { + let label = app.descendants(matching: .any) + .matching(identifier: Identifier.IdentityDetail.balanceLabel) + .firstMatch + XCTAssertTrue( + label.waitForExistence(timeout: timeout), + "Expected identityDetail.balanceLabel.", + file: file, line: line + ) + let displayLabel = label.label + guard let raw = label.value as? String, !raw.isEmpty else { + XCTFail( + "identityDetail.balanceLabel has no accessibilityValue. Display label was \"\(displayLabel)\". " + + "Did the .accessibilityValue modifier get dropped?", + file: file, line: line + ) + return 0 + } + // iOS may apply locale-aware thousand separators to the accessibility + // value string (e.g. "79 750 667 720" in French/German locales, + // "79,750,667,720" in en-US). Strip non-digit characters before + // parsing — we know the underlying value is a UInt64 credit count. + let digits = raw.filter { $0.isASCII && $0.isNumber } + guard !digits.isEmpty, let credits = UInt64(digits) else { + XCTFail( + "Could not parse \"\(raw)\" as UInt64 credits. Display label was \"\(displayLabel)\".", + file: file, line: line + ) + return 0 + } + return credits +} + +// MARK: - Pre-import cleanup + +/// Deletes any wallet whose label starts with the given prefix. Used at +/// the start of the credit-transfer test to remove leftovers from prior +/// failed runs — re-importing the same mnemonic otherwise hits +/// `Wallet operation: Wallet already exists` because walletId is +/// deterministic from the mnemonic. +/// +/// Bails as soon as no matching row is visible: an earlier full-sweep +/// implementation issued blind `swipeUp` calls on an empty wallets list +/// and routinely tripped XCUITest's 60s event-synthesis timeout (per +/// swipe), blowing the test runtime out by ~20+ minutes. If a developer +/// has accumulated more `ImportTransfer-*` wallets than the viewport +/// can hold, `simctl erase` is the right recovery (documented in +/// SwiftExampleAppUITests/README.md). +@MainActor +func cleanupWalletsByPrefix(_ prefix: String, in app: XCUIApplication) { + let walletsScreen = element(Identifier.walletsScreen, in: app) + guard walletsScreen.waitForExistence(timeout: 10) else { return } + + let predicate = NSPredicate(format: "label BEGINSWITH %@", prefix) + for _ in 0..<10 { + let row = app.buttons.matching(predicate).firstMatch + if !row.waitForExistence(timeout: 1) { + return + } + let name = row.label + bestEffortDeleteWallet(named: name, in: app) + } +} + +// MARK: - Best-effort cleanup + +/// Best-effort wallet deletion for teardown blocks — does not assert. Used +/// by relaunch tests so an aborted assertion mid-flow doesn't leave a real +/// wallet on the developer's simulator. Silent on every "not found" path, +/// because if any required element is missing there's nothing useful to +/// clean up. This mirrors `deleteWallet` step-for-step but with bailouts +/// instead of XCTAssert calls. +@MainActor +func bestEffortDeleteWallet(named walletName: String, in app: XCUIApplication) { + // If a previous failure left Keychain mnemonics behind without + // matching SwiftData rows, the cold-launch shows the orphan-mnemonic + // recovery prompt before any UI we care about. Dismiss it + // best-effort so the rest of the helper isn't silently no-oped by + // a modal blocking the wallets tab. The prompt's "Cancel" button + // declines the recovery offer (we then proceed with the deletion + // we came here to do). + let recoverAlert = app.alerts["Recover Wallet?"] + if recoverAlert.waitForExistence(timeout: 1) { + if recoverAlert.buttons["Cancel"].exists { + recoverAlert.buttons["Cancel"].tap() + } else if recoverAlert.buttons["Don't Recover"].exists { + recoverAlert.buttons["Don't Recover"].tap() + } else { + // Last-ditch: tap whatever the dismissive button is by index. + recoverAlert.buttons.element(boundBy: 0).tap() + } + } + + let walletsScreen = element(Identifier.walletsScreen, in: app) + if !walletsScreen.exists { + let walletsTab = app.tabBars.buttons + .matching(identifier: Identifier.walletsTab) + .firstMatch + if walletsTab.waitForExistence(timeout: 30) { + walletsTab.tap() + } else if app.tabBars.buttons["Wallets"].waitForExistence(timeout: 2) { + app.tabBars.buttons["Wallets"].tap() + } + guard walletsScreen.waitForExistence(timeout: 10) + || app.navigationBars["Wallets"].waitForExistence(timeout: 1) + else { return } + } + + let row = app.buttons + .matching(NSPredicate(format: "label == %@", walletName)) + .firstMatch + for _ in 0..<8 where !row.exists { + app.swipeUp() + } + guard row.waitForExistence(timeout: 5) else { return } + row.tap() + + let infoButton = button(Identifier.walletInfoButton, in: app) + guard infoButton.waitForExistence(timeout: 5) else { return } + infoButton.tap() + + let deleteButton = button(Identifier.deleteWalletButton, in: app) + scrollUntilHittable(deleteButton, in: app) + guard deleteButton.exists, deleteButton.isHittable else { return } + deleteButton.tap() + + let deleteAlert = app.alerts["Delete Wallet"] + if deleteAlert.waitForExistence(timeout: 5) { + deleteAlert.buttons["Delete"].tap() + } +} + +// MARK: - Row lookup / assertion + +/// Mirrors the original `scrollToWalletRow`: each wallet row is a +/// `NavigationLink` (a button in the accessibility tree) wrapping +/// `WalletRowView`, with `.accessibilityLabel(wallet.label)` set on the +/// link. Match by buttons first; fall back to staticTexts for surfaces +/// where the wallet name is rendered as plain text. +@MainActor +func scrollToWalletRow(named walletName: String, in app: XCUIApplication) -> XCUIElement { + let row = app.buttons + .matching(NSPredicate(format: "label == %@", walletName)) + .firstMatch + for _ in 0..<8 where !row.exists { + app.swipeUp() + } + if row.exists { + return row + } + + let label = app.staticTexts + .matching(NSPredicate(format: "label == %@", walletName)) + .firstMatch + for _ in 0..<8 where !label.exists { + app.swipeUp() + } + return label +} + +/// Assert a wallet row's presence (or absence) by name. For `exists: true` +/// this scrolls up to ~16 swipes to find the row, mirroring +/// `scrollToWalletRow`. For `exists: false` it scrolls back to the top +/// and sweeps down — SwiftUI Lists are lazy, and a still-persisted row +/// off-screen would otherwise let the absence predicate evaluate true +/// even though deletion or relaunch cleanup actually failed. +@MainActor +func assertWalletRowVisible( + named walletName: String, + in app: XCUIApplication, + exists: Bool, + timeout: TimeInterval = 10, + file: StaticString = #filePath, + line: UInt = #line +) { + if exists { + let row = scrollToWalletRow(named: walletName, in: app) + XCTAssertTrue( + row.waitForExistence(timeout: timeout), + "Expected wallet row \(walletName) to be visible.", + file: file, + line: line + ) + return + } + + // Reset to the top of the list, then sweep down. If the row appears + // at any scroll position, fail loudly — its presence anywhere in the + // list means deletion didn't actually happen. + for _ in 0..<6 { app.swipeDown() } + + let buttonRow = app.buttons + .matching(NSPredicate(format: "label == %@", walletName)) + .firstMatch + let textRow = app.staticTexts + .matching(NSPredicate(format: "label == %@", walletName)) + .firstMatch + + for _ in 0..<10 { + if buttonRow.exists || textRow.exists { + XCTFail( + "Expected wallet row \(walletName) to be absent, but found during sweep.", + file: file, + line: line + ) + return + } + app.swipeUp() + } + + let absencePredicate = NSPredicate { _, _ in + !buttonRow.exists && !textRow.exists + } + let expectation = XCTNSPredicateExpectation(predicate: absencePredicate, object: app) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + XCTAssertEqual( + result, + .completed, + "Expected wallet row \(walletName) to be absent after sweep.", + file: file, + line: line + ) +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/XCUIElement+Helpers.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/XCUIElement+Helpers.swift new file mode 100644 index 00000000000..aedd3e91bf5 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/XCUIElement+Helpers.swift @@ -0,0 +1,129 @@ +// +// XCUIElement+Helpers.swift +// SwiftExampleAppUITests +// +// Shared XCUITest helpers — element lookup, predicate-driven waits, and +// the orphan-mnemonic recovery-prompt guard. Relocated from +// SwiftExampleAppUITests.swift so multiple test classes can share them. +// Behavior matches the previous private implementations exactly. +// + +import XCTest + +// MARK: - Element lookup + +@MainActor +func element(_ identifier: String, in app: XCUIApplication) -> XCUIElement { + app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch +} + +@MainActor +func button(_ identifier: String, in app: XCUIApplication) -> XCUIElement { + app.buttons + .matching(identifier: identifier) + .firstMatch +} + +@MainActor +func textField(_ identifier: String, in app: XCUIApplication) -> XCUIElement { + app.textFields + .matching(identifier: identifier) + .firstMatch +} + +@MainActor +func secureTextField(_ identifier: String, in app: XCUIApplication) -> XCUIElement { + app.secureTextFields + .matching(identifier: identifier) + .firstMatch +} + +@MainActor +func switchControl(_ identifier: String, in app: XCUIApplication) -> XCUIElement { + app.switches + .matching(identifier: identifier) + .firstMatch +} + +// MARK: - Waits + +@MainActor +func waitForElementToBeEnabled( + _ element: XCUIElement, + timeout: TimeInterval +) -> Bool { + let predicate = NSPredicate { object, _ in + guard let element = object as? XCUIElement else { return false } + return element.exists && element.isEnabled + } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed +} + +@MainActor +func waitForNonExistence( + _ element: XCUIElement, + timeout: TimeInterval +) -> Bool { + let predicate = NSPredicate { object, _ in + guard let element = object as? XCUIElement else { return false } + return !element.exists + } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed +} + +@MainActor +func waitForSwitchToTurnOn( + _ element: XCUIElement, + timeout: TimeInterval +) -> Bool { + let predicate = NSPredicate { object, _ in + guard let element = object as? XCUIElement else { return false } + guard let value = element.value as? String else { return false } + return value == "1" || value.lowercased() == "true" + } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed +} + +@MainActor +func isSwitchOn(_ element: XCUIElement) -> Bool { + guard let value = element.value as? String else { + return false + } + return value == "1" || value.lowercased() == "true" +} + +@MainActor +func scrollUntilHittable(_ element: XCUIElement, in app: XCUIApplication) { + for _ in 0..<6 where !(element.exists && element.isHittable) { + app.swipeUp() + } +} + +// MARK: - Recovery-prompt guard + +/// Fails the running test if the orphan-mnemonic "Recover Wallet?" alert is +/// already on screen. Used at the start of any test that depends on a clean +/// wallet state — pre-existing residue from an aborted run would otherwise +/// silently change the flow under test. +@MainActor +func failIfRecoveryPromptVisible( + in app: XCUIApplication, + timeout: TimeInterval, + file: StaticString = #filePath, + line: UInt = #line +) { + let recoverWalletAlert = app.alerts["Recover Wallet?"] + if recoverWalletAlert.waitForExistence(timeout: timeout) { + XCTFail( + "Pre-existing orphan-mnemonic recovery alert is blocking the UI test. " + + "Clean simulator state or resolve the alert manually before running this flow.", + file: file, + line: line + ) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/SwiftExampleAppUITests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/SwiftExampleAppUITests.swift index d179eb04ba5..2816cafb2b1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/SwiftExampleAppUITests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/SwiftExampleAppUITests.swift @@ -8,21 +8,6 @@ import XCTest final class SwiftExampleAppUITests: XCTestCase { - private enum Identifier { - static let walletsTab = "rootTab.wallets" - static let walletsScreen = "wallets.screen" - static let addWalletButton = "wallets.addWalletButton" - static let emptyCreateWalletButton = "wallets.empty.createWalletButton" - static let walletNameField = "createWallet.walletNameField" - static let pinField = "createWallet.pinField" - static let confirmPinField = "createWallet.confirmPinField" - static let createWalletButton = "createWallet.createButton" - static let wroteItDownToggle = "seedBackup.wroteItDownToggle" - static let confirmSeedCreateWalletButton = "seedBackup.createWalletButton" - static let walletInfoButton = "walletDetail.infoButton" - static let deleteWalletButton = "walletInfo.deleteWalletButton" - } - override func setUpWithError() throws { continueAfterFailure = false } @@ -38,277 +23,8 @@ final class SwiftExampleAppUITests: XCTestCase { let walletName = "UITest Wallet \(UUID().uuidString.prefix(8))" createGeneratedWallet(named: walletName, in: app) - let createdWalletRow = scrollToWalletRow(named: walletName, in: app) - XCTAssertTrue( - createdWalletRow.waitForExistence(timeout: 20), - "Expected created wallet row named \(walletName) to appear." - ) + assertWalletRowVisible(named: walletName, in: app, exists: true, timeout: 20) deleteWallet(named: walletName, in: app) } - - @MainActor - private func openWalletsTab(in app: XCUIApplication) { - let walletsScreen = element(Identifier.walletsScreen, in: app) - if walletsScreen.exists { - return - } - - let tabBar = app.tabBars.firstMatch - XCTAssertTrue( - tabBar.waitForExistence(timeout: 60), - "Expected root tab bar to appear after app initialization." - ) - failIfRecoveryPromptVisible(in: app, timeout: 0) - - let walletsTab = app.tabBars.buttons - .matching(identifier: Identifier.walletsTab) - .firstMatch - if walletsTab.waitForExistence(timeout: 2) { - walletsTab.tap() - } else { - let labeledWalletsTab = app.tabBars.buttons["Wallets"] - if labeledWalletsTab.waitForExistence(timeout: 2) { - labeledWalletsTab.tap() - } else { - let indexedWalletsTab = app.tabBars.buttons.element(boundBy: 1) - XCTAssertTrue( - indexedWalletsTab.waitForExistence(timeout: 5), - "Expected Wallets tab button to exist." - ) - indexedWalletsTab.tap() - } - } - - XCTAssertTrue( - walletsScreen.waitForExistence(timeout: 10) - || app.navigationBars["Wallets"].waitForExistence(timeout: 1), - "Expected Wallets screen after selecting Wallets tab." - ) - } - - @MainActor - private func createGeneratedWallet(named walletName: String, in app: XCUIApplication) { - let addWalletButton = button(Identifier.addWalletButton, in: app) - if addWalletButton.waitForExistence(timeout: 5) { - addWalletButton.tap() - } else { - let emptyCreateButton = button(Identifier.emptyCreateWalletButton, in: app) - XCTAssertTrue( - emptyCreateButton.waitForExistence(timeout: 5), - "Expected either the toolbar add button or empty-state create wallet button." - ) - emptyCreateButton.tap() - } - - XCTAssertTrue( - app.navigationBars["Create Wallet"].waitForExistence(timeout: 10), - "Expected Create Wallet sheet to open." - ) - - let walletNameField = textField(Identifier.walletNameField, in: app) - XCTAssertTrue(walletNameField.waitForExistence(timeout: 5)) - walletNameField.tap() - walletNameField.typeText(walletName) - - let pinField = secureTextField(Identifier.pinField, in: app) - XCTAssertTrue(pinField.waitForExistence(timeout: 5)) - pinField.tap() - pinField.typeText("1234") - - let confirmPinField = secureTextField(Identifier.confirmPinField, in: app) - XCTAssertTrue(confirmPinField.waitForExistence(timeout: 5)) - confirmPinField.tap() - confirmPinField.typeText("1234") - - let createButton = button(Identifier.createWalletButton, in: app) - XCTAssertTrue( - waitForElementToBeEnabled(createButton, timeout: 5), - "Expected Create button to become enabled after valid wallet form input." - ) - createButton.tap() - - XCTAssertTrue( - app.navigationBars["Backup Seed"].waitForExistence(timeout: 10), - "Expected Backup Seed screen after creating a generated recovery phrase." - ) - - let wroteItDownToggle = switchControl(Identifier.wroteItDownToggle, in: app) - XCTAssertTrue(wroteItDownToggle.waitForExistence(timeout: 5)) - scrollUntilHittable(wroteItDownToggle, in: app) - XCTAssertTrue( - wroteItDownToggle.isHittable, - "Expected seed backup confirmation switch to be hittable." - ) - if !isSwitchOn(wroteItDownToggle) { - wroteItDownToggle - .coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)) - .tap() - } - XCTAssertTrue( - waitForSwitchToTurnOn(wroteItDownToggle, timeout: 5), - "Expected seed backup confirmation switch to turn on." - ) - - let confirmCreateButton = button(Identifier.confirmSeedCreateWalletButton, in: app) - XCTAssertTrue( - waitForElementToBeEnabled(confirmCreateButton, timeout: 5), - "Expected final Create Wallet button to enable after confirming seed backup." - ) - confirmCreateButton.tap() - } - - @MainActor - private func deleteWallet(named walletName: String, in app: XCUIApplication) { - let walletRow = scrollToWalletRow(named: walletName, in: app) - XCTAssertTrue( - walletRow.waitForExistence(timeout: 10), - "Expected wallet row named \(walletName) before cleanup." - ) - walletRow.tap() - - let infoButton = button(Identifier.walletInfoButton, in: app) - XCTAssertTrue(infoButton.waitForExistence(timeout: 10)) - infoButton.tap() - - let deleteButton = button(Identifier.deleteWalletButton, in: app) - scrollUntilHittable(deleteButton, in: app) - XCTAssertTrue( - deleteButton.exists && deleteButton.isHittable, - "Expected Delete Wallet button to be reachable in Wallet Info." - ) - deleteButton.tap() - - let deleteAlert = app.alerts["Delete Wallet"] - XCTAssertTrue(deleteAlert.waitForExistence(timeout: 5)) - deleteAlert.buttons["Delete"].tap() - - XCTAssertTrue( - waitForNonExistence(walletRow, timeout: 10), - "Expected created wallet row named \(walletName) to disappear after cleanup." - ) - } - - @MainActor - private func failIfRecoveryPromptVisible(in app: XCUIApplication, timeout: TimeInterval) { - let recoverWalletAlert = app.alerts["Recover Wallet?"] - if recoverWalletAlert.waitForExistence(timeout: timeout) { - XCTFail( - "Pre-existing orphan-mnemonic recovery alert is blocking the UI test. " - + "Clean simulator state or resolve the alert manually before running this flow." - ) - } - } - - @MainActor - private func scrollToWalletRow(named walletName: String, in app: XCUIApplication) -> XCUIElement { - let row = app.buttons - .matching(NSPredicate(format: "label == %@", walletName)) - .firstMatch - for _ in 0..<8 where !row.exists { - app.swipeUp() - } - if row.exists { - return row - } - - let label = app.staticTexts - .matching(NSPredicate(format: "label == %@", walletName)) - .firstMatch - for _ in 0..<8 where !label.exists { - app.swipeUp() - } - return label - } - - @MainActor - private func scrollUntilHittable(_ element: XCUIElement, in app: XCUIApplication) { - for _ in 0..<6 where !(element.exists && element.isHittable) { - app.swipeUp() - } - } - - @MainActor - private func element(_ identifier: String, in app: XCUIApplication) -> XCUIElement { - app.descendants(matching: .any) - .matching(identifier: identifier) - .firstMatch - } - - @MainActor - private func button(_ identifier: String, in app: XCUIApplication) -> XCUIElement { - app.buttons - .matching(identifier: identifier) - .firstMatch - } - - @MainActor - private func textField(_ identifier: String, in app: XCUIApplication) -> XCUIElement { - app.textFields - .matching(identifier: identifier) - .firstMatch - } - - @MainActor - private func secureTextField(_ identifier: String, in app: XCUIApplication) -> XCUIElement { - app.secureTextFields - .matching(identifier: identifier) - .firstMatch - } - - @MainActor - private func switchControl(_ identifier: String, in app: XCUIApplication) -> XCUIElement { - app.switches - .matching(identifier: identifier) - .firstMatch - } - - @MainActor - private func waitForElementToBeEnabled( - _ element: XCUIElement, - timeout: TimeInterval - ) -> Bool { - let predicate = NSPredicate { object, _ in - guard let element = object as? XCUIElement else { return false } - return element.exists && element.isEnabled - } - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) - return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed - } - - @MainActor - private func waitForNonExistence( - _ element: XCUIElement, - timeout: TimeInterval - ) -> Bool { - let predicate = NSPredicate { object, _ in - guard let element = object as? XCUIElement else { return false } - return !element.exists - } - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) - return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed - } - - @MainActor - private func waitForSwitchToTurnOn( - _ element: XCUIElement, - timeout: TimeInterval - ) -> Bool { - let predicate = NSPredicate { object, _ in - guard let element = object as? XCUIElement else { return false } - guard let value = element.value as? String else { return false } - return value == "1" || value.lowercased() == "true" - } - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) - return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed - } - - @MainActor - private func isSwitchOn(_ element: XCUIElement) -> Bool { - guard let value = element.value as? String else { - return false - } - return value == "1" || value.lowercased() == "true" - } - } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/WalletPersistenceTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/WalletPersistenceTests.swift new file mode 100644 index 00000000000..4e4f17a66f0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/WalletPersistenceTests.swift @@ -0,0 +1,110 @@ +// +// WalletPersistenceTests.swift +// SwiftExampleAppUITests +// +// SDK-backed integration tests that exercise the real SwiftData persister +// + Keychain bootstrap path across app relaunches. These tests deliberately +// do NOT use `-UITestResetState` or any in-memory ModelContainer hook — +// doing so would defeat the SDK signal they are designed to give. Aborted +// local runs may leave a wallet or an orphan-mnemonic recovery prompt on +// the simulator; this is an intentional tradeoff documented in the PR. +// + +import XCTest + +final class WalletPersistenceTests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launch() + return app + } + + // MARK: - B-1 + + /// Validates `walletManager.loadFromPersistor()` after a cold restart: + /// SwiftData rehydration + Keychain read + the `rebindWalletScopedServices` + /// chain. A wallet created in run #1 must come back in run #2. + @MainActor + func testWalletPersistsAcrossRelaunch() throws { + let walletName = "PersistTest-\(UUID().uuidString.prefix(6))" + + // Best-effort teardown: if any assertion below halts the test before + // the explicit delete in step 11, this re-launches a fresh app and + // attempts to remove the wallet by name. Silent on failure. + addTeardownBlock { + // Teardown for UI tests runs on the main thread; assume the + // isolation so we can call MainActor-isolated helpers. + MainActor.assumeIsolated { + let cleanupApp = XCUIApplication() + cleanupApp.launch() + bestEffortDeleteWallet(named: walletName, in: cleanupApp) + cleanupApp.terminate() + } + } + + let app = launchApp() + failIfRecoveryPromptVisible(in: app, timeout: 2) + openWalletsTab(in: app) + + createGeneratedWallet(named: walletName, in: app) + assertWalletRowVisible(named: walletName, in: app, exists: true, timeout: 20) + + app.terminate() + + let app2 = launchApp() + failIfRecoveryPromptVisible(in: app2, timeout: 10) + openWalletsTab(in: app2) + assertWalletRowVisible(named: walletName, in: app2, exists: true, timeout: 15) + + deleteWallet(named: walletName, in: app2) + assertWalletRowVisible(named: walletName, in: app2, exists: false) + } + + // MARK: - B-2 + + /// Validates that `deleteWallet` clears SwiftData and Keychain + /// atomically. If either side leaks, the orphan-mnemonic recovery + /// prompt fires on relaunch and the test fails. This is the strongest + /// SDK-integration assertion in the suite. + @MainActor + func testWalletDeletionCleanupSurvivesRelaunch() throws { + let walletName = "DeleteTest-\(UUID().uuidString.prefix(6))" + + // Defensive teardown: ordinarily the test deletes the wallet itself + // in step 6, but if we fail mid-flow before delete, this catches the + // residue. After the delete-then-relaunch sequence runs cleanly, + // there's nothing for cleanup to find — that's expected. + addTeardownBlock { + // Teardown for UI tests runs on the main thread; assume the + // isolation so we can call MainActor-isolated helpers. + MainActor.assumeIsolated { + let cleanupApp = XCUIApplication() + cleanupApp.launch() + bestEffortDeleteWallet(named: walletName, in: cleanupApp) + cleanupApp.terminate() + } + } + + let app = launchApp() + failIfRecoveryPromptVisible(in: app, timeout: 2) + openWalletsTab(in: app) + + createGeneratedWallet(named: walletName, in: app) + assertWalletRowVisible(named: walletName, in: app, exists: true, timeout: 20) + + deleteWallet(named: walletName, in: app) + assertWalletRowVisible(named: walletName, in: app, exists: false) + + app.terminate() + + let app2 = launchApp() + failIfRecoveryPromptVisible(in: app2, timeout: 10) // ← key SDK assertion + openWalletsTab(in: app2) + assertWalletRowVisible(named: walletName, in: app2, exists: false, timeout: 15) + } +}