From efe36656a450b17e15b0dd4e9bf2225e61d6519d Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 31 Mar 2026 16:46:55 +0100 Subject: [PATCH 1/4] chore: hide xcresult --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7b68a9a26fb..112d69df5e8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ Pods/ Podfile.lock *.xcworkspace/ samples/**/GoogleService-Info.plist +e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests.xcresult/ \ No newline at end of file From ae771b5b76e9a61211b87ffac270c46b605a7418 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Thu, 2 Apr 2026 11:24:54 +0100 Subject: [PATCH 2/4] ci: Stabilize SwiftUI auth UI tests --- .../FirebaseSwiftUIExampleUITests.swift | 13 +- .../MFAEnrolmentUITests.swift | 119 +++++++++--------- .../MFAResolutionUITests.swift | 97 +++++++------- .../TestUtils.swift | 108 ++++++++++++---- 4 files changed, 196 insertions(+), 141 deletions(-) diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift index 1d096c078fd..191566a953d 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift @@ -233,22 +233,15 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { let emailField = app.textFields["email-field"] XCTAssertTrue(emailField.waitForExistence(timeout: 2), "Email field should exist") - // Workaround for updating SecureFields with ConnectHardwareKeyboard enabled - UIPasteboard.general.string = email - emailField.press(forDuration: 1.2) - app.menuItems["Paste"].tap() + try pasteIntoField(emailField, text: email, app: app) let passwordField = app.secureTextFields["password-field"] XCTAssertTrue(passwordField.exists, "Password field should exist") - UIPasteboard.general.string = password - passwordField.press(forDuration: 1.2) - app.menuItems["Paste"].tap() + try pasteIntoField(passwordField, text: password, app: app) let confirmPasswordField = app.secureTextFields["confirm-password-field"] XCTAssertTrue(confirmPasswordField.exists, "Confirm password field should exist") - UIPasteboard.general.string = password - confirmPasswordField.press(forDuration: 1.2) - app.menuItems["Paste"].tap() + try pasteIntoField(confirmPasswordField, text: password, app: app) // Create the user (sign up) let signUpButton = app diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift index c5563be16ea..0e6ceff4009 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift @@ -56,23 +56,7 @@ final class MFAEnrollmentUITests: XCTestCase { // Sign in first to access MFA management try signInToApp(app: app, email: email) - // Check MFA management button exists - let mfaManagementButton = app.buttons["mfa-management-button"] - XCTAssertTrue( - mfaManagementButton.waitForExistence(timeout: 5), - "MFA management button should exist" - ) - XCTAssertTrue(mfaManagementButton.isEnabled, "MFA management button should be enabled") - - // Tap the button - mfaManagementButton.tap() - - // Verify we navigated to MFA management view - let managementTitle = app.staticTexts["Two-Factor Authentication"] - XCTAssertTrue( - managementTitle.waitForExistence(timeout: 5), - "Should navigate to MFA management view" - ) + try navigateToMFAManagement(app: app) } @MainActor @@ -87,16 +71,16 @@ final class MFAEnrollmentUITests: XCTestCase { // Sign in and navigate to MFA management try signInToApp(app: app, email: email) - app.buttons["mfa-management-button"].tap() + try navigateToMFAManagement(app: app) // Tap setup MFA button (for users with no enrolled factors) let setupButton = app.buttons["setup-mfa-button"] - if setupButton.waitForExistence(timeout: 3) { + if setupButton.waitForExistence(timeout: 10) { setupButton.tap() } else { // If factors are already enrolled, tap add another method let addMethodButton = app.buttons["add-mfa-method-button"] - XCTAssertTrue(addMethodButton.waitForExistence(timeout: 3), "Add method button should exist") + XCTAssertTrue(addMethodButton.waitForExistence(timeout: 10), "Add method button should exist") addMethodButton.tap() } @@ -448,27 +432,46 @@ final class MFAEnrollmentUITests: XCTestCase { signedInText.waitForExistence(timeout: 30), "SignedInView should be visible after login" ) + dismissAlert(app: app) XCTAssertTrue(signedInText.exists, "SignedInView should be visible after login") } + @MainActor + private func navigateToMFAManagement(app: XCUIApplication) throws { + dismissAlert(app: app) + + let mfaManagementButton = app.buttons["mfa-management-button"] + XCTAssertTrue( + mfaManagementButton.waitForExistence(timeout: 10), + "MFA management button should exist" + ) + XCTAssertTrue(mfaManagementButton.isEnabled, "MFA management button should be enabled") + mfaManagementButton.tap() + + let managementTitle = app.staticTexts["Two-Factor Authentication"] + XCTAssertTrue( + managementTitle.waitForExistence(timeout: 10), + "Should navigate to MFA management view" + ) + } + @MainActor private func navigateToMFAEnrollment(app: XCUIApplication) throws { - // Navigate to MFA management - app.buttons["mfa-management-button"].tap() + try navigateToMFAManagement(app: app) // Navigate to MFA enrollment let setupButton = app.buttons["setup-mfa-button"] - if setupButton.waitForExistence(timeout: 3) { + if setupButton.waitForExistence(timeout: 10) { setupButton.tap() } else { let addMethodButton = app.buttons["add-mfa-method-button"] - XCTAssertTrue(addMethodButton.waitForExistence(timeout: 3), "Add method button should exist") + XCTAssertTrue(addMethodButton.waitForExistence(timeout: 10), "Add method button should exist") addMethodButton.tap() } // Verify we're in MFA enrollment view let enrollmentTitle = app.staticTexts["Set Up Two-Factor Authentication"] - XCTAssertTrue(enrollmentTitle.waitForExistence(timeout: 5), "Should be in MFA enrollment view") + XCTAssertTrue(enrollmentTitle.waitForExistence(timeout: 10), "Should be in MFA enrollment view") } } @@ -486,49 +489,47 @@ struct VerificationCode: Codable { /// - Returns: The verification code as a String /// - Throws: Error if unable to retrieve codes private func getLastSmsCode(specificPhone: String? = nil) async throws -> String { - let getSmsCodesUrl = - "http://127.0.0.1:9099/emulator/v1/projects/flutterfire-e2e-tests/verificationCodes" + do { + for projectID in authEmulatorCandidateProjectIDs() { + let getSmsCodesUrl = + "http://127.0.0.1:9099/emulator/v1/projects/\(projectID)/verificationCodes" - guard let url = URL(string: getSmsCodesUrl) else { - throw NSError( - domain: "getLastSmsCode", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to create URL for SMS codes endpoint"] - ) - } + guard let url = URL(string: getSmsCodesUrl) else { + continue + } - do { - let (data, _) = try await URLSession.shared.data(from: url) + let (data, _) = try await URLSession.shared.data(from: url) - let decoder = JSONDecoder() - let codesResponse = try decoder.decode(VerificationCodesResponse.self, from: data) + let decoder = JSONDecoder() + let codesResponse = try decoder.decode(VerificationCodesResponse.self, from: data) - guard let codes = codesResponse.verificationCodes, !codes.isEmpty else { - throw NSError( - domain: "getLastSmsCode", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "No SMS verification codes found in emulator"] - ) - } + guard let codes = codesResponse.verificationCodes, !codes.isEmpty else { + continue + } - if let specificPhone = specificPhone { - // Search backwards through codes for the specific phone number - for code in codes.reversed() { - if code.phoneNumber == specificPhone { - return code.code + if let specificPhone = specificPhone { + // Search backwards through codes for the specific phone number + for code in codes.reversed() { + if code.phoneNumber == specificPhone { + return code.code + } } + } else if let lastCode = codes.last { + return lastCode.code } - throw NSError( - domain: "getLastSmsCode", - code: -1, - userInfo: [ - NSLocalizedDescriptionKey: "No SMS verification code found for phone number: \(specificPhone)", - ] - ) + } + + let description = if let specificPhone { + "No SMS verification code found for phone number: \(specificPhone)" } else { - // Return the last code in the array - return codes.last!.code + "No SMS verification codes found in emulator" } + + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [NSLocalizedDescriptionKey: description] + ) } catch let error as DecodingError { throw NSError( domain: "getLastSmsCode", diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift index 12ce4235e4e..9024a19f83d 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift @@ -266,63 +266,64 @@ final class MFAResolutionUITests: XCTestCase { @MainActor private func getSMSVerificationCode(for phoneNumber: String, codeType: String = "enrollment") async -> String? { - let emulatorUrl = - "http://127.0.0.1:9099/emulator/v1/projects/flutterfire-e2e-tests/verificationCodes" - - guard let url = URL(string: emulatorUrl) else { - return nil - } - do { - let (data, _) = try await URLSession.shared.data(from: url) + for projectID in authEmulatorCandidateProjectIDs() { + let emulatorUrl = + "http://127.0.0.1:9099/emulator/v1/projects/\(projectID)/verificationCodes" - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let codes = json["verificationCodes"] as? [[String: Any]] else { - print("❌ Failed to parse verification codes") - return nil - } - - // Filter codes by phone number and type, then get the most recent one - let matchingCodes = codes.filter { codeInfo in - guard let phone = codeInfo["phoneNumber"] as? String else { - print("❌ Code missing phoneNumber field") - return false + guard let url = URL(string: emulatorUrl) else { + continue } - // The key difference between enrollment and verification codes: - // - Enrollment codes have full phone numbers (e.g., "+15551234567") - // - Verification codes have masked phone numbers (e.g., "+*******4567") - let isMasked = phone.contains("*") - - // Match phone number - let phoneMatches: Bool - if isMasked { - // Extract last 4 digits from both numbers - let last4OfResponse = String(phone.suffix(4)) - let last4OfTarget = String(phoneNumber.suffix(4)) - phoneMatches = last4OfResponse == last4OfTarget - } else { - // Full phone number match - phoneMatches = phone == phoneNumber - } + let (data, _) = try await URLSession.shared.data(from: url) - guard phoneMatches else { - return false + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let codes = json["verificationCodes"] as? [[String: Any]] else { + continue } - if codeType == "enrollment" { - // Enrollment codes have unmasked phone numbers - return !isMasked - } else { // "verification" - // Verification codes have masked phone numbers - return isMasked + // Filter codes by phone number and type, then get the most recent one + let matchingCodes = codes.filter { codeInfo in + guard let phone = codeInfo["phoneNumber"] as? String else { + print("❌ Code missing phoneNumber field") + return false + } + + // The key difference between enrollment and verification codes: + // - Enrollment codes have full phone numbers (e.g., "+15551234567") + // - Verification codes have masked phone numbers (e.g., "+*******4567") + let isMasked = phone.contains("*") + + // Match phone number + let phoneMatches: Bool + if isMasked { + // Extract last 4 digits from both numbers + let last4OfResponse = String(phone.suffix(4)) + let last4OfTarget = String(phoneNumber.suffix(4)) + phoneMatches = last4OfResponse == last4OfTarget + } else { + // Full phone number match + phoneMatches = phone == phoneNumber + } + + guard phoneMatches else { + return false + } + + if codeType == "enrollment" { + // Enrollment codes have unmasked phone numbers + return !isMasked + } else { // "verification" + // Verification codes have masked phone numbers + return isMasked + } } - } - // Get the last matching code (most recent) - if let lastCode = matchingCodes.last, - let code = lastCode["code"] as? String { - return code + // Get the last matching code (most recent) + if let lastCode = matchingCodes.last, + let code = lastCode["code"] as? String { + return code + } } print("❌ No matching code found") diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift index b1e6e5df533..b3e736af02b 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift @@ -24,8 +24,9 @@ func createEmail() -> String { // MARK: - Alert Handling @MainActor func dismissAlert(app: XCUIApplication) { - if app.scrollViews.otherElements.buttons["Not Now"].waitForExistence(timeout: 2) { - app.scrollViews.otherElements.buttons["Not Now"].tap() + let notNowButton = app.buttons["Not Now"].firstMatch + if notNowButton.waitForExistence(timeout: 5) { + notNowButton.tap() } } @@ -115,6 +116,49 @@ func createEmail() -> String { // MARK: - Email Verification +private let authEmulatorProjectIDs = [ + "flutterfire-e2e-tests", + "extensions-testing", +] + +private func projectIDFromIDToken(_ idToken: String) -> String? { + let segments = idToken.split(separator: ".") + guard segments.count >= 2 else { return nil } + + var payload = String(segments[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let paddingLength = (4 - payload.count % 4) % 4 + payload += String(repeating: "=", count: paddingLength) + + guard let payloadData = Data(base64Encoded: payload), + let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any] + else { + return nil + } + + return json["aud"] as? String +} + +func authEmulatorCandidateProjectIDs(preferredProjectID: String? = nil, + idToken: String? = nil) -> [String] { + var projectIDs: [String] = [] + + if let preferredProjectID, !preferredProjectID.isEmpty { + projectIDs.append(preferredProjectID) + } + + if let idToken, let tokenProjectID = projectIDFromIDToken(idToken) { + projectIDs.append(tokenProjectID) + } + + projectIDs.append(contentsOf: authEmulatorProjectIDs) + + var seen = Set() + return projectIDs.filter { seen.insert($0).inserted } +} + /// Verifies an email address in the emulator using the OOB code mechanism @MainActor func verifyEmailInEmulator(email: String, idToken: String, @@ -157,33 +201,52 @@ func createEmail() -> String { } // Step 2: Fetch OOB codes from emulator with retry logic - let oobURL = URL(string: "\(base)/emulator/v1/projects/\(projectID)/oobCodes")! + let candidateProjectIDs = authEmulatorCandidateProjectIDs( + preferredProjectID: projectID, + idToken: idToken + ) var codeItem: OobItem? var attempts = 0 let maxAttempts = 5 + var availableCodesByProject = "" while codeItem == nil, attempts < maxAttempts { - let (oobData, oobResp) = try await URLSession.shared.data(from: oobURL) - guard (oobResp as? HTTPURLResponse)?.statusCode == 200 else { - throw NSError(domain: "EmulatorError", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Failed to fetch OOB codes"]) - } - - let envelope = try JSONDecoder().decode(OobEnvelope.self, from: oobData) + var availableCodes: [String] = [] + + for candidateProjectID in candidateProjectIDs { + let oobURL = URL(string: "\(base)/emulator/v1/projects/\(candidateProjectID)/oobCodes")! + let (oobData, oobResp) = try await URLSession.shared.data(from: oobURL) + guard (oobResp as? HTTPURLResponse)?.statusCode == 200 else { + throw NSError(domain: "EmulatorError", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to fetch OOB codes"]) + } - // Step 3: Find most recent VERIFY_EMAIL code for this email - let iso = ISO8601DateFormatter() - codeItem = envelope.oobCodes - .filter { - $0.email.caseInsensitiveCompare(email) == .orderedSame && $0.requestType == "VERIFY_EMAIL" + let envelope = try JSONDecoder().decode(OobEnvelope.self, from: oobData) + + let iso = ISO8601DateFormatter() + codeItem = envelope.oobCodes + .filter { + $0.email.caseInsensitiveCompare(email) == .orderedSame && $0.requestType == "VERIFY_EMAIL" + } + .sorted { + let d0 = $0.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast + let d1 = $1.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast + return d0 > d1 + } + .first + + if codeItem != nil { + break } - .sorted { - let d0 = $0.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast - let d1 = $1.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast - return d0 > d1 + + let descriptions = envelope.oobCodes.map { + "[\(candidateProjectID)] Email: \($0.email), Type: \($0.requestType)" } - .first + availableCodes.append(contentsOf: descriptions) + } + + availableCodesByProject = availableCodes.joined(separator: "; ") if codeItem == nil { attempts += 1 @@ -191,12 +254,9 @@ func createEmail() -> String { // Wait before retrying try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds } else { - // Log available codes for debugging - let availableCodes = envelope.oobCodes.map { "Email: \($0.email), Type: \($0.requestType)" } - .joined(separator: "; ") throw NSError(domain: "EmulatorError", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "No VERIFY_EMAIL OOB code found for \(email) after \(maxAttempts) attempts. Available codes: \(availableCodes)", + NSLocalizedDescriptionKey: "No VERIFY_EMAIL OOB code found for \(email) after \(maxAttempts) attempts. Available codes: \(availableCodesByProject)", ]) } } From 7f02c75112b234e623e52f93d9ac9de7850e48ec Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Thu, 2 Apr 2026 11:48:42 +0100 Subject: [PATCH 3/4] fix(pr): make emulator project fallback lookups resilient --- .../MFAEnrolmentUITests.swift | 8 ++++++-- .../FirebaseSwiftUIExampleUITests/TestUtils.swift | 11 ++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift index 0e6ceff4009..c2ff98f4a24 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift @@ -498,10 +498,14 @@ private func getLastSmsCode(specificPhone: String? = nil) async throws -> String continue } - let (data, _) = try await URLSession.shared.data(from: url) + guard let (data, _) = try? await URLSession.shared.data(from: url) else { + continue + } let decoder = JSONDecoder() - let codesResponse = try decoder.decode(VerificationCodesResponse.self, from: data) + guard let codesResponse = try? decoder.decode(VerificationCodesResponse.self, from: data) else { + continue + } guard let codes = codesResponse.verificationCodes, !codes.isEmpty else { continue diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift index b3e736af02b..662c6c05d5e 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift @@ -216,13 +216,14 @@ func authEmulatorCandidateProjectIDs(preferredProjectID: String? = nil, for candidateProjectID in candidateProjectIDs { let oobURL = URL(string: "\(base)/emulator/v1/projects/\(candidateProjectID)/oobCodes")! - let (oobData, oobResp) = try await URLSession.shared.data(from: oobURL) - guard (oobResp as? HTTPURLResponse)?.statusCode == 200 else { - throw NSError(domain: "EmulatorError", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Failed to fetch OOB codes"]) + guard let (oobData, oobResp) = try? await URLSession.shared.data(from: oobURL), + (oobResp as? HTTPURLResponse)?.statusCode == 200 else { + continue } - let envelope = try JSONDecoder().decode(OobEnvelope.self, from: oobData) + guard let envelope = try? JSONDecoder().decode(OobEnvelope.self, from: oobData) else { + continue + } let iso = ISO8601DateFormatter() codeItem = envelope.oobCodes From 6bdb82f025250e36cb47aa7977296239070ba303 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Thu, 2 Apr 2026 15:09:07 +0100 Subject: [PATCH 4/4] chore: remove stale firebase project id --- .../FirebaseSwiftUIExampleUITests/TestUtils.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift index 662c6c05d5e..e59b96ff427 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift @@ -118,7 +118,6 @@ func createEmail() -> String { private let authEmulatorProjectIDs = [ "flutterfire-e2e-tests", - "extensions-testing", ] private func projectIDFromIDToken(_ idToken: String) -> String? {