From 03af8e2f96bc0227588ca3870212fe913abc7dce Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 28 Apr 2026 08:04:00 +0200 Subject: [PATCH 01/17] test(swift-sdk): add wallet persistence UI tests + extract test helpers Extract Identifier enum, XCUIElement helpers, and the create/delete wallet flow into Support/ for cross-class reuse. Rewrite the existing testCreateGeneratedWalletFlow on top of the extracted helpers; behavior is unchanged. Add WalletPersistenceTests with two SDK-backed integration tests: - testWalletPersistsAcrossRelaunch validates loadFromPersistor() after a cold restart (SwiftData rehydration + Keychain read). - testWalletDeletionCleanupSurvivesRelaunch guards atomic SwiftData + Keychain mnemonic cleanup; the orphan-mnemonic recovery prompt fires on relaunch if either side leaks. Both new tests use addTeardownBlock + MainActor.assumeIsolated for best-effort wallet cleanup if assertions halt mid-flow. Extend swift-example-app-ui-smoke.yml -only-testing list to include the two new tests; -parallel-testing-enabled NO and -maximum-concurrent-test-simulator-destinations 1 retained. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/swift-example-app-ui-smoke.yml | 2 + .../Support/Identifiers.swift | 26 ++ .../Support/WalletFlow.swift | 321 ++++++++++++++++++ .../Support/XCUIElement+Helpers.swift | 129 +++++++ .../SwiftExampleAppUITests.swift | 286 +--------------- .../WalletPersistenceTests.swift | 110 ++++++ 6 files changed, 589 insertions(+), 285 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/XCUIElement+Helpers.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/WalletPersistenceTests.swift diff --git a/.github/workflows/swift-example-app-ui-smoke.yml b/.github/workflows/swift-example-app-ui-smoke.yml index 46fe77ac076..ab233a2e5a2 100644 --- a/.github/workflows/swift-example-app-ui-smoke.yml +++ b/.github/workflows/swift-example-app-ui-smoke.yml @@ -168,6 +168,8 @@ 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 \ -parallel-testing-enabled NO \ -maximum-concurrent-test-simulator-destinations 1 \ -resultBundlePath "$RESULT_BUNDLE_PATH" 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..b6b9dc85239 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift @@ -0,0 +1,26 @@ +// +// 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 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" +} 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..9942eba06c0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -0,0 +1,321 @@ +// +// 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: - 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) { + 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 does not scroll — deleted +/// wallets disappear in place, so we wait for both the buttons and +/// staticTexts predicate matches to fail at the current scroll position. +@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 + } + + let buttonRow = app.buttons + .matching(NSPredicate(format: "label == %@", walletName)) + .firstMatch + let textRow = app.staticTexts + .matching(NSPredicate(format: "label == %@", walletName)) + .firstMatch + 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.", + 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) + } +} From b3c85a22f0e936f6d3766156941aede79bf36b45 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 28 Apr 2026 18:00:42 +0200 Subject: [PATCH 02/17] test(swift-sdk): add testnet identity-discovery UI test Imports a wallet from `UI_TEST_TESTNET_MNEMONIC`, runs DIP-9 identity discovery, and asserts the registered identity surfaces with a non-zero balance. Skipped when the env var is unset. Test is scaffolded under CreditTransferTest so the credit-transfer assertion can be re-added as a sibling method (helpers for that flow are already in WalletFlow). Adds accessibility identifiers across the views the test traverses (wallet creation, identities tab, search-wallets sheet, identity detail, options/network picker, transition form) and one accessibilityValue on the balance label so the raw credit count is parseable from XCUITest. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/ContentView.swift | 2 + .../Core/Views/CreateWalletView.swift | 2 + .../Core/Views/IdentitiesContentView.swift | 2 + .../Views/IdentitiesView.swift | 1 + .../Views/IdentityDetailView.swift | 6 + .../SwiftExampleApp/Views/OptionsView.swift | 35 +- .../SearchWalletsForIdentitiesView.swift | 3 + .../Views/TransitionDetailView.swift | 4 + .../Views/TransitionInputView.swift | 4 + .../CreditTransferTest.swift | 103 +++ .../Support/Identifiers.swift | 60 ++ .../Support/WalletFlow.swift | 649 ++++++++++++++++++ 12 files changed, 857 insertions(+), 14 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 18b6459b92a..e569dfb7b94 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 80dec9b2943..3ad0ae9093d 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 e39674a1bbb..dda02960836 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("Search Wallets 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 19b0dec1925..bc75f85bb1e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -109,6 +109,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 7dc50749d55..04f9ae176d6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -58,6 +58,7 @@ struct OptionsView: View { } .pickerStyle(SegmentedPickerStyle()) .disabled(isSwitchingNetwork) + .accessibilityIdentifier("options.networkPicker") if appState.currentNetwork == .regtest { Toggle("Use Docker Setup", isOn: $appState.useDockerSetup) @@ -84,23 +85,29 @@ struct OptionsView: View { HStack { Text("Network Status") Spacer() - if isSwitchingNetwork { - HStack(spacing: 4) { - ProgressView() - .scaleEffect(0.8) - Text("Switching...") + Group { + if 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(.secondary) + .foregroundColor(.green) + } else { + Label("Disconnected", systemImage: "xmark.circle.fill") + .font(.caption) + .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 e49bd0fa281..ad2cc588e6b 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 983b7881e3d..881a4edc5f2 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/SwiftExampleAppUITests/CreditTransferTest.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift new file mode 100644 index 00000000000..bfbf1d2f122 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift @@ -0,0 +1,103 @@ +// +// 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 credit- +// transfer assertion 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_TESTNET_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 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_TESTNET_MNEMONIC"], + !mnemonic.isEmpty + else { + throw XCTSkip("Set UI_TEST_TESTNET_MNEMONIC to run this test.") + } + XCTAssertEqual( + mnemonic.split(separator: " ").count, + 12, + "UI_TEST_TESTNET_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) + + openWalletsTab(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() + + let balance = readIdentityBalanceCredits(in: app) + XCTAssertGreaterThan( + balance, + 0, + "Discovered identity should have a non-zero balance." + ) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift index b6b9dc85239..fe352296d93 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift @@ -12,6 +12,8 @@ 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" @@ -19,8 +21,66 @@ enum Identifier { 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" + } + + enum Transition { + static let senderIdentityPicker = "transition.senderIdentityPicker" + static let executeButton = "transition.executeButton" + static let resultStatusLabel = "transition.resultStatusLabel" + + /// Per-row sender option in the senderIdentityPicker menu. + static func senderIdentityOption(_ identityIdBase58: String) -> String { + "transition.senderIdentityOption.\(identityIdBase58)" + } + + /// Generic input wrapper id — covers `toIdentityId`, `amount`, etc. + static func input(_ name: String) -> String { + "transition.input.\(name)" + } + + /// Recipient picker (multi-identity branch); contains a `Manually Enter Recipient` option. + static func recipientPicker(_ inputName: String) -> String { + "transition.input.\(inputName).recipientPicker" + } + + /// Escape-hatch button shown only when no other identities exist. + static func manualEntryButton(_ inputName: String) -> String { + "transition.input.\(inputName).manualEntryButton" + } + + /// Free-form recipient TextField, visible after either branch hits `useManualEntry = true`. + static func manualEntryField(_ inputName: String) -> String { + "transition.input.\(inputName).manualEntryField" + } + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index 9942eba06c0..9d1c9eab0f7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -198,6 +198,655 @@ func deleteWallet( ) } +// 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 + ) + + if testnetButton.isSelected { + // Already on Testnet; status should already be Connected. + } else { + testnetButton.tap() + } + + 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 + ) + 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). + let searchSheetNavBar = app.navigationBars["Search Wallets"] + var sheetOpened = false + for attempt in 1...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) + _ = attempt + } + XCTAssertTrue( + sheetOpened, + "Expected Search Wallets sheet to open after tapping the Add menu item.", + file: file, line: line + ) + + // Trust the picker's default-first auto-selection. The + // CreditTransferTest deletes any leftover wallet and re-imports a + // fresh one before this runs, so exactly one wallet is in the + // picker — the one we want. Tapping the menu picker reliably to + // pick a non-default option turns out to be flaky in XCUITest + // (`pickerStyle(.menu)` keeps the dropdown overlay around long + // enough to occlude the Search button below). Verify the picker + // currently shows our wallet's label as a sanity check, then tap + // Search. + // Generous timeout — SearchWalletsForIdentitiesView gates the picker + // on `hdWallets.isEmpty`, which is driven by an `@Query` over + // PersistentWallet. After a fresh import the SwiftData write + // → @Query update → view rerender takes a moment, and 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 + ) + XCTAssertTrue( + walletPicker.label.contains(walletName), + "Picker shows \"\(walletPicker.label)\" but the test imported \(walletName). Was an unrelated wallet selected as default?", + 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 + return label.hasPrefix("+") && 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: - Credit transfer + +/// Settings → State Transitions → Identity → Transfer Credits. +@MainActor +func navigateToIdentityCreditTransferForm( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line +) { + openSettingsTab(in: app, file: file, line: line) + + // OptionsView's Form is lazy — cells below the fold (including the + // Platform section's "State Transitions" cell) aren't in the + // accessibility tree until we scroll them in. + let stateTransitionsCell = app.buttons["State Transitions"] + for _ in 0..<8 where !stateTransitionsCell.exists { + app.swipeUp() + } + XCTAssertTrue( + stateTransitionsCell.waitForExistence(timeout: 10), + "Expected State Transitions cell in Settings.", + file: file, line: line + ) + stateTransitionsCell.tap() + + // The category rows in StateTransitionsView render an HStack with + // icon + headline + description, so the button's accessibility label + // is the composed text — `app.buttons["Identity"]` (exact label + // match) fails. Match the description text, which is unique per + // category, via CONTAINS. + let identityCategory = app.buttons + .matching(NSPredicate(format: "label CONTAINS[c] %@", "manage identities")) + .firstMatch + XCTAssertTrue( + identityCategory.waitForExistence(timeout: 10), + "Expected Identity category cell.", + file: file, line: line + ) + identityCategory.tap() + + // Same shape inside TransitionCategoryView — match by the unique + // description "Transfer credits between identities". + let transferCredits = app.buttons + .matching(NSPredicate(format: "label CONTAINS[c] %@", "Transfer credits between identities")) + .firstMatch + XCTAssertTrue( + transferCredits.waitForExistence(timeout: 10), + "Expected Transfer Credits cell.", + file: file, line: line + ) + transferCredits.tap() + + XCTAssertTrue( + app.navigationBars["Transfer Credits"].waitForExistence(timeout: 10), + "Expected Transfer Credits form.", + file: file, line: line + ) +} + +/// Drive a credit-transfer state transition. +/// Sender selection: tap the senderIdentityPicker, tap the per-row option +/// matching the sender ID. Recipient handling covers both branches of +/// `recipientIdentityPicker` (the wallet's identity-only single-identity +/// case AND the multi-identity-on-simulator case). +@MainActor +func executeCreditTransfer( + senderIdentityIdBase58: String, + recipientIdentityIdBase58: String, + amountCredits: UInt64, + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line +) { + // Sender selection. + let senderPicker = app.descendants(matching: .any) + .matching(identifier: Identifier.Transition.senderIdentityPicker) + .firstMatch + XCTAssertTrue( + senderPicker.waitForExistence(timeout: 10), + "Expected sender identity picker.", + file: file, line: line + ) + senderPicker.tap() + let senderOptionId = Identifier.Transition.senderIdentityOption(senderIdentityIdBase58) + let senderOption = app.descendants(matching: .any) + .matching(identifier: senderOptionId) + .firstMatch + if senderOption.waitForExistence(timeout: 5) { + senderOption.tap() + } else { + // Fallback: match by displayName prefix (first 12 chars + "...") + let prefix = String(senderIdentityIdBase58.prefix(12)) + let labelPredicate = NSPredicate(format: "label BEGINSWITH %@", prefix) + let senderByLabel = app.buttons.matching(labelPredicate).firstMatch + XCTAssertTrue( + senderByLabel.waitForExistence(timeout: 5), + "Expected sender option \(senderIdentityIdBase58).", + file: file, line: line + ) + senderByLabel.tap() + } + + // Wait for the picker's menu overlay to dismiss and the toIdentityId + // form input wrapper to render. The menu overlay can occlude + // descendants beneath it for a moment after a selection. + let toIdentityWrapper = app.descendants(matching: .any) + .matching(identifier: Identifier.Transition.input("toIdentityId")) + .firstMatch + XCTAssertTrue( + toIdentityWrapper.waitForExistence(timeout: 15), + "Expected toIdentityId input wrapper to render after sender selection. (Did the picker menu overlay get stuck open, or did selectedIdentityId not propagate?)", + file: file, line: line + ) + + // Recipient: reach the manual-entry text field via either of the two + // recipientIdentityPicker branches. Match descendants of the wrapper + // to avoid picking up unrelated buttons elsewhere on screen. + let manualButton = toIdentityWrapper.buttons + .matching(identifier: Identifier.Transition.manualEntryButton("toIdentityId")) + .firstMatch + if manualButton.waitForExistence(timeout: 5) && manualButton.isHittable { + manualButton.tap() + } else { + let recipientPicker = toIdentityWrapper.descendants(matching: .any) + .matching(identifier: Identifier.Transition.recipientPicker("toIdentityId")) + .firstMatch + XCTAssertTrue( + recipientPicker.waitForExistence(timeout: 10), + "Expected either manual-entry button or recipient picker for toIdentityId.", + file: file, line: line + ) + recipientPicker.tap() + let manualOption = app.buttons["💳 Manually Enter Recipient"] + XCTAssertTrue( + manualOption.waitForExistence(timeout: 5), + "Expected 'Manually Enter Recipient' option in recipient picker menu.", + file: file, line: line + ) + manualOption.tap() + } + + let recipientField = app.textFields + .matching(identifier: Identifier.Transition.manualEntryField("toIdentityId")) + .firstMatch + XCTAssertTrue( + recipientField.waitForExistence(timeout: 5), + "Expected manual-entry recipient field.", + file: file, line: line + ) + recipientField.tap() + recipientField.typeText(recipientIdentityIdBase58) + + // Amount. + let amountWrapper = app.descendants(matching: .any) + .matching(identifier: Identifier.Transition.input("amount")) + .firstMatch + XCTAssertTrue( + amountWrapper.waitForExistence(timeout: 5), + "Expected amount input wrapper.", + file: file, line: line + ) + let amountField = amountWrapper.textFields.firstMatch + XCTAssertTrue( + amountField.waitForExistence(timeout: 5), + "Expected amount TextField.", + file: file, line: line + ) + amountField.tap() + amountField.typeText(String(amountCredits)) + app.swipeDown() + + let executeButton = app.buttons + .matching(identifier: Identifier.Transition.executeButton) + .firstMatch + XCTAssertTrue( + waitForElementToBeEnabled(executeButton, timeout: 10), + "Expected Execute Transition button to enable.", + file: file, line: line + ) + executeButton.tap() +} + +@MainActor +func waitForCreditTransferSuccess( + in app: XCUIApplication, + timeout: TimeInterval = 30, + file: StaticString = #filePath, + line: UInt = #line +) { + let resultStatus = app.staticTexts + .matching(identifier: Identifier.Transition.resultStatusLabel) + .firstMatch + XCTAssertTrue( + resultStatus.waitForExistence(timeout: timeout), + "Expected transition result status label.", + file: file, line: line + ) + XCTAssertEqual( + resultStatus.label, + "Success", + "Transition reported Error rather than Success.", + file: file, line: line + ) +} + +// 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. +@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) + var iteration = 0 + while iteration < 8 { + iteration += 1 + let row = app.buttons.matching(predicate).firstMatch + if !row.waitForExistence(timeout: 2) { + 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 From 7c0ae9492d2cbf589b7b69e46eac6b7e6eeb185e Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 29 Apr 2026 08:34:47 +0200 Subject: [PATCH 03/17] fix(swift-sdk): address PR #3560 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Network-switch race (BLOCKING from both reviewers): lift `isSwitchingNetwork` from OptionsView's local @State to AppState. The flag now spans the full async cycle (currentNetwork.didSet → Task → switchNetwork → sdk = newSDK), so the network status label and test-side `Connected` predicate stop reading the previous network's SDK as a successful switch. Test reliability: - runIdentityDiscovery drives the wallet picker explicitly instead of trusting default-first auto-selection, so unrelated wallets on the simulator no longer hijack discovery. - assertWalletRowVisible(exists: false) scrolls to the top and sweeps down before declaring absent, closing a false-pass on long lists where SwiftUI's lazy List could leave a still-persisted row outside the accessibility tree. - CreditTransferTest drops the `balance > 0` floor (couples to live testnet funding state); readIdentityBalanceCredits already XCFails on parse error, so reaching the next line is the readability signal. - Re-run failIfRecoveryPromptVisible after switchAppNetworkToTestnet — 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. - cleanupWalletsByPrefix("ImportTransfer-") on entry sweeps random- suffixed wallets from prior failed runs (the deterministic walletId-based check missed them). - Discovery retry loop only re-taps the Add menu when its menu item isn't still visible from a prior attempt — re-tapping while the menu is open closes it. Trim: - Delete unused credit-transfer helpers (navigateToIdentityCreditTransferForm, executeCreditTransfer, waitForCreditTransferSuccess) and Identifier.Transition. They will return alongside the credit-transfer test in a follow-up; the corresponding app-side .accessibilityIdentifier calls on TransitionDetailView/TransitionInputView are kept (harmless). CI: - Wire CreditTransferTest into the smoke workflow with -only-testing. Self-skips when UI_TEST_TESTNET_MNEMONIC is unset, so the new line is a no-op for forks/PRs without the secret while still exercising the build path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/swift-example-app-ui-smoke.yml | 1 + .../SwiftExampleApp/AppState.swift | 17 +- .../SwiftExampleApp/Views/OptionsView.swift | 50 ++- .../CreditTransferTest.swift | 24 +- .../Support/Identifiers.swift | 31 -- .../Support/WalletFlow.swift | 287 ++++-------------- 6 files changed, 112 insertions(+), 298 deletions(-) diff --git a/.github/workflows/swift-example-app-ui-smoke.yml b/.github/workflows/swift-example-app-ui-smoke.yml index ab233a2e5a2..aa049ec1214 100644 --- a/.github/workflows/swift-example-app-ui-smoke.yml +++ b/.github/workflows/swift-example-app-ui-smoke.yml @@ -170,6 +170,7 @@ jobs: -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" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index 8a04aabe4a7..bd233f10707 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -9,11 +9,22 @@ 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 + @Published var currentNetwork: AppNetwork { didSet { UserDefaults.standard.set(currentNetwork.rawValue, forKey: "currentNetwork") + isSwitchingNetwork = true Task { await switchNetwork(to: currentNetwork) + isSwitchingNetwork = false } } } @@ -27,7 +38,11 @@ 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) } + isSwitchingNetwork = true + Task { + await switchNetwork(to: currentNetwork) + isSwitchingNetwork = false + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 04f9ae176d6..8e599dd7361 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 @@ -28,27 +27,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() } } )) { @@ -57,18 +51,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 { @@ -86,7 +76,7 @@ struct OptionsView: View { Text("Network Status") Spacer() Group { - if isSwitchingNetwork { + if appState.isSwitchingNetwork { HStack(spacing: 4) { ProgressView() .scaleEffect(0.8) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift index bfbf1d2f122..b376715059b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift @@ -57,9 +57,21 @@ final class CreditTransferTest: XCTestCase { // 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 @@ -93,11 +105,11 @@ final class CreditTransferTest: XCTestCase { let senderRow = waitForIdentityRow(idBase58: expectedSenderIdentityIdBase58, in: app) senderRow.tap() - let balance = readIdentityBalanceCredits(in: app) - XCTAssertGreaterThan( - balance, - 0, - "Discovered identity should have a non-zero balance." - ) + // Helper XCTFails if the balance can't be parsed; reaching this + // line confirms identityDetail.balanceLabel exposed a readable + // credit count. We deliberately don't assert a non-zero floor — + // the test's scope is discovery + balance readability, not the + // external testnet-funding state of the fixture identity. + _ = readIdentityBalanceCredits(in: app) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift index fe352296d93..d8c7cb2d548 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/Identifiers.swift @@ -52,35 +52,4 @@ enum Identifier { enum IdentityDetail { static let balanceLabel = "identityDetail.balanceLabel" } - - enum Transition { - static let senderIdentityPicker = "transition.senderIdentityPicker" - static let executeButton = "transition.executeButton" - static let resultStatusLabel = "transition.resultStatusLabel" - - /// Per-row sender option in the senderIdentityPicker menu. - static func senderIdentityOption(_ identityIdBase58: String) -> String { - "transition.senderIdentityOption.\(identityIdBase58)" - } - - /// Generic input wrapper id — covers `toIdentityId`, `amount`, etc. - static func input(_ name: String) -> String { - "transition.input.\(name)" - } - - /// Recipient picker (multi-identity branch); contains a `Manually Enter Recipient` option. - static func recipientPicker(_ inputName: String) -> String { - "transition.input.\(inputName).recipientPicker" - } - - /// Escape-hatch button shown only when no other identities exist. - static func manualEntryButton(_ inputName: String) -> String { - "transition.input.\(inputName).manualEntryButton" - } - - /// Free-form recipient TextField, visible after either branch hits `useManualEntry = true`. - static func manualEntryField(_ inputName: String) -> String { - "transition.input.\(inputName).manualEntryField" - } - } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index 9d1c9eab0f7..ef34d921c3a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -462,15 +462,18 @@ func runIdentityDiscovery( // 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). + // by the actual signal (Search Wallets nav bar appears). Re-tap + // `addMenu` only when the menu item from the previous attempt isn't + // still visible — re-tapping while the menu is open *closes* it. let searchSheetNavBar = app.navigationBars["Search Wallets"] var sheetOpened = false - for attempt in 1...3 where !sheetOpened { - addMenu.tap() - + for _ in 0..<3 where !sheetOpened { let searchMenuItem = app.descendants(matching: .any) .matching(identifier: Identifier.Identities.searchWalletsMenuItem) .firstMatch + if !searchMenuItem.exists { + addMenu.tap() + } if searchMenuItem.waitForExistence(timeout: 3) { searchMenuItem.tap() } else { @@ -481,7 +484,6 @@ func runIdentityDiscovery( } } sheetOpened = searchSheetNavBar.waitForExistence(timeout: 5) - _ = attempt } XCTAssertTrue( sheetOpened, @@ -489,21 +491,16 @@ func runIdentityDiscovery( file: file, line: line ) - // Trust the picker's default-first auto-selection. The - // CreditTransferTest deletes any leftover wallet and re-imports a - // fresh one before this runs, so exactly one wallet is in the - // picker — the one we want. Tapping the menu picker reliably to - // pick a non-default option turns out to be flaky in XCUITest - // (`pickerStyle(.menu)` keeps the dropdown overlay around long - // enough to occlude the Search button below). Verify the picker - // currently shows our wallet's label as a sanity check, then tap - // Search. - // Generous timeout — SearchWalletsForIdentitiesView gates the picker - // on `hdWallets.isEmpty`, which is driven by an `@Query` over - // PersistentWallet. After a fresh import the SwiftData write - // → @Query update → view rerender takes a moment, and during that - // window the view shows the "No wallets loaded" branch instead of - // the picker. 20s comfortably covers the propagation lag. + // 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 @@ -512,9 +509,25 @@ func runIdentityDiscovery( "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. + 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() + } XCTAssertTrue( walletPicker.label.contains(walletName), - "Picker shows \"\(walletPicker.label)\" but the test imported \(walletName). Was an unrelated wallet selected as default?", + "Picker shows \"\(walletPicker.label)\" but the test imported \(walletName).", file: file, line: line ) @@ -617,211 +630,6 @@ func readIdentityBalanceCredits( return credits } -// MARK: - Credit transfer - -/// Settings → State Transitions → Identity → Transfer Credits. -@MainActor -func navigateToIdentityCreditTransferForm( - in app: XCUIApplication, - file: StaticString = #filePath, - line: UInt = #line -) { - openSettingsTab(in: app, file: file, line: line) - - // OptionsView's Form is lazy — cells below the fold (including the - // Platform section's "State Transitions" cell) aren't in the - // accessibility tree until we scroll them in. - let stateTransitionsCell = app.buttons["State Transitions"] - for _ in 0..<8 where !stateTransitionsCell.exists { - app.swipeUp() - } - XCTAssertTrue( - stateTransitionsCell.waitForExistence(timeout: 10), - "Expected State Transitions cell in Settings.", - file: file, line: line - ) - stateTransitionsCell.tap() - - // The category rows in StateTransitionsView render an HStack with - // icon + headline + description, so the button's accessibility label - // is the composed text — `app.buttons["Identity"]` (exact label - // match) fails. Match the description text, which is unique per - // category, via CONTAINS. - let identityCategory = app.buttons - .matching(NSPredicate(format: "label CONTAINS[c] %@", "manage identities")) - .firstMatch - XCTAssertTrue( - identityCategory.waitForExistence(timeout: 10), - "Expected Identity category cell.", - file: file, line: line - ) - identityCategory.tap() - - // Same shape inside TransitionCategoryView — match by the unique - // description "Transfer credits between identities". - let transferCredits = app.buttons - .matching(NSPredicate(format: "label CONTAINS[c] %@", "Transfer credits between identities")) - .firstMatch - XCTAssertTrue( - transferCredits.waitForExistence(timeout: 10), - "Expected Transfer Credits cell.", - file: file, line: line - ) - transferCredits.tap() - - XCTAssertTrue( - app.navigationBars["Transfer Credits"].waitForExistence(timeout: 10), - "Expected Transfer Credits form.", - file: file, line: line - ) -} - -/// Drive a credit-transfer state transition. -/// Sender selection: tap the senderIdentityPicker, tap the per-row option -/// matching the sender ID. Recipient handling covers both branches of -/// `recipientIdentityPicker` (the wallet's identity-only single-identity -/// case AND the multi-identity-on-simulator case). -@MainActor -func executeCreditTransfer( - senderIdentityIdBase58: String, - recipientIdentityIdBase58: String, - amountCredits: UInt64, - in app: XCUIApplication, - file: StaticString = #filePath, - line: UInt = #line -) { - // Sender selection. - let senderPicker = app.descendants(matching: .any) - .matching(identifier: Identifier.Transition.senderIdentityPicker) - .firstMatch - XCTAssertTrue( - senderPicker.waitForExistence(timeout: 10), - "Expected sender identity picker.", - file: file, line: line - ) - senderPicker.tap() - let senderOptionId = Identifier.Transition.senderIdentityOption(senderIdentityIdBase58) - let senderOption = app.descendants(matching: .any) - .matching(identifier: senderOptionId) - .firstMatch - if senderOption.waitForExistence(timeout: 5) { - senderOption.tap() - } else { - // Fallback: match by displayName prefix (first 12 chars + "...") - let prefix = String(senderIdentityIdBase58.prefix(12)) - let labelPredicate = NSPredicate(format: "label BEGINSWITH %@", prefix) - let senderByLabel = app.buttons.matching(labelPredicate).firstMatch - XCTAssertTrue( - senderByLabel.waitForExistence(timeout: 5), - "Expected sender option \(senderIdentityIdBase58).", - file: file, line: line - ) - senderByLabel.tap() - } - - // Wait for the picker's menu overlay to dismiss and the toIdentityId - // form input wrapper to render. The menu overlay can occlude - // descendants beneath it for a moment after a selection. - let toIdentityWrapper = app.descendants(matching: .any) - .matching(identifier: Identifier.Transition.input("toIdentityId")) - .firstMatch - XCTAssertTrue( - toIdentityWrapper.waitForExistence(timeout: 15), - "Expected toIdentityId input wrapper to render after sender selection. (Did the picker menu overlay get stuck open, or did selectedIdentityId not propagate?)", - file: file, line: line - ) - - // Recipient: reach the manual-entry text field via either of the two - // recipientIdentityPicker branches. Match descendants of the wrapper - // to avoid picking up unrelated buttons elsewhere on screen. - let manualButton = toIdentityWrapper.buttons - .matching(identifier: Identifier.Transition.manualEntryButton("toIdentityId")) - .firstMatch - if manualButton.waitForExistence(timeout: 5) && manualButton.isHittable { - manualButton.tap() - } else { - let recipientPicker = toIdentityWrapper.descendants(matching: .any) - .matching(identifier: Identifier.Transition.recipientPicker("toIdentityId")) - .firstMatch - XCTAssertTrue( - recipientPicker.waitForExistence(timeout: 10), - "Expected either manual-entry button or recipient picker for toIdentityId.", - file: file, line: line - ) - recipientPicker.tap() - let manualOption = app.buttons["💳 Manually Enter Recipient"] - XCTAssertTrue( - manualOption.waitForExistence(timeout: 5), - "Expected 'Manually Enter Recipient' option in recipient picker menu.", - file: file, line: line - ) - manualOption.tap() - } - - let recipientField = app.textFields - .matching(identifier: Identifier.Transition.manualEntryField("toIdentityId")) - .firstMatch - XCTAssertTrue( - recipientField.waitForExistence(timeout: 5), - "Expected manual-entry recipient field.", - file: file, line: line - ) - recipientField.tap() - recipientField.typeText(recipientIdentityIdBase58) - - // Amount. - let amountWrapper = app.descendants(matching: .any) - .matching(identifier: Identifier.Transition.input("amount")) - .firstMatch - XCTAssertTrue( - amountWrapper.waitForExistence(timeout: 5), - "Expected amount input wrapper.", - file: file, line: line - ) - let amountField = amountWrapper.textFields.firstMatch - XCTAssertTrue( - amountField.waitForExistence(timeout: 5), - "Expected amount TextField.", - file: file, line: line - ) - amountField.tap() - amountField.typeText(String(amountCredits)) - app.swipeDown() - - let executeButton = app.buttons - .matching(identifier: Identifier.Transition.executeButton) - .firstMatch - XCTAssertTrue( - waitForElementToBeEnabled(executeButton, timeout: 10), - "Expected Execute Transition button to enable.", - file: file, line: line - ) - executeButton.tap() -} - -@MainActor -func waitForCreditTransferSuccess( - in app: XCUIApplication, - timeout: TimeInterval = 30, - file: StaticString = #filePath, - line: UInt = #line -) { - let resultStatus = app.staticTexts - .matching(identifier: Identifier.Transition.resultStatusLabel) - .firstMatch - XCTAssertTrue( - resultStatus.waitForExistence(timeout: timeout), - "Expected transition result status label.", - file: file, line: line - ) - XCTAssertEqual( - resultStatus.label, - "Success", - "Transition reported Error rather than Success.", - file: file, line: line - ) -} - // MARK: - Pre-import cleanup /// Deletes any wallet whose label starts with the given prefix. Used at @@ -926,9 +734,10 @@ func scrollToWalletRow(named walletName: String, in app: XCUIApplication) -> XCU /// 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 does not scroll — deleted -/// wallets disappear in place, so we wait for both the buttons and -/// staticTexts predicate matches to fail at the current scroll position. +/// `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, @@ -949,12 +758,30 @@ func assertWalletRowVisible( 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 } @@ -963,7 +790,7 @@ func assertWalletRowVisible( XCTAssertEqual( result, .completed, - "Expected wallet row \(walletName) to be absent.", + "Expected wallet row \(walletName) to be absent after sweep.", file: file, line: line ) From c08f9fa1fb0872a479dc72286feca9238a32f672 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 29 Apr 2026 09:51:44 +0200 Subject: [PATCH 04/17] fix(swift-sdk): address PR #3560 follow-up review Three findings from coderabbit's re-review of 7c0ae9492: - AppState: overlapping network switches could cause an earlier task to clear `isSwitchingNetwork` while a later switch was still running. Add a monotonic `networkSwitchRequestID`; each spawned task captures its id at start and only clears the flag when its id still matches. - CreditTransferTest header: aligned the file-level comment with the current scope (balance readability, not non-zero balance). - WalletFlow `runIdentityDiscovery`: replaced the bare label-contains assertion after `walletOption.tap()` with an `XCTNSPredicateExpectation` wait so the picker has time to propagate the selection on slower simulators. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/AppState.swift | 32 ++++++++++++++----- .../CreditTransferTest.swift | 6 ++-- .../Support/WalletFlow.swift | 16 ++++++++-- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index bd233f10707..258b5e11a8f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -18,14 +18,18 @@ class AppState: ObservableObject { /// 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: AppNetwork { didSet { UserDefaults.standard.set(currentNetwork.rawValue, forKey: "currentNetwork") - isSwitchingNetwork = true - Task { - await switchNetwork(to: currentNetwork) - isSwitchingNetwork = false - } + beginNetworkSwitch() } } @@ -38,9 +42,21 @@ class AppState: ObservableObject { UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhostPlatform") UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhostCore") UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhost") - isSwitchingNetwork = true - 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/SwiftExampleAppUITests/CreditTransferTest.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift index b376715059b..3790af7ce43 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift @@ -4,8 +4,10 @@ // // 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 credit- -// transfer assertion is deferred to a follow-up. +// expected identity's balance is readable from the identity-detail +// view. We do not assert a non-zero floor — that would couple the +// test to live testnet funding state. The credit-transfer assertion +// 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. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index ef34d921c3a..fb32c8c58f3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -525,8 +525,20 @@ func runIdentityDiscovery( ) walletOption.tap() } - XCTAssertTrue( - walletPicker.label.contains(walletName), + // 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 ) From 52312a24981212f840cddb6625db75b6df0cc5be Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 29 Apr 2026 10:15:33 +0200 Subject: [PATCH 05/17] ci(swift-sdk): wire UI_TEST_MNEMONIC secret into smoke workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the testnet mnemonic env var the XCUITest reads to `UI_TEST_MNEMONIC` and forwards it from a GitHub secret of the same name into the xcodebuild step as `TEST_RUNNER_UI_TEST_MNEMONIC` (the prefix is required — xcodebuild strips it before handing the env to the XCUITest runner process). Empty on fork PRs (GitHub withholds secrets there), at which point the test self-skips. Once a repository secret named `UI_TEST_MNEMONIC` is set, `testImportWalletAndDiscoverIdentity` runs end-to-end against testnet on every push to a non-fork branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/swift-example-app-ui-smoke.yml | 6 ++++++ .../SwiftExampleAppUITests/CreditTransferTest.swift | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/swift-example-app-ui-smoke.yml b/.github/workflows/swift-example-app-ui-smoke.yml index aa049ec1214..12b5a9cfba6 100644 --- a/.github/workflows/swift-example-app-ui-smoke.yml +++ b/.github/workflows/swift-example-app-ui-smoke.yml @@ -161,6 +161,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 \ diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift index 3790af7ce43..06f08f8f6b5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift @@ -13,7 +13,7 @@ // suite can run locally without test-network credentials. // // Env var: -// * UI_TEST_TESTNET_MNEMONIC — sender wallet's 12-word phrase +// * 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 @@ -41,15 +41,15 @@ final class CreditTransferTest: XCTestCase { @MainActor func testImportWalletAndDiscoverIdentity() throws { - guard let mnemonic = ProcessInfo.processInfo.environment["UI_TEST_TESTNET_MNEMONIC"], + guard let mnemonic = ProcessInfo.processInfo.environment["UI_TEST_MNEMONIC"], !mnemonic.isEmpty else { - throw XCTSkip("Set UI_TEST_TESTNET_MNEMONIC to run this test.") + throw XCTSkip("Set UI_TEST_MNEMONIC to run this test.") } XCTAssertEqual( mnemonic.split(separator: " ").count, 12, - "UI_TEST_TESTNET_MNEMONIC must be a 12-word phrase." + "UI_TEST_MNEMONIC must be a 12-word phrase." ) let app = XCUIApplication() From e8eb12300e9313f131d553b4157a16beb94c7719 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 29 Apr 2026 10:31:24 +0200 Subject: [PATCH 06/17] ci(swift-sdk): run Swift Example App UI smoke nightly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 23:00 UTC daily cron to the smoke workflow, alongside the existing manual `workflow_dispatch` trigger. Mirrors the repo's nightly pattern (separate workflow with its own cron, like `tests-rs-nightly-long-running.yml`). Also adds a concurrency block so a manual dispatch and a cron run don't fight over the single self-hosted macOS ARM64 runner — the older run is cancelled in favor of the newer one. The cron only takes effect once this lands on the default branch (`v3.1-dev`); GitHub Actions schedules run against the default branch's workflow file. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/swift-example-app-ui-smoke.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/swift-example-app-ui-smoke.yml b/.github/workflows/swift-example-app-ui-smoke.yml index 12b5a9cfba6..a1ed081c66a 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 From 4e4cf1ad3722cfac82f41c820971aa7e051a0005 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 29 Apr 2026 10:47:59 +0200 Subject: [PATCH 07/17] docs(swift-sdk): add UI test suite README Captures the operational gotchas that took multiple sessions to re-discover: - `TEST_RUNNER_` prefix gate when passing `UI_TEST_MNEMONIC` via `xcodebuild test ENV=`. - `simctl erase` is the only way to clear stale Keychain entries between runs (uninstall alone leaves orphan mnemonics that trip the recovery prompt). - SwiftData migration failures from leftover stores manifest as a "Expected root tab bar" timeout, not an obvious crash. - The CI nightly cron's reliance on the self-hosted Mac being online. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleAppUITests/README.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/README.md 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. From 296d96614790a3ab6675fbd219932aa2d3c20802 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 29 Apr 2026 11:14:32 +0200 Subject: [PATCH 08/17] fix(swift-sdk): address PR #3560 follow-up review (4e4cf1ad) Three findings from coderabbit's re-review: - `switchAppNetworkToTestnet`: wait for the testnet segment's `isSelected == true` before trusting the "Connected" predicate. The AppState fix already covers the rebind-in-progress race; this closes the missed-tap window where the picker setter never fires and the previous network's "Connected" still satisfies the wait. - `runIdentityDiscovery`: anchor the wallet-option predicate with a trailing space (`BEGINSWITH " "`) so a longer wallet name sharing a prefix can't win firstMatch. - `cleanupWalletsByPrefix`: scroll-and-sweep the full wallets list instead of bailing when no matching row is visible in the current viewport. Resets to top, then up to 10 delete+reset cycles + 10 scroll passes per call. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Support/WalletFlow.swift | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index fb32c8c58f3..a96c5edcedb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -404,12 +404,31 @@ func switchAppNetworkToTestnet( file: file, line: line ) - if testnetButton.isSelected { - // Already on Testnet; status should already be Connected. - } else { + if !testnetButton.isSelected { 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 @@ -513,10 +532,12 @@ func runIdentityDiscovery( // 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. + // 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)) + .matching(NSPredicate(format: "label BEGINSWITH %@", "\(walletName) ")) .firstMatch XCTAssertTrue( walletOption.waitForExistence(timeout: 5), @@ -649,21 +670,35 @@ func readIdentityBalanceCredits( /// failed runs — re-importing the same mnemonic otherwise hits /// `Wallet operation: Wallet already exists` because walletId is /// deterministic from the mnemonic. +/// +/// Sweeps the entire wallets list (not just the current viewport): a +/// developer with N accumulated `ImportTransfer-*` wallets from prior +/// runs has some sitting below the fold, where `firstMatch` of an +/// unrooted predicate query won't see them. @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) - var iteration = 0 - while iteration < 8 { - iteration += 1 + + // Reset toward the top so the sweep starts at a known position. + for _ in 0..<6 { app.swipeDown() } + + // Each iteration: if a matching row is visible, delete it (which + // navigates away) and reset back to the wallets list, then continue + // from the top. If nothing visible, scroll up to expose more rows. + // 20 iterations = up to ~10 deletions + ~10 swipes worth of list. + for _ in 0..<20 { let row = app.buttons.matching(predicate).firstMatch - if !row.waitForExistence(timeout: 2) { - return + if row.exists { + let name = row.label + bestEffortDeleteWallet(named: name, in: app) + openWalletsTab(in: app) + for _ in 0..<6 { app.swipeDown() } + continue } - let name = row.label - bestEffortDeleteWallet(named: name, in: app) + app.swipeUp() } } From 33b6f3cc17c580f8d541f5cf47ef20624c24b7fd Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 4 May 2026 09:33:31 +0200 Subject: [PATCH 09/17] fix(swift-sdk): address PR #3560 follow-up review (3823ee4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five reviewer findings + two collateral fixes from the v3.1-dev merge: Reviewer findings: - `switchAppNetworkToTestnet`: when we actually tapped to switch, observe the label transition through "Switching..." before trusting "Connected". Defends against the predicate sampling stale "Connected" state from the previous network before SwiftUI re-renders. - `CreditTransferTest`: hardcoded `expectedSenderIdentityIdBase58` and `expectedSenderWalletIdHex` are deterministic functions of the secret mnemonic — added a regeneration-steps comment block so a future rotation doesn't surface as an opaque "identity row not found" timeout. - `runIdentityDiscovery` foundCount predicate: tightened from `hasPrefix("+") && != "+0"` to also require the suffix to be all digits, defending against future label format drift. - Restore `XCTAssertGreaterThan(credits, 0, ...)` on the balance read. `IdentityDetailView.onAppear` doesn't refresh balance, so a regression that breaks balance discovery (returns 0) would silently pass the parseability-only assertion. Updated the file header comment to match. - `bestEffortDeleteWallet`: dismiss the orphan-mnemonic recovery alert best-effort at the top of the helper. Otherwise a leaked partial wallet write from an earlier failure stalls all teardown blocks silently. - Workflow: only upload the xcresult bundle on `failure()` (not always) and reduce retention from 14 to 7 days. The bundle includes the XCUITest activity log which records `typeText` arguments — `importWallet` types the testnet mnemonic. Limiting to failures narrows the leak surface. Collateral from the v3.1-dev merge in 3823ee4a: - `KeyManagerTests`: `KeyFormatter.toWIF(_:isTestnet:)` was renamed to `KeyFormatter.toWIF(_:network:)` upstream but this test file wasn't updated. One-line signature fix; otherwise `xcodebuild test` won't compile. - `cleanupWalletsByPrefix`: revert the full-list scroll-and-sweep added in c08f9fa1 (intended to address a coderabbit suggestion). On an empty wallet list the blind `swipeUp` calls trip XCUITest's 60s event-synthesis timeout per swipe, blowing test runtime out by 20+ minutes. Bounded bail-on-no-match version with a longer 10-iteration cap is a better balance; the off-screen-rows risk is documented in the helper comment and `simctl erase` is the right developer recovery. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/swift-example-app-ui-smoke.yml | 11 +- .../KeyManagerTests.swift | 2 +- .../CreditTransferTest.swift | 47 ++++++-- .../Support/WalletFlow.swift | 105 +++++++++++++----- 4 files changed, 124 insertions(+), 41 deletions(-) diff --git a/.github/workflows/swift-example-app-ui-smoke.yml b/.github/workflows/swift-example-app-ui-smoke.yml index a1ed081c66a..af837c0ac7e 100644 --- a/.github/workflows/swift-example-app-ui-smoke.yml +++ b/.github/workflows/swift-example-app-ui-smoke.yml @@ -196,13 +196,20 @@ jobs: -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/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 index 06f08f8f6b5..473a6a35737 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/CreditTransferTest.swift @@ -4,10 +4,12 @@ // // Imports a wallet from a known testnet mnemonic that already has a // registered identity, runs identity discovery, and asserts that the -// expected identity's balance is readable from the identity-detail -// view. We do not assert a non-zero floor — that would couple the -// test to live testnet funding state. The credit-transfer assertion -// is deferred to a follow-up. +// 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. @@ -25,6 +27,25 @@ 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. @@ -107,11 +128,17 @@ final class CreditTransferTest: XCTestCase { let senderRow = waitForIdentityRow(idBase58: expectedSenderIdentityIdBase58, in: app) senderRow.tap() - // Helper XCTFails if the balance can't be parsed; reaching this - // line confirms identityDetail.balanceLabel exposed a readable - // credit count. We deliberately don't assert a non-zero floor — - // the test's scope is discovery + balance readability, not the - // external testnet-funding state of the fixture identity. - _ = readIdentityBalanceCredits(in: app) + // 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/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index a96c5edcedb..236e34321af 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -404,7 +404,8 @@ func switchAppNetworkToTestnet( file: file, line: line ) - if !testnetButton.isSelected { + let tappedToSwitch = !testnetButton.isSelected + if tappedToSwitch { testnetButton.tap() } @@ -437,6 +438,33 @@ func switchAppNetworkToTestnet( "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") @@ -481,18 +509,22 @@ func runIdentityDiscovery( // 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` only when the menu item from the previous attempt isn't - // still visible — re-tapping while the menu is open *closes* it. + // 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.exists { - addMenu.tap() - } if searchMenuItem.waitForExistence(timeout: 3) { searchMenuItem.tap() } else { @@ -580,7 +612,13 @@ func runIdentityDiscovery( let foundPredicate = NSPredicate { object, _ in guard let element = object as? XCUIElement, element.exists else { return false } let label = element.label - return label.hasPrefix("+") && label != "+0" + // 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( @@ -671,34 +709,26 @@ func readIdentityBalanceCredits( /// `Wallet operation: Wallet already exists` because walletId is /// deterministic from the mnemonic. /// -/// Sweeps the entire wallets list (not just the current viewport): a -/// developer with N accumulated `ImportTransfer-*` wallets from prior -/// runs has some sitting below the fold, where `firstMatch` of an -/// unrooted predicate query won't see them. +/// 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) - - // Reset toward the top so the sweep starts at a known position. - for _ in 0..<6 { app.swipeDown() } - - // Each iteration: if a matching row is visible, delete it (which - // navigates away) and reset back to the wallets list, then continue - // from the top. If nothing visible, scroll up to expose more rows. - // 20 iterations = up to ~10 deletions + ~10 swipes worth of list. - for _ in 0..<20 { + for _ in 0..<10 { let row = app.buttons.matching(predicate).firstMatch - if row.exists { - let name = row.label - bestEffortDeleteWallet(named: name, in: app) - openWalletsTab(in: app) - for _ in 0..<6 { app.swipeDown() } - continue + if !row.waitForExistence(timeout: 1) { + return } - app.swipeUp() + let name = row.label + bestEffortDeleteWallet(named: name, in: app) } } @@ -712,6 +742,25 @@ func cleanupWalletsByPrefix(_ prefix: String, in app: XCUIApplication) { /// 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 From 983f89c20fd4c0f2bb8b0a04b5063cdd2ffbd76d Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 12 May 2026 12:30:39 +0200 Subject: [PATCH 10/17] fix(swift-sdk): address PR #3560 follow-up review - WalletFlow.swift: match the discovery sheet's actual nav title ("Re-scan for Identities") so testImportWalletAndDiscoverIdentity can detect the sheet opening - AppState.swift: thread networkSwitchRequestID into switchNetwork and guard each shared-state mutation, so stale Tasks from an earlier switch can't overwrite sdk/dataManager/isLoading after a newer switch has begun Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/SwiftExampleApp/AppState.swift | 14 ++++++++++++-- .../Support/WalletFlow.swift | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index c86e93ed62e..cc298a9f0e8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -55,13 +55,19 @@ class AppState: ObservableObject { let requestID = networkSwitchRequestID isSwitchingNetwork = true Task { - await switchNetwork(to: currentNetwork) + await switchNetwork(to: currentNetwork, requestID: requestID) if requestID == networkSwitchRequestID { isSwitchingNetwork = false } } } + /// True if `token` is still the most recent network-switch request. + /// Stale tasks bail out before mutating shared state. + private func isCurrent(_ token: UInt64) -> Bool { + token == networkSwitchRequestID + } + // Identity-key signing is performed per-flow via a fresh // `KeychainSigner` constructed from the active `ModelContainer` // (see `CreateIdentityView.submit()`). `AppState` no longer holds @@ -132,8 +138,9 @@ class AppState: ObservableObject { showError = true } - func switchNetwork(to network: Network) async { + func switchNetwork(to network: Network, requestID: UInt64) async { guard let modelContext = modelContext else { return } + guard isCurrent(requestID) else { return } // Identities, contracts, documents, and token balances are // scoped per-network inside SwiftData. `@Query` consumers @@ -149,13 +156,16 @@ class AppState: ObservableObject { // Create new SDK instance for the network let newSDK = try SDK(network: network) + guard isCurrent(requestID) else { return } sdk = newSDK // Load known contracts into the SDK's trusted provider await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) + guard isCurrent(requestID) else { return } isLoading = false } catch { + guard isCurrent(requestID) else { return } sdk = nil showError(message: "Failed to switch network: \(error.localizedDescription)") NSLog("❌ AppState.switchNetwork: \(error)") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index 236e34321af..a8125b838de 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -517,7 +517,7 @@ func runIdentityDiscovery( // 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"] + let searchSheetNavBar = app.navigationBars["Re-scan for Identities"] var sheetOpened = false for _ in 0..<3 where !sheetOpened { addMenu.tap() @@ -538,7 +538,7 @@ func runIdentityDiscovery( } XCTAssertTrue( sheetOpened, - "Expected Search Wallets sheet to open after tapping the Add menu item.", + "Expected Re-scan for Identities sheet to open after tapping the Add menu item.", file: file, line: line ) From 4801f03a9a45b5d055f72075026f954745b158f1 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 12 May 2026 12:39:51 +0200 Subject: [PATCH 11/17] fix(swift-sdk): address PR #3560 follow-up review - WalletFlow.swift: close stale menu before re-opening so each retry gets a fresh accessibility-tree snapshot instead of toggling the menu closed - WalletFlow.swift: bestEffortDeleteWallet teardown dismisses both "Recover Wallet?" and "Recover Wallets?" alert variants - README.md: match the suite description to the actual non-zero-credits assertion in testImportWalletAndDiscoverIdentity Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleAppUITests/README.md | 2 +- .../Support/WalletFlow.swift | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/README.md b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/README.md index 0573317b76b..79ac00cc8b0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/README.md +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/README.md @@ -7,7 +7,7 @@ XCUITest suite that drives the SwiftExampleApp through real user flows on the iO - `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`.** +- `CreditTransferTest.testImportWalletAndDiscoverIdentity` — imports a known testnet mnemonic, runs DIP-9 identity discovery, asserts the registered identity has a non-zero credit balance. **Self-skips without `UI_TEST_MNEMONIC`.** The first three are local-only and hermetic; the last hits public testnet DAPI. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index a8125b838de..88592fe963a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -520,11 +520,19 @@ func runIdentityDiscovery( let searchSheetNavBar = app.navigationBars["Re-scan for Identities"] var sheetOpened = false for _ in 0..<3 where !sheetOpened { - addMenu.tap() - let searchMenuItem = app.descendants(matching: .any) .matching(identifier: Identifier.Identities.searchWalletsMenuItem) .firstMatch + // If the menu is still open from a prior failed item-tap, + // close it first so the open below forces a fresh + // accessibility-tree snapshot. Tapping `addMenu` unconditionally + // would toggle an already-open menu shut, halving the effective + // retries. + if searchMenuItem.exists { + addMenu.tap() // close stale menu + } + addMenu.tap() // open fresh menu + if searchMenuItem.waitForExistence(timeout: 3) { searchMenuItem.tap() } else { @@ -749,7 +757,16 @@ func bestEffortDeleteWallet(named walletName: String, in app: XCUIApplication) { // 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?"] + // Match both the singular ("Recover Wallet?") and plural + // ("Recover Wallets?", N>1 orphans) titles so the teardown + // dismisses either variant. + let recoverAlert = app.alerts.matching( + NSPredicate( + format: "label == %@ OR label == %@", + "Recover Wallets?", + "Recover Wallet?" + ) + ).firstMatch if recoverAlert.waitForExistence(timeout: 1) { if recoverAlert.buttons["Cancel"].exists { recoverAlert.buttons["Cancel"].tap() From 0e38b246ac0fd42f102ec08498a16c20dd514f1c Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 12 May 2026 12:57:55 +0200 Subject: [PATCH 12/17] fix(swift-sdk): scrub stale Search Wallets selectors from runIdentityDiscovery Self-review of the prior PR #3560 follow-up commits surfaced three second-order copies of the "Search Wallets" vs "Re-scan for Identities" defect that the blocking fix only addressed at one site: - comment block above the retry loop still pointed at the old nav-bar signal name - the rationale block claimed addMenu is re-tapped unconditionally, but the prior suggestion patch made it conditional (close-then-open) - the label-based fallback inside the loop still queried `app.buttons["Search Wallets for Identities"]`, but IdentitiesContentView labels the menu item "Re-scan for Identities" Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Support/WalletFlow.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index 88592fe963a..59b01377a4a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -509,14 +509,13 @@ func runIdentityDiscovery( // 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). + // by the actual signal (Re-scan for Identities 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. + // Each retry forces a fresh accessibility-tree snapshot for the + // menu: if the previous iteration's item-tap missed and left the + // menu open, close it first before re-opening. A naive + // unconditional re-tap would toggle an already-open menu shut and + // halve the effective retries. let searchSheetNavBar = app.navigationBars["Re-scan for Identities"] var sheetOpened = false for _ in 0..<3 where !sheetOpened { @@ -537,7 +536,7 @@ func runIdentityDiscovery( searchMenuItem.tap() } else { // Fallback: match the menu item by visible label. - let labeled = app.buttons["Search Wallets for Identities"] + let labeled = app.buttons["Re-scan for Identities"] if labeled.waitForExistence(timeout: 3) { labeled.tap() } From 92cddcddcec98c1227da8f790eb37f81a97251ee Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 12 May 2026 14:05:45 +0200 Subject: [PATCH 13/17] savepoint --- .../Views/SearchWalletsForIdentitiesView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift index d5abe8a4eac..75d94e8a8ed 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift @@ -159,6 +159,11 @@ struct SearchWalletsForIdentitiesView: View { .font(.caption.monospaced()) .foregroundColor(.secondary) } + // Force a deterministic space-joined accessibility label so the + // XCUITest BEGINSWITH-with-trailing-space predicate matches; the + // default HStack a11y synthesis joins child Texts with ", ". + .accessibilityElement(children: .combine) + .accessibilityLabel("\(wallet.label) \(labelFingerprint(wallet.walletId))") } @ViewBuilder From 8cfad23e197fdc0a1f06c047ced80d8106376871 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 18 May 2026 15:25:35 +0200 Subject: [PATCH 14/17] fix(swift-sdk): tighten UI test helpers and network-status label - WalletFlow.cleanupWalletsByPrefix: replace the 0..<10 cap with a signal-driven loop plus no-progress detection. The previous count cap silently bailed when bestEffortDeleteWallet made no progress, leaving any wallets above index 10 in place. - WalletFlow.switchAppNetworkToTestnet: remove the "Switching..." transition wait. The status label now includes the network name, so we assert on "Connected to Testnet" directly instead of inferring network identity from an intermediate state. - WalletFlow.bestEffortDeleteWallet: dismiss the orphan-mnemonic recovery alert by its actual button label ("No", per ContentView.swift) instead of guessed labels ("Cancel" / "Don't Recover") with a positional fallback that could tap Authorize and enter the recovery flow. - OptionsView: render "Connected to " in the network status label, reusing the existing Network.displayName. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/Views/OptionsView.swift | 13 ++- .../Support/WalletFlow.swift | 97 +++++++------------ 2 files changed, 43 insertions(+), 67 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index c2448ba9e23..210b2b53877 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -211,17 +211,20 @@ struct OptionsView: View { .foregroundColor(.secondary) } } else if appState.sdk != nil { - Label("Connected", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundColor(.green) + Label( + "Connected to \(appState.currentNetwork.displayName)", + 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). + // Combine the icon + text into a single accessibility element + // so the network status reads as one string under the shared identifier. .accessibilityElement(children: .combine) .accessibilityIdentifier("options.networkStatusLabel") } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index 59b01377a4a..9838dc74887 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -404,18 +404,12 @@ func switchAppNetworkToTestnet( file: file, line: line ) - let tappedToSwitch = !testnetButton.isSelected - if tappedToSwitch { + if !testnetButton.isSelected { 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. + // Confirm the segmented control actually latched onto Testnet + // before reading the status label below. let selectedResult = XCTWaiter.wait( for: [XCTNSPredicateExpectation( predicate: NSPredicate(format: "isSelected == true"), @@ -439,35 +433,12 @@ func switchAppNetworkToTestnet( 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 - ) - } - + // The label includes the network name ("Connected to Testnet"), so + // matching on the network-qualified string proves we're on testnet + // rather than reading a stale "Connected" from the previous SDK. let connectedPredicate = NSPredicate { object, _ in guard let element = object as? XCUIElement, element.exists else { return false } - return element.label.contains("Connected") + return element.label.contains("Connected to Testnet") } let result = XCTWaiter.wait( for: [XCTNSPredicateExpectation(predicate: connectedPredicate, object: statusLabel)], @@ -476,7 +447,7 @@ func switchAppNetworkToTestnet( XCTAssertEqual( result, .completed, - "Network status did not reach 'Connected' within \(Int(timeout))s. Last label: \(statusLabel.label).", + "Network status did not reach 'Connected to Testnet' within \(Int(timeout))s. Last label: \(statusLabel.label).", file: file, line: line ) XCTAssertFalse( @@ -716,26 +687,24 @@ func readIdentityBalanceCredits( /// `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). +/// Best-effort: terminates when no matching row remains or when a delete +/// makes no progress on the row at the top of the list. @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) + let prefixPredicate = NSPredicate(format: "label BEGINSWITH %@", prefix) + for _ in 0..<50 { + let row = app.buttons.matching(prefixPredicate).firstMatch + if !row.waitForExistence(timeout: 1) { return } + let nameBefore = row.label + bestEffortDeleteWallet(named: nameBefore, in: app) + + let stillThere = app.buttons + .matching(NSPredicate(format: "label == %@", nameBefore)) + .firstMatch + if stillThere.exists { return } } } @@ -753,12 +722,21 @@ func bestEffortDeleteWallet(named walletName: String, in app: XCUIApplication) { // 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). + // a modal blocking the wallets tab. The prompt's "No" button + // (role: .cancel in ContentView.swift) declines the recovery offer + // — we then proceed with the deletion we came here to do. // Match both the singular ("Recover Wallet?") and plural // ("Recover Wallets?", N>1 orphans) titles so the teardown // dismisses either variant. + // + // No positional (`boundBy: 0`) fallback on purpose: index 0 is + // "Authorize" (declared first in the SwiftUI .alert closure), and + // tapping it would *enter* the recovery flow instead of dismissing + // it — opening the RecoverWalletsSheet, leaving the simulator in a + // half-recovered state, and undermining the cleanup these tests + // rely on. If the "No" label ever changes, the next assertion + // should fail loudly against the still-visible modal rather than + // be silently dismissed onto the wrong screen. let recoverAlert = app.alerts.matching( NSPredicate( format: "label == %@ OR label == %@", @@ -767,13 +745,8 @@ func bestEffortDeleteWallet(named walletName: String, in app: XCUIApplication) { ) ).firstMatch 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() + if recoverAlert.buttons["No"].exists { + recoverAlert.buttons["No"].tap() } } From be5f3fa4d50a7a3238c3061d19d2104d3b18c530 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 18 May 2026 15:35:34 +0200 Subject: [PATCH 15/17] fix(swift-sdk): preserve identity balance sign through accessibility value PersistentIdentity.balance is stored as Int64 (via UInt64(bitPattern:) round-trip from the SDK's UInt64). The identity-detail view previously reinterpreted that Int64 back to UInt64 for accessibilityValue, so a genuine negative balance would render as a huge positive number and the CreditTransferTest's `> 0` floor would silently pass. - IdentityDetailView: emit `identity.balance` directly (signed Int64). - readIdentityBalanceCredits: return Int64; capture the sign before the locale-separator strip so a negative balance survives parsing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/IdentityDetailView.swift | 8 ++++---- .../Support/WalletFlow.swift | 15 +++++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index 89522b7f448..9e3b6e607da 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -141,10 +141,10 @@ struct IdentityDetailView: View { .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))") + // sub-1000-credit deltas. Expose the raw signed + // credit count via accessibilityValue for tests + // that need exact numbers. + .accessibilityValue("\(identity.balance)") } // Top-up entry point. Hidden for purely-local rows diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index 9838dc74887..26b82693efe 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -646,7 +646,7 @@ func readIdentityBalanceCredits( timeout: TimeInterval = 30, file: StaticString = #filePath, line: UInt = #line -) -> UInt64 { +) -> Int64 { let label = app.descendants(matching: .any) .matching(identifier: Identifier.IdentityDetail.balanceLabel) .firstMatch @@ -664,19 +664,18 @@ func readIdentityBalanceCredits( ) 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. + // Strip locale-aware thousand separators; capture the sign first so + // a negative balance survives the digit filter. + let negative = raw.trimmingCharacters(in: .whitespaces).hasPrefix("-") let digits = raw.filter { $0.isASCII && $0.isNumber } - guard !digits.isEmpty, let credits = UInt64(digits) else { + guard !digits.isEmpty, let magnitude = Int64(digits) else { XCTFail( - "Could not parse \"\(raw)\" as UInt64 credits. Display label was \"\(displayLabel)\".", + "Could not parse \"\(raw)\" as Int64 credits. Display label was \"\(displayLabel)\".", file: file, line: line ) return 0 } - return credits + return negative ? -magnitude : magnitude } // MARK: - Pre-import cleanup From d3503c83a3ef9bfe1b5d854703477c27d585cc90 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 19 May 2026 09:54:14 +0200 Subject: [PATCH 16/17] fix(swift-sdk): scope discovery sheet's Done button to its nav bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app.buttons["Done"]` matches the first Done button anywhere in the accessibility tree. Today the discovery sheet is the only thing with a Done button while this code runs, so it works — but a keyboard accessory, system overlay, or future sheet stacked in front would silently tap the wrong button. Scope the lookup to the sheet's nav bar that's already captured in the same function. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleAppUITests/Support/WalletFlow.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift index 26b82693efe..6f92a27004b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/Support/WalletFlow.swift @@ -608,7 +608,7 @@ func runIdentityDiscovery( file: file, line: line ) - let doneButton = app.buttons["Done"] + let doneButton = searchSheetNavBar.buttons["Done"] if doneButton.waitForExistence(timeout: 5) { doneButton.tap() } From 2624335a7dbdb413224353d3ed5cd2cfa88c3ece Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 19 May 2026 12:23:41 +0200 Subject: [PATCH 17/17] fix(swift-sdk): build per-network SDK in activateManager to close stale-SDK race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SwiftUI `.onChange(of: platformState.currentNetwork)` handler in SwiftExampleAppApp fires synchronously the moment `currentNetwork` changes, while `AppState.switchNetwork` rebuilds `platformState.sdk` asynchronously. `activateManager` was reading `platformState.sdk` in that window — i.e. the *previous* network's SDK — and handing it to `walletManagerStore.activate`, which constructs and caches a `PlatformWalletManager` for the new network bound to that stale SDK. The manager is network-locked to its configure-time SDK and the cache is never invalidated, so the new network's manager was permanently routing through the previous network's backend. Build the per-network SDK locally inside `activateManager`, the same way `WalletManagerStore.backgroundManager(for:)` already does, so the cached manager is born correctly scoped and doesn't depend on the shared SDK pointer being up-to-date at handler-fire time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/SwiftExampleAppApp.swift | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 616d0966e56..8e4b088b993 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -147,19 +147,24 @@ struct SwiftExampleAppApp: App { } /// Lazy-create + cache a `PlatformWalletManager` for `network`, - /// configured against `platformState.sdk`. No-ops on the - /// already-active network. Called from bootstrap and from + /// configured against a freshly-built per-network SDK. No-ops on + /// the already-active network. Called from bootstrap and from /// `currentNetwork.onChange`. + /// + /// The SDK is built locally rather than read from `platformState.sdk` + /// because the SwiftUI `.onChange` handler that calls this fires + /// synchronously the moment `currentNetwork` changes, while + /// `AppState.switchNetwork` rebuilds `platformState.sdk` + /// asynchronously — at this instant the shared SDK still points at + /// the previous network. `PlatformWalletManager` is network-locked + /// to its configure-time SDK for its lifetime and the cache is + /// never invalidated, so capturing the stale reference would + /// permanently bind the new network's manager to the previous + /// network's backend. Mirrors `WalletManagerStore.backgroundManager(for:)`. @MainActor private func activateManager(for network: Network) { - guard let sdk = platformState.sdk else { - SDKLogger.error( - "Cannot activate wallet manager for \(network.displayName): " - + "no SDK available (still bootstrapping?)" - ) - return - } do { + let sdk = try SDK(network: network) try walletManagerStore.activate(network: network, sdk: sdk) } catch { SDKLogger.error(