From 03af8e2f96bc0227588ca3870212fe913abc7dce Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 28 Apr 2026 08:04:00 +0200 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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