diff --git a/.github/workflows/swiftui-auth.yml b/.github/workflows/swiftui-auth.yml new file mode 100644 index 0000000000..c5aa722b7c --- /dev/null +++ b/.github/workflows/swiftui-auth.yml @@ -0,0 +1,78 @@ +name: SwiftUI Auth + +on: + push: + branches: [ main ] + paths: + - '.github/workflows/swiftui-auth.yml' + - 'samples/swiftui/**' + - 'FirebaseSwiftUI/**' + - 'Package.swift' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/swiftui-auth.yml' + - 'samples/swiftui/**' + - 'FirebaseSwiftUI/**' + - 'Package.swift' + + workflow_dispatch: + +permissions: + contents: read + +jobs: + swiftui-auth: + runs-on: macOS-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a + name: Install Node.js 20 + with: + node-version: '20' + - uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b + with: + distribution: 'temurin' + java-version: '17' + - name: Install Firebase + run: | + sudo npm i -g firebase-tools + - name: Start Firebase Emulator + run: | + sudo chown -R 501:20 "/Users/runner/.npm" && cd ./samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample && ./start-firebase-emulator.sh + - name: Install xcpretty + run: gem install xcpretty + - name: Select Xcode version + run: | + sudo xcode-select -switch /Applications/Xcode_16.1.app/Contents/Developer + - name: Run Integration Tests + run: | + cd ./samples/swiftui/FirebaseSwiftUIExample + set -o pipefail + xcodebuild test -scheme FirebaseSwiftUIExampleTests -destination 'platform=iOS Simulator,name=iPhone 16 Plus' -enableCodeCoverage YES -resultBundlePath FirebaseSwiftUIExampleTests.xcresult | tee FirebaseSwiftUIExampleTests.log | xcpretty --test --color --simple + - name: Run View UI Tests + run: | + cd ./samples/swiftui/FirebaseSwiftUIExample + set -o pipefail + xcodebuild test -scheme FirebaseSwiftUIExampleUITests -destination 'platform=iOS Simulator,name=iPhone 16 Plus' -enableCodeCoverage YES -resultBundlePath FirebaseSwiftUIExampleUITests.xcresult | tee FirebaseSwiftUIExampleUITests.log | xcpretty --test --color --simple + - name: Upload test logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: swiftui-auth-test-logs + path: | + samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests.log + samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests.log + - name: Upload FirebaseSwiftUIExampleUITests.xcresult bundle + if: failure() + uses: actions/upload-artifact@v4 + with: + name: FirebaseSwiftUIExampleUITests.xcresult + path: samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests.xcresult + - name: Upload FirebaseSwiftUIExampleTests.xcresult bundle + if: failure() + uses: actions/upload-artifact@v4 + with: + name: FirebaseSwiftUIExampleTests.xcresult + path: samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests.xcresult \ No newline at end of file diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift index 3c857d43ed..c1d4b24e01 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift @@ -16,14 +16,14 @@ import FirebaseAuth import Foundation public struct AuthConfiguration { - let shouldHideCancelButton: Bool - let interactiveDismissEnabled: Bool - let shouldAutoUpgradeAnonymousUsers: Bool - let customStringsBundle: Bundle? - let tosUrl: URL? - let privacyPolicyUrl: URL? - let emailLinkSignInActionCodeSettings: ActionCodeSettings? - let verifyEmailActionCodeSettings: ActionCodeSettings? + public let shouldHideCancelButton: Bool + public let interactiveDismissEnabled: Bool + public let shouldAutoUpgradeAnonymousUsers: Bool + public let customStringsBundle: Bundle? + public let tosUrl: URL? + public let privacyPolicyUrl: URL? + public let emailLinkSignInActionCodeSettings: ActionCodeSettings? + public let verifyEmailActionCodeSettings: ActionCodeSettings? public init(shouldHideCancelButton: Bool = false, interactiveDismissEnabled: Bool = true, diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 54a93c0c69..0a656f2490 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -142,7 +142,7 @@ public final class AuthService { private var unsafePhoneAuthProvider: (any PhoneAuthProviderAuthUIProtocol)? private var listenerManager: AuthListenerManager? - private var signedInCredential: AuthCredential? + public var signedInCredential: AuthCredential? var emailSignInEnabled = false diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 9d589726cd..e48b8ddc32 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -82,7 +82,7 @@ extension AuthPickerView: View { .emailLoginFlowLabel : authService.string.emailSignUpFlowLabel) .fontWeight(.semibold) .foregroundColor(.blue) - } + }.accessibilityIdentifier("switch-auth-flow") } } PrivacyTOCsView(displayMode: .footer) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 812185f59a..739bbebe11 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -79,10 +79,13 @@ extension EmailAuthView: View { .padding(.vertical, 6) .background(Divider(), alignment: .bottom) .padding(.bottom, 4) + .accessibilityIdentifier("email-field") LabeledContent { SecureField(authService.string.passwordInputLabel, text: $password) .focused($focus, equals: .password) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) .submitLabel(.go) .onSubmit { Task { await signInWithEmailPassword() } @@ -93,19 +96,22 @@ extension EmailAuthView: View { .padding(.vertical, 6) .background(Divider(), alignment: .bottom) .padding(.bottom, 8) + .accessibilityIdentifier("password-field") if authService.authenticationFlow == .login { Button(action: { authService.authView = .passwordRecovery }) { Text(authService.string.passwordButtonLabel) - } + }.accessibilityIdentifier("password-recovery-button") } if authService.authenticationFlow == .signUp { LabeledContent { SecureField(authService.string.confirmPasswordInputLabel, text: $confirmPassword) .focused($focus, equals: .confirmPassword) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) .submitLabel(.go) .onSubmit { Task { await createUserWithEmailPassword() } @@ -116,6 +122,7 @@ extension EmailAuthView: View { .padding(.vertical, 6) .background(Divider(), alignment: .bottom) .padding(.bottom, 8) + .accessibilityIdentifier("confirm-password-field") } Button(action: { @@ -140,11 +147,12 @@ extension EmailAuthView: View { .padding([.top, .bottom, .horizontal], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) + .accessibilityIdentifier("sign-in-button") Button(action: { authService.authView = .emailLink }) { Text(authService.string.signUpWithEmailLinkButtonLabel) - } + }.accessibilityIdentifier("sign-in-with-email-link-button") } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 07241b3f2b..5e242f8d62 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -35,6 +35,7 @@ extension EmailLinkView: View { public var body: some View { VStack { Text(authService.string.signInWithEmailLinkViewTitle) + .accessibilityIdentifier("email-link-title-text") LabeledContent { TextField(authService.string.emailInputLabel, text: $email) .textInputAutocapitalization(.never) @@ -84,7 +85,7 @@ extension EmailLinkView: View { .foregroundColor(.blue) Text(authService.string.backButtonLabel) .foregroundColor(.blue) - }) + }.accessibilityIdentifier("email-link-back-button")) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index cc1207a77c..d58f2be1f7 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -46,6 +46,7 @@ extension PasswordRecoveryView: View { .font(.largeTitle) .fontWeight(.bold) .padding() + .accessibilityIdentifier("password-recovery-text") Divider() @@ -85,7 +86,7 @@ extension PasswordRecoveryView: View { .foregroundColor(.blue) Text(authService.string.backButtonLabel) .foregroundColor(.blue) - }) + }.accessibilityIdentifier("password-recovery-back-button")) } @ViewBuilder diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index b903a53219..a8d3acca9f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -37,6 +37,7 @@ extension SignedInView: View { .font(.largeTitle) .fontWeight(.bold) .padding() + .accessibilityIdentifier("signed-in-text") Text(authService.string.accountSettingsEmailLabel) Text("\(authService.currentUser?.email ?? "Unknown")") @@ -54,7 +55,7 @@ extension SignedInView: View { try await authService.signOut() } catch {} } - } + }.accessibilityIdentifier("sign-out-button") Divider() Button(authService.string.deleteAccountButtonLabel) { Task { diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj index 2fb44425f3..812f7b770c 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 4600E5522DD777BE00EED5F3 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 4600E5512DD777BE00EED5F3 /* FirebaseAuth */; }; + 4600E5542DD777BE00EED5F3 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4600E5532DD777BE00EED5F3 /* FirebaseCore */; }; 4607CC9C2D9BFE29009EC3F5 /* FirebaseAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4607CC9B2D9BFE29009EC3F5 /* FirebaseAuthSwiftUI */; }; 4607CC9E2D9BFE29009EC3F5 /* FirebaseGoogleSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4607CC9D2D9BFE29009EC3F5 /* FirebaseGoogleSwiftUI */; }; 46CB7B252D773F2100F1FD0A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 46CB7B242D773F2100F1FD0A /* GoogleService-Info.plist */; }; @@ -96,6 +98,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4600E5542DD777BE00EED5F3 /* FirebaseCore in Frameworks */, + 4600E5522DD777BE00EED5F3 /* FirebaseAuth in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -203,6 +207,8 @@ ); name = FirebaseSwiftUIExampleUITests; packageProductDependencies = ( + 4600E5512DD777BE00EED5F3 /* FirebaseAuth */, + 4600E5532DD777BE00EED5F3 /* FirebaseCore */, ); productName = FirebaseSwiftUIExampleUITests; productReference = 46F89C242D64A86D000F8BC0 /* FirebaseSwiftUIExampleUITests.xctest */; @@ -242,6 +248,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 8D808CB52DB07EBD00D2293F /* XCLocalSwiftPackageReference "../../../../FirebaseUI-iOS" */, + 4600E5502DD777BE00EED5F3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = 46F89C092D64A86C000F8BC0 /* Products */; @@ -511,7 +518,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = YYX2P3XVJ7; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.invertase.testing.FirebaseSwiftUIExampleTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -530,7 +537,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = YYX2P3XVJ7; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.invertase.testing.FirebaseSwiftUIExampleTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -623,7 +630,28 @@ }; /* End XCLocalSwiftPackageReference section */ +/* Begin XCRemoteSwiftPackageReference section */ + 4600E5502DD777BE00EED5F3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 11.12.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ + 4600E5512DD777BE00EED5F3 /* FirebaseAuth */ = { + isa = XCSwiftPackageProductDependency; + package = 4600E5502DD777BE00EED5F3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAuth; + }; + 4600E5532DD777BE00EED5F3 /* FirebaseCore */ = { + isa = XCSwiftPackageProductDependency; + package = 4600E5502DD777BE00EED5F3 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCore; + }; 4607CC9B2D9BFE29009EC3F5 /* FirebaseAuthSwiftUI */ = { isa = XCSwiftPackageProductDependency; productName = FirebaseAuthSwiftUI; diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExampleTests.xcscheme b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExampleTests.xcscheme new file mode 100644 index 0000000000..5a80b5361c --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExampleTests.xcscheme @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExampleUITests.xcscheme b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExampleUITests.xcscheme new file mode 100644 index 0000000000..3bd50f3ad0 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExampleUITests.xcscheme @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/.firebaserc b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/.firebaserc new file mode 100644 index 0000000000..6aa25141ca --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "flutterfire-e2e-tests" + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/.gitignore b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/.gitignore new file mode 100644 index 0000000000..b17f631075 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/.gitignore @@ -0,0 +1,69 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# dataconnect generated files +.dataconnect diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift index 0a4a67644c..e51ef876cd 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift @@ -39,10 +39,12 @@ struct ContentView: View { actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com" actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) let configuration = AuthConfiguration( + shouldAutoUpgradeAnonymousUsers: !uiAuthEmulator, tosUrl: URL(string: "https://example.com/tos"), privacyPolicyUrl: URL(string: "https://example.com/privacy"), emailLinkSignInActionCodeSettings: actionCodeSettings ) + authService = AuthService( configuration: configuration ) diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift index 93d24cb5ae..630fdb6603 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift @@ -30,6 +30,10 @@ class AppDelegate: NSObject, UIApplicationDelegate { UIApplication.LaunchOptionsKey: Any ]?) -> Bool { FirebaseApp.configure() + if uiAuthEmulator { + Auth.auth().useEmulator(withHost: "localhost", port: 9099) + } + ApplicationDelegate.shared.application( application, didFinishLaunchingWithOptions: launchOptions @@ -72,6 +76,12 @@ class AppDelegate: NSObject, UIApplicationDelegate { struct FirebaseSwiftUIExampleApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + init() { + Task { + try await testCreateUser() + } + } + var body: some Scene { WindowGroup { NavigationView { diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift new file mode 100644 index 0000000000..2116c2f26a --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift @@ -0,0 +1,27 @@ +// +// UITestUtils.swift +// FirebaseSwiftUIExample +// +// Created by Russell Wheatley on 16/05/2025. +// +import FirebaseAuth +import SwiftUI + +// UI Test Runner keys +public let uiAuthEmulator = CommandLine.arguments.contains("--auth-emulator") + +public var testEmail: String? { + guard let emailIndex = CommandLine.arguments.firstIndex(of: "--create-user"), + CommandLine.arguments.indices.contains(emailIndex + 1) + else { return nil } + return CommandLine.arguments[emailIndex + 1] +} + +func testCreateUser() async throws { + if let email = testEmail { + let password = "123456" + let auth = Auth.auth() + try await auth.createUser(withEmail: email, password: password) + try auth.signOut() + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/firebase.json b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/firebase.json new file mode 100644 index 0000000000..2fb2a16b0d --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/firebase.json @@ -0,0 +1,11 @@ +{ + "emulators": { + "auth": { + "port": 9099 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/start-firebase-emulator.sh b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/start-firebase-emulator.sh new file mode 100755 index 0000000000..fb1280e438 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/start-firebase-emulator.sh @@ -0,0 +1,57 @@ +#!/bin/bash +if ! [ -x "$(command -v firebase)" ]; then + echo "❌ Firebase tools CLI is missing." + exit 1 +fi + +if ! [ -x "$(command -v node)" ]; then + echo "❌ Node.js is missing." + exit 1 +fi + +if ! [ -x "$(command -v npm)" ]; then + echo "❌ NPM is missing." + exit 1 +fi + +EMU_START_COMMAND="firebase emulators:start --only auth --project flutterfire-e2e-tests" + +MAX_RETRIES=3 +MAX_CHECKATTEMPTS=60 +CHECKATTEMPTS_WAIT=1 + +RETRIES=1 +while [ $RETRIES -le $MAX_RETRIES ]; do + + if [[ -z "${CI}" ]]; then + echo "Starting Firebase Emulator in foreground." + $EMU_START_COMMAND + exit 0 + else + echo "Starting Firebase Emulator in background." + $EMU_START_COMMAND & + CHECKATTEMPTS=1 + while [ $CHECKATTEMPTS -le $MAX_CHECKATTEMPTS ]; do + sleep $CHECKATTEMPTS_WAIT + if curl --output /dev/null --silent --fail http://localhost:9099; then + # Check again since it can exit before the emulator is ready. + sleep 15 + if curl --output /dev/null --silent --fail http://localhost:9099; then + echo "Firebase Emulator is online!" + exit 0 + else + echo "❌ Firebase Emulator exited after startup." + exit 1 + fi + fi + echo "Waiting for Firebase Emulator to come online, check $CHECKATTEMPTS of $MAX_CHECKATTEMPTS..." + ((CHECKATTEMPTS = CHECKATTEMPTS + 1)) + done + fi + + echo "Firebase Emulator did not come online in $MAX_CHECKATTEMPTS checks. Try $RETRIES of $MAX_RETRIES." + ((RETRIES = RETRIES + 1)) + +done +echo "Firebase Emulator did not come online after $MAX_RETRIES attempts." +exit 1 \ No newline at end of file diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift index cc3ad84890..e43a002927 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift @@ -18,12 +18,124 @@ // // Created by Russell Wheatley on 18/02/2025. // - +import FirebaseAuth +import FirebaseAuthSwiftUI +import FirebaseCore @testable import FirebaseSwiftUIExample import Testing +let kPassword = "123456" + struct FirebaseSwiftUIExampleTests { - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. + @MainActor + func prepareFreshAuthService(configuration: AuthConfiguration? = nil) async throws + -> AuthService { + configureFirebaseIfNeeded() + try await clearAuthEmulatorState() + + let resolvedConfiguration = configuration ?? AuthConfiguration() + + return AuthService(configuration: resolvedConfiguration) + } + + @Test + @MainActor + func testDefaultAuthConfigurationInjection() async throws { + let config = AuthConfiguration() + let service = AuthService(configuration: config) + + let actual = service.configuration + + #expect(actual.shouldHideCancelButton == false) + #expect(actual.interactiveDismissEnabled == true) + #expect(actual.shouldAutoUpgradeAnonymousUsers == false) + #expect(actual.customStringsBundle == nil) + #expect(actual.tosUrl == nil) + #expect(actual.privacyPolicyUrl == nil) + #expect(actual.emailLinkSignInActionCodeSettings == nil) + #expect(actual.verifyEmailActionCodeSettings == nil) + } + + @Test + @MainActor + func testCustomAuthConfigurationInjection() async throws { + let emailSettings = ActionCodeSettings() + emailSettings.handleCodeInApp = true + emailSettings.url = URL(string: "https://example.com/email-link") + emailSettings.setIOSBundleID("com.example.test") + + let verifySettings = ActionCodeSettings() + verifySettings.handleCodeInApp = true + verifySettings.url = URL(string: "https://example.com/verify-email") + verifySettings.setIOSBundleID("com.example.test") + + let config = AuthConfiguration( + shouldHideCancelButton: true, + interactiveDismissEnabled: false, + shouldAutoUpgradeAnonymousUsers: true, + customStringsBundle: .main, + tosUrl: URL(string: "https://example.com/tos"), + privacyPolicyUrl: URL(string: "https://example.com/privacy"), + emailLinkSignInActionCodeSettings: emailSettings, + verifyEmailActionCodeSettings: verifySettings + ) + + let service = AuthService(configuration: config) + + let actual = service.configuration + #expect(actual.shouldHideCancelButton == true) + #expect(actual.interactiveDismissEnabled == false) + #expect(actual.shouldAutoUpgradeAnonymousUsers == true) + #expect(actual.customStringsBundle === Bundle.main) + #expect(actual.tosUrl == URL(string: "https://example.com/tos")) + #expect(actual.privacyPolicyUrl == URL(string: "https://example.com/privacy")) + + // Optional action code settings checks + #expect(actual.emailLinkSignInActionCodeSettings?.url == emailSettings.url) + #expect(actual.verifyEmailActionCodeSettings?.url == verifySettings.url) + } + + @Test + @MainActor + func testCreateEmailPasswordUser() async throws { + let service = try await prepareFreshAuthService() + + #expect(service.authenticationState == .unauthenticated) + #expect(service.authView == .authPicker) + #expect(service.errorMessage.isEmpty) + #expect(service.signedInCredential == nil) + #expect(service.currentUser == nil) + try await service.createUser(withEmail: createEmail(), password: kPassword) + try await Task.sleep(nanoseconds: 4_000_000_000) + #expect(service.authenticationState == .authenticated) + #expect(service.authView == .authPicker) + #expect(service.errorMessage.isEmpty) + #expect(service.currentUser != nil) + // TODO: - reinstate once this PR is merged: https://github.com/firebase/FirebaseUI-iOS/pull/1256 +// #expect(service.signedInCredential is AuthCredential) + } + + @Test + @MainActor + func testSignInUser() async throws { + let service = try await prepareFreshAuthService() + let email = createEmail() + try await service.createUser(withEmail: email, password: kPassword) + try await service.signOut() + try await Task.sleep(nanoseconds: 2_000_000_000) + #expect(service.authenticationState == .unauthenticated) + #expect(service.authView == .authPicker) + #expect(service.errorMessage.isEmpty) + #expect(service.signedInCredential == nil) + #expect(service.currentUser == nil) + + try await service.signIn(withEmail: email, password: kPassword) + + #expect(service.authenticationState == .authenticated) + #expect(service.authView == .authPicker) + #expect(service.errorMessage.isEmpty) + #expect(service.currentUser != nil) + // TODO: - reinstate once this PR is merged: https://github.com/firebase/FirebaseUI-iOS/pull/1256 + // #expect(service.signedInCredential is AuthCredential) } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift new file mode 100644 index 0000000000..1b474daf80 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift @@ -0,0 +1,63 @@ +// +// TestHarness.swift +// FirebaseSwiftUIExample +// +// Created by Russell Wheatley on 16/05/2025. +// + +import FirebaseAuth +import FirebaseCore + +@MainActor +func configureFirebaseIfNeeded() { + if FirebaseApp.app() == nil { + FirebaseApp.configure() + } +} + +private var hasCheckedEmulatorAvailability = false + +@MainActor +func isEmulatorRunning() async throws { + if hasCheckedEmulatorAvailability { return } + let healthCheckURL = URL(string: "http://localhost:9099/")! + var healthRequest = URLRequest(url: healthCheckURL) + healthRequest.httpMethod = "HEAD" + + let session = URLSession(configuration: .ephemeral) + let (_, response) = try await session.data(for: healthRequest) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw NSError( + domain: "FirebaseAuthSwiftUITests", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: """ + πŸ”Œ Firebase Auth Emulator is not running on localhost:9099. + Please run: `firebase emulators:start --only auth` + """, + ] + ) + } + Auth.auth().useEmulator(withHost: "localhost", port: 9099) + hasCheckedEmulatorAvailability = true +} + +@MainActor +func clearAuthEmulatorState(projectID: String = "flutterfire-e2e-tests") async throws { + try await isEmulatorRunning() + let url = URL(string: "http://localhost:9099/emulator/v1/projects/\(projectID)/accounts")! + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + let (_, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + print("πŸ”₯ clearAuthEmulatorState: status = \(httpResponse.statusCode)") + } +} + +func createEmail() -> String { + let before = UUID().uuidString.prefix(8) + let after = UUID().uuidString.prefix(6) + return "\(before)@\(after).com" +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift index 4370378fe7..1252d35519 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift @@ -19,41 +19,167 @@ // Created by Russell Wheatley on 18/02/2025. // +import FirebaseAuth +import FirebaseCore import XCTest +func createEmail() -> String { + let before = UUID().uuidString.prefix(8) + let after = UUID().uuidString.prefix(6) + return "\(before)@\(after).com" +} + +func dismissAlert(app: XCUIApplication) { + if app.scrollViews.otherElements.buttons["Not Now"].waitForExistence(timeout: 2) { + app.scrollViews.otherElements.buttons["Not Now"].tap() + } +} + final class FirebaseSwiftUIExampleUITests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the - // class. - - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - - // required for your tests before they run. The setUp method is a good place to do this. } - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the - // class. - } + override func tearDownWithError() throws {} @MainActor func testExample() throws { - // UI tests must launch the application that they test. let app = XCUIApplication() app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. } @MainActor func testLaunchPerformance() throws { if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() } } } + + @MainActor + func testSignInDisplaysSignedInView() async throws { + let app = XCUIApplication() + let email = createEmail() + app.launchArguments.append("--auth-emulator") + app.launchArguments.append("--create-user") + app.launchArguments.append("\(email)") + app.launch() + + let emailField = app.textFields["email-field"] + XCTAssertTrue(emailField.waitForExistence(timeout: 6), "Email field should exist") + emailField.tap() + emailField.typeText(email) + + let passwordField = app.secureTextFields["password-field"] + XCTAssertTrue(passwordField.exists, "Password field should exist") + passwordField.tap() + passwordField.typeText("123456") + + let signInButton = app.buttons["sign-in-button"] + XCTAssertTrue(signInButton.exists, "Sign-In button should exist") + signInButton.tap() + + let signedInText = app.staticTexts["signed-in-text"] + XCTAssertTrue( + signedInText.waitForExistence(timeout: 10), + "SignedInView should be visible after login" + ) + + dismissAlert(app: app) + // Check the Views are updated + let signOutButton = app.buttons["sign-out-button"] + XCTAssertTrue( + signOutButton.waitForExistence(timeout: 10), + "Sign-Out button should exist and be visible" + ) + + signOutButton.tap() + XCTAssertTrue( + signInButton.waitForExistence(timeout: 20), + "Sign-In button should exist after logout" + ) + + let passwordRecoveryButton = app.buttons["password-recovery-button"] + XCTAssertTrue(passwordRecoveryButton.exists, "Password recovery button should exist") + passwordRecoveryButton.tap() + let passwordRecoveryText = app.staticTexts["password-recovery-text"] + XCTAssertTrue( + passwordRecoveryText.waitForExistence(timeout: 10), + "Password recovery text should exist after routing to PasswordRecoveryView" + ) + + let passwordRecoveryBackButton = app.buttons["password-recovery-back-button"] + XCTAssertTrue(passwordRecoveryBackButton.exists, "Password back button should exist") + passwordRecoveryBackButton.tap() + + let signInButton2 = app.buttons["sign-in-button"] + XCTAssertTrue( + signInButton2.waitForExistence(timeout: 10), + "Sign-In button should exist after pressing password recovery back button" + ) + + let emailLinkSignInButton = app.buttons["sign-in-with-email-link-button"] + XCTAssertTrue(emailLinkSignInButton.exists, "Email link sign-in button should exist") + emailLinkSignInButton.tap() + + let emailLinkText = app.staticTexts["email-link-title-text"] + + XCTAssertTrue( + emailLinkText.waitForExistence(timeout: 10), + "Email link text should exist after pressing email link button in AuthPickerView" + ) + + let emailLinkBackButton = app.buttons["email-link-back-button"] + XCTAssertTrue(emailLinkBackButton.exists, "Email link back button should exist") + emailLinkBackButton.tap() + + let signInButton3 = app.buttons["sign-in-button"] + XCTAssertTrue( + signInButton3.waitForExistence(timeout: 10), + "Sign-In button should exist after pressing password recovery back button" + ) + } + + @MainActor + func testCreateUserDisplaysSignedInView() throws { + let app = XCUIApplication() + let email = createEmail() + let password = "qwerty321@" + app.launchArguments.append("--auth-emulator") + app.launch() + + let switchFlowButton = app.buttons["switch-auth-flow"] + switchFlowButton.tap() + + 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() + + 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() + + 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() + + let signInButton = app.buttons["sign-in-button"] + XCTAssertTrue(signInButton.exists, "Sign-In button should exist") + signInButton.tap() + + let signedInText = app.staticTexts["signed-in-text"] + XCTAssertTrue( + signedInText.waitForExistence(timeout: 20), + "SignedInView should be visible after login" + ) + } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITestsLaunchTests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITestsLaunchTests.swift deleted file mode 100644 index 5518d8c0e5..0000000000 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITestsLaunchTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// -// FirebaseSwiftUIExampleUITestsLaunchTests.swift -// FirebaseSwiftUIExampleUITests -// -// Created by Russell Wheatley on 18/02/2025. -// - -import XCTest - -final class FirebaseSwiftUIExampleUITestsLaunchTests: XCTestCase { - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -}