diff --git a/.gitignore b/.gitignore index 3b4e8b9c..e7b2459f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ bin obj output tools +!tools/ +tools/* +!tools/e2e/ +tools/e2e/* +!tools/e2e/run-firebase-foundation.sh tmp-nugets *.userprefs *.DS_Store @@ -15,4 +20,5 @@ _._ externals artifacts *.user -GoogleService-Info.plist \ No newline at end of file +GoogleService-Info.plist +!tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/GoogleService-Info.plist diff --git a/source/Firebase/Analytics/ApiDefinition.cs b/source/Firebase/Analytics/ApiDefinition.cs index 2d371c39..df63600e 100644 --- a/source/Firebase/Analytics/ApiDefinition.cs +++ b/source/Firebase/Analytics/ApiDefinition.cs @@ -42,6 +42,7 @@ interface Analytics // + (nullable NSString *)appInstanceID; [Static] + [NullAllowed] [Export ("appInstanceID")] string AppInstanceId { get; } diff --git a/source/Firebase/AppCheck/ApiDefinition.cs b/source/Firebase/AppCheck/ApiDefinition.cs index 61371f46..9408ea13 100644 --- a/source/Firebase/AppCheck/ApiDefinition.cs +++ b/source/Firebase/AppCheck/ApiDefinition.cs @@ -5,7 +5,7 @@ namespace Firebase.AppCheck { // typedef void (^)(FIRAppCheckToken *_Nullable token, NSError *_Nullable error) - delegate void TokenCompletionHandler (AppCheckToken token, NSError error); + delegate void TokenCompletionHandler ([NullAllowed] AppCheckToken token, [NullAllowed] NSError error); interface IAppCheckProviderFactory { } @@ -97,6 +97,7 @@ interface AppCheckToken { [BaseType (typeof (NSObject), Name = "FIRAppCheckDebugProvider")] interface AppCheckDebugProvider : IAppCheckProvider { // -(instancetype _Nullable)initWithApp:(FIRApp * _Nonnull)app; + [return: NullAllowed] [Export ("initWithApp:")] NativeHandle Constructor (App app); @@ -120,6 +121,7 @@ interface AppCheckDebugProviderFactory : AppCheckProviderFactory { [BaseType (typeof (NSObject), Name = "FIRDeviceCheckProvider")] interface DeviceCheckProvider : AppCheckProvider { // -(instancetype _Nullable)initWithApp:(FIRApp * _Nonnull)app; + [return: NullAllowed] [Export ("initWithApp:")] NativeHandle Constructor (App app); } @@ -130,6 +132,7 @@ interface DeviceCheckProvider : AppCheckProvider { [BaseType (typeof (NSObject), Name = "FIRAppAttestProvider")] interface AppAttestProvider : AppCheckProvider { // -(instancetype _Nullable)initWithApp:(FIRApp * _Nonnull)app; + [return: NullAllowed] [Export ("initWithApp:")] NativeHandle Constructor (App app); } diff --git a/source/Firebase/CloudFirestore/ApiDefinition.cs b/source/Firebase/CloudFirestore/ApiDefinition.cs index 39976177..3d0b2dc9 100644 --- a/source/Firebase/CloudFirestore/ApiDefinition.cs +++ b/source/Firebase/CloudFirestore/ApiDefinition.cs @@ -45,10 +45,10 @@ interface CollectionReference // -(FIRDocumentReference * _Nonnull)addDocumentWithData:(NSDictionary * _Nonnull)data completion:(void (^ _Nullable)(NSError * _Nullable))completion; [Export ("addDocumentWithData:completion:")] - DocumentReference AddDocument (NSDictionary nsData, AddDocumentCompletionHandler completion); + DocumentReference AddDocument (NSDictionary nsData, [NullAllowed] AddDocumentCompletionHandler completion); [Wrap ("AddDocument (data == null ? null : NSDictionary.FromObjectsAndKeys (System.Linq.Enumerable.ToArray (data.Values), System.Linq.Enumerable.ToArray (data.Keys), data.Keys.Count), completion)")] - DocumentReference AddDocument (Dictionary data, AddDocumentCompletionHandler completion); + DocumentReference AddDocument (Dictionary data, [NullAllowed] AddDocumentCompletionHandler completion); } // @interface FIRDocumentChange : NSObject @@ -247,6 +247,7 @@ interface DocumentSnapshot SnapshotMetadata Metadata { get; } // -(NSDictionary * _Nonnull)data; + [NullAllowed] [Export ("data")] NSDictionary Data { get; } diff --git a/source/Firebase/CloudFunctions/ApiDefinition.cs b/source/Firebase/CloudFunctions/ApiDefinition.cs index a8a0976f..c97fbb6b 100644 --- a/source/Firebase/CloudFunctions/ApiDefinition.cs +++ b/source/Firebase/CloudFunctions/ApiDefinition.cs @@ -48,6 +48,7 @@ interface CloudFunctions HttpsCallable HttpsCallable(string name); // @property(nonatomic, readonly, nullable) NSString *emulatorOrigin; + [NullAllowed] [Export("emulatorOrigin")] string EmulatorOrigin { get; } diff --git a/source/Firebase/CloudMessaging/ApiDefinition.cs b/source/Firebase/CloudMessaging/ApiDefinition.cs index 8161d14d..bea93fa8 100644 --- a/source/Firebase/CloudMessaging/ApiDefinition.cs +++ b/source/Firebase/CloudMessaging/ApiDefinition.cs @@ -37,9 +37,9 @@ interface IMessagingDelegate [BaseType (typeof (NSObject), Name = "FIRMessagingDelegate")] interface MessagingDelegate { - // @optional -(void)messaging:(FIRMessaging * _Nonnull)messaging didReceiveRegistrationToken:(NSString * _Nonnull)fcmToken; + // @optional -(void)messaging:(FIRMessaging * _Nonnull)messaging didReceiveRegistrationToken:(NSString * _Nullable)fcmToken; [Export ("messaging:didReceiveRegistrationToken:")] - void DidReceiveRegistrationToken (Messaging messaging, string fcmToken); + void DidReceiveRegistrationToken (Messaging messaging, [NullAllowed] string fcmToken); } // @interface FIRMessaging : NSObject @@ -108,7 +108,7 @@ interface Messaging // -(void)subscribeToTopic:(NSString * _Nonnull)topic completion:(nullable FIRMessagingTopicOperationCompletion)completion; [Async] [Export ("subscribeToTopic:completion:")] - void Subscribe (string topic, MessagingTopicOperationCompletionHandler completion); + void Subscribe (string topic, [NullAllowed] MessagingTopicOperationCompletionHandler completion); // -(void)unsubscribeFromTopic:(NSString * _Nonnull)topic; [Export ("unsubscribeFromTopic:")] @@ -117,7 +117,7 @@ interface Messaging //-(void)unsubscribeFromTopic:(NSString * _Nonnull)topic completion:(nullable FIRMessagingTopicOperationCompletion)completion; [Async] [Export ("unsubscribeFromTopic:completion:")] - void Unsubscribe (string topic, MessagingTopicOperationCompletionHandler completion); + void Unsubscribe (string topic, [NullAllowed] MessagingTopicOperationCompletionHandler completion); // -(FIRMessagingMessageInfo * _Nonnull)appDidReceiveMessage:(NSDictionary * _Nonnull)message; [Export ("appDidReceiveMessage:")] diff --git a/source/Firebase/Core/ApiDefinition.cs b/source/Firebase/Core/ApiDefinition.cs index f9c082e9..78eaa0e3 100644 --- a/source/Firebase/Core/ApiDefinition.cs +++ b/source/Firebase/Core/ApiDefinition.cs @@ -32,6 +32,7 @@ interface App : INativeObject // +(FIRApp * _Nullable)defaultApp; [Static] + [NullAllowed] [Export ("defaultApp")] App DefaultInstance { get; } @@ -96,7 +97,6 @@ interface Options : INSCopying string ApiKey { get; set; } // @property(nonatomic, copy) NSString *bundleID; - [NullAllowed] [Export ("bundleID")] string BundleId { get; set; } diff --git a/source/Firebase/Crashlytics/ApiDefinition.cs b/source/Firebase/Crashlytics/ApiDefinition.cs index 87cd55e5..43812871 100644 --- a/source/Firebase/Crashlytics/ApiDefinition.cs +++ b/source/Firebase/Crashlytics/ApiDefinition.cs @@ -23,17 +23,17 @@ interface Crashlytics [Export ("log:")] void Log (string message); - // -(void)setCustomValue:(id _Nonnull)value forKey:(NSString * _Nonnull)key; + // -(void)setCustomValue:(id _Nullable)value forKey:(NSString * _Nonnull)key; [Export ("setCustomValue:forKey:")] - void SetCustomValue (NSObject value, string key); + void SetCustomValue ([NullAllowed] NSObject value, string key); // -(void)setCustomKeysAndValues:(NSDictionary * _Nonnull)keysAndValues; [Export ("setCustomKeysAndValues:")] void SetCustomKeysAndValues (NSDictionary keysAndValues); - // -(void)setUserID:(NSString * _Nonnull)userID; + // -(void)setUserID:(NSString * _Nullable)userID; [Export ("setUserID:")] - void SetUserId (string userId); + void SetUserId ([NullAllowed] string userId); // -(void)recordError:(NSError * _Nonnull)error __attribute__((swift_name("record(error:)"))); [Export ("recordError:")] @@ -131,16 +131,16 @@ interface CrashlyticsReport { [Export ("log:")] void Log (string msg); - // -(void)setCustomValue:(id _Nonnull)value forKey:(NSString * _Nonnull)key; + // -(void)setCustomValue:(id _Nullable)value forKey:(NSString * _Nonnull)key; [Export ("setCustomValue:forKey:")] - void SetCustomValue (NSObject value, string key); + void SetCustomValue ([NullAllowed] NSObject value, string key); // -(void)setCustomKeysAndValues:(NSDictionary * _Nonnull)keysAndValues; [Export ("setCustomKeysAndValues:")] void SetCustomKeysAndValues (NSDictionary keysAndValues); - // -(void)setUserID:(NSString * _Nonnull)userID; + // -(void)setUserID:(NSString * _Nullable)userID; [Export ("setUserID:")] - void SetUserID (string userID); + void SetUserID ([NullAllowed] string userID); } } diff --git a/source/Firebase/Database/ApiDefinition.cs b/source/Firebase/Database/ApiDefinition.cs index c4e0c349..919a84cb 100644 --- a/source/Firebase/Database/ApiDefinition.cs +++ b/source/Firebase/Database/ApiDefinition.cs @@ -463,7 +463,7 @@ interface DatabaseReference // -(void)runTransactionBlock:(FIRTransactionResult * _Nonnull (^ _Nonnull)(FIRMutableData * _Nonnull))block andCompletionBlock:(void (^ _Nullable)(NSError * _Nullable, BOOL, FIRDataSnapshot * _Nullable))completionBlock withLocalEvents:(BOOL)localEvents; [Export ("runTransactionBlock:andCompletionBlock:withLocalEvents:")] - void RunTransaction (DatabaseReferenceTransactionHandler transactionHandler, DatabaseReferenceTransactionCompletionHandler completionBlock, bool localEvents); + void RunTransaction (DatabaseReferenceTransactionHandler transactionHandler, [NullAllowed] DatabaseReferenceTransactionCompletionHandler completionBlock, bool localEvents); // -(NSString * _Nonnull)description; [New] @@ -480,6 +480,7 @@ interface DatabaseReference DatabaseReference Root { get; } // @property (readonly, nonatomic, strong) NSString * _Nonnull key; + [NullAllowed] [Export ("key", ArgumentSemantic.Strong)] string Key { get; } diff --git a/source/Firebase/PerformanceMonitoring/ApiDefinition.cs b/source/Firebase/PerformanceMonitoring/ApiDefinition.cs index 1b8425ff..171df06e 100644 --- a/source/Firebase/PerformanceMonitoring/ApiDefinition.cs +++ b/source/Firebase/PerformanceMonitoring/ApiDefinition.cs @@ -13,6 +13,7 @@ namespace Firebase.PerformanceMonitoring interface HttpMetric : PerformanceAttributable { // -(instancetype _Nullable)initWithURL:(NSURL * _Nonnull)URL HTTPMethod:(FIRHTTPMethod)httpMethod; + [return: NullAllowed] [Export ("initWithURL:HTTPMethod:")] NativeHandle Constructor (NSUrl url, HttpMethod httpMethod); diff --git a/source/Firebase/Storage/ApiDefinition.cs b/source/Firebase/Storage/ApiDefinition.cs index e0378f77..67f6fced 100644 --- a/source/Firebase/Storage/ApiDefinition.cs +++ b/source/Firebase/Storage/ApiDefinition.cs @@ -295,15 +295,15 @@ interface StorageReference [Export ("writeToFile:completion:")] StorageDownloadTask WriteToFile (NSUrl fileURL, [NullAllowed] StorageWriteToFileCompletionHandler completion); - // -(void)listAllWithCompletion:(void (^ _Nonnull)(FIRStorageListResult * _Nonnull, NSError * _Nullable))completion; + // -(void)listAllWithCompletion:(void (^ _Nonnull)(FIRStorageListResult * _Nullable, NSError * _Nullable))completion; [Export ("listAllWithCompletion:")] void ListAll (Action completion); - // -(void)listWithMaxResults:(int64_t)maxResults completion:(void (^ _Nonnull)(FIRStorageListResult * _Nonnull, NSError * _Nullable))completion; + // -(void)listWithMaxResults:(int64_t)maxResults completion:(void (^ _Nonnull)(FIRStorageListResult * _Nullable, NSError * _Nullable))completion; [Export ("listWithMaxResults:completion:")] void List (long maxResults, Action completion); - // -(void)listWithMaxResults:(int64_t)maxResults pageToken:(NSString * _Nonnull)pageToken completion:(void (^ _Nonnull)(FIRStorageListResult * _Nonnull, NSError * _Nullable))completion; + // -(void)listWithMaxResults:(int64_t)maxResults pageToken:(NSString * _Nonnull)pageToken completion:(void (^ _Nonnull)(FIRStorageListResult * _Nullable, NSError * _Nullable))completion; [Export ("listWithMaxResults:pageToken:completion:")] void List (long maxResults, string pageToken, Action completion); diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/AppDelegate.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/AppDelegate.cs new file mode 100644 index 00000000..a0a9a89d --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/AppDelegate.cs @@ -0,0 +1,26 @@ +using Foundation; +using UIKit; + +namespace FirebaseFoundationE2E; + +[Register("AppDelegate")] +public sealed class AppDelegate : UIApplicationDelegate +{ + public override UIWindow? Window { get; set; } + + public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions) + { +#pragma warning disable CA1422 + Window = new UIWindow(UIScreen.MainScreen.Bounds); +#pragma warning restore CA1422 + + var statusViewController = new StatusViewController(); + Window.RootViewController = statusViewController; + Window.MakeKeyAndVisible(); + statusViewController.LoadViewIfNeeded(); + + _ = FirebaseSelfTestRunner.RunAsync(statusViewController); + + return true; + } +} diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/E2EContracts.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/E2EContracts.cs new file mode 100644 index 00000000..780bb906 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/E2EContracts.cs @@ -0,0 +1,26 @@ +namespace FirebaseFoundationE2E; + +public sealed class FirebaseE2ERunResult +{ + public string BundleId { get; set; } = string.Empty; + public string? DefaultAppName { get; set; } + public string? GoogleAppId { get; set; } + public string? ProjectId { get; set; } + public string? FirebaseVersion { get; set; } + public string? InstallationsIdPreview { get; set; } + public bool Success { get; set; } + public DateTimeOffset StartedAtUtc { get; set; } + public DateTimeOffset CompletedAtUtc { get; set; } + public string? FatalError { get; set; } + public List Cases { get; } = new(); +} + +public sealed class FirebaseE2ETestCaseResult +{ + public string Name { get; set; } = string.Empty; + public bool Success { get; set; } + public long DurationMs { get; set; } + public string? Message { get; set; } + public string? ExceptionType { get; set; } + public string? Detail { get; set; } +} diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/E2ELogger.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/E2ELogger.cs new file mode 100644 index 00000000..1ea1bd26 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/E2ELogger.cs @@ -0,0 +1,9 @@ +namespace FirebaseFoundationE2E; + +public static class E2ELogger +{ + public static void WriteLine(string message) + { + Console.WriteLine(message); + } +} diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj new file mode 100644 index 00000000..8608dab9 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj @@ -0,0 +1,67 @@ + + + net9.0-ios;net9.0-maccatalyst + iossimulator-arm64;ios-arm64;maccatalyst-arm64 + + Exe + enable + true + 15.0 + FirebaseFoundationE2E + FirebaseFoundationE2E + Resources + AnyCPU;iPhoneSimulator;iPhone + Debug;Release + false + manual + false + + + + $(DefineConstants);ENABLE_NULLABILITY_VALIDATION + + + + iPhoneSimulator + + + + iossimulator-arm64 + + + + maccatalyst-arm64 + + + + None + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseNullabilityValidation.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseNullabilityValidation.cs new file mode 100644 index 00000000..3c20a32b --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseNullabilityValidation.cs @@ -0,0 +1,227 @@ +#if ENABLE_NULLABILITY_VALIDATION +using Firebase.Analytics; +using Firebase.AppCheck; +using Firebase.CloudFirestore; +using Firebase.CloudMessaging; +using Firebase.Crashlytics; +using Firebase.Database; +using Firebase.PerformanceMonitoring; +using FirebaseCoreApp = Firebase.Core.App; +using FirebaseCoreOptions = Firebase.Core.Options; +using FirebaseMessagingClient = Firebase.CloudMessaging.Messaging; +using FirebasePerformanceHttpMethod = Firebase.PerformanceMonitoring.HttpMethod; +using Foundation; +using ObjCRuntime; + +namespace FirebaseFoundationE2E; + +static class FirebaseNullabilityValidation +{ + static readonly TimeSpan AsyncTimeout = TimeSpan.FromSeconds(5); + + public static Task VerifyCoreNullabilityAsync() + { + var defaultApp = FirebaseCoreApp.DefaultInstance ?? throw new InvalidOperationException("Firebase.Core.App.DefaultInstance returned null during nullability validation."); + var defaultOptions = FirebaseCoreOptions.DefaultInstance ?? throw new InvalidOperationException("Firebase.Core.Options.DefaultInstance returned null during nullability validation."); + var bundleId = defaultOptions.BundleId; + + if (string.IsNullOrWhiteSpace(bundleId)) + { + throw new InvalidOperationException("Firebase.Core.Options.BundleId unexpectedly returned null or empty."); + } + + var currentBundleId = NSBundle.MainBundle.BundleIdentifier ?? string.Empty; + if (!string.Equals(bundleId, currentBundleId, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Firebase.Core.Options.BundleId returned '{bundleId}', expected '{currentBundleId}'."); + } + + return Task.FromResult($"Default app '{defaultApp.Name}' resolved successfully. BundleId: {bundleId}."); + } + + public static Task VerifyAnalyticsNullabilityAsync() + { + var appInstanceId = Analytics.AppInstanceId; + var state = string.IsNullOrWhiteSpace(appInstanceId) ? "" : appInstanceId; + return Task.FromResult($"Analytics.AppInstanceId completed without throwing. Value: {state}."); + } + + public static Task VerifyAppCheckNullabilityAsync() + { + var defaultApp = FirebaseCoreApp.DefaultInstance ?? throw new InvalidOperationException("Firebase.Core.App.DefaultInstance returned null before App Check validation."); + var perAppInstance = AppCheck.Create(defaultApp); + + var handlerObservedNullPayload = false; + TokenCompletionHandler tokenHandler = (token, error) => + { + handlerObservedNullPayload = token is null && error is null; + }; + tokenHandler(null, null); + + if (!handlerObservedNullPayload) + { + throw new InvalidOperationException("AppCheck TokenCompletionHandler did not accept null token/error values."); + } + + var debugProvider = new AppCheckDebugProvider(defaultApp); + var deviceCheckProvider = new DeviceCheckProvider(defaultApp); + var appAttestProvider = new AppAttestProvider(defaultApp); + + return Task.FromResult( + $"AppCheck.Create returned {(perAppInstance is null ? "" : "instance")}. " + + $"DebugProvider handle zero: {debugProvider.Handle == NativeHandle.Zero}. " + + $"DeviceCheckProvider handle zero: {deviceCheckProvider.Handle == NativeHandle.Zero}. " + + $"AppAttestProvider handle zero: {appAttestProvider.Handle == NativeHandle.Zero}. " + + $"Token handler accepted null payloads."); + } + + public static Task VerifyCloudMessagingNullabilityAsync() + { + var messaging = FirebaseMessagingClient.SharedInstance ?? throw new InvalidOperationException("Firebase.CloudMessaging.Messaging.SharedInstance returned null."); + var delegateProbe = new MessagingDelegateProbe(); + + messaging.Delegate = delegateProbe; + delegateProbe.DidReceiveRegistrationToken(messaging, null); + + if (!delegateProbe.Invoked || delegateProbe.ObservedToken is not null) + { + throw new InvalidOperationException("Firebase.CloudMessaging.MessagingDelegate did not accept a null registration token."); + } + + messaging.Subscribe("codex-nullability-e2e", null); + messaging.Unsubscribe("codex-nullability-e2e", null); + + return Task.FromResult("Cloud Messaging accepted a null registration token and null topic-operation completions without throwing."); + } + + public static async Task VerifyCloudFirestoreNullabilityAsync() + { + var firestore = Firestore.SharedInstance ?? throw new InvalidOperationException("Firebase.CloudFirestore.Firestore.SharedInstance returned null."); + var collection = firestore.GetCollection("codex-nullability-e2e"); + var addedDocument = collection.AddDocument( + new Dictionary + { + ["marker"] = new NSString("nullability"), + }, + null); + + if (addedDocument is null) + { + throw new InvalidOperationException("Firebase.CloudFirestore.CollectionReference.AddDocument returned null."); + } + + var snapshotCompletion = new TaskCompletionSource<(DocumentSnapshot? Snapshot, NSError? Error)>(TaskCreationOptions.RunContinuationsAsynchronously); + IListenerRegistration? registration = null; + + registration = addedDocument.AddSnapshotListener((snapshot, error) => + { + registration?.Remove(); + snapshotCompletion.TrySetResult((snapshot, error)); + }); + + var (snapshot, error) = await WaitForCompletionAsync(snapshotCompletion.Task, "Cloud Firestore document listener"); + if (error is not null) + { + return $"Cloud Firestore accepted a null AddDocument completion and reached native listener callback with Firebase error {FormatNSError(error)} for '{addedDocument.Path}'."; + } + + if (snapshot is null) + { + throw new InvalidOperationException("Cloud Firestore document listener completed without either a snapshot or an error."); + } + + var snapshotData = snapshot.Data; + var dataState = snapshotData is null ? "null" : $"count={snapshotData.Count}"; + return $"Cloud Firestore accepted a null AddDocument completion and reached native listener callback for '{addedDocument.Path}'. Exists={snapshot.Exists}. Data={dataState}."; + } + + public static async Task VerifyCrashlyticsNullabilityAsync() + { + var crashlytics = Crashlytics.SharedInstance ?? throw new InvalidOperationException("Firebase.Crashlytics.Crashlytics.SharedInstance returned null."); + + crashlytics.SetCustomValue(null, "codex-nullability-value"); + crashlytics.SetUserId(null); + crashlytics.Log("codex-nullability-crashlytics"); + + var reportCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + crashlytics.CheckAndUpdateUnsentReportsWithCompletion(report => reportCompletion.TrySetResult(report)); + + var completedTask = await Task.WhenAny(reportCompletion.Task, Task.Delay(AsyncTimeout)); + if (completedTask != reportCompletion.Task) + { + return "Crashlytics accepted null custom value/user ID without throwing. Unsent-report callback did not complete within the validation timeout."; + } + + var report = await reportCompletion.Task; + if (report is not null) + { + report.SetCustomValue(null, "codex-nullability-report-value"); + report.SetUserID(null); + return $"Crashlytics accepted null values on both the shared instance and CrashlyticsReport '{report.ReportID}'."; + } + + return "Crashlytics accepted null custom value/user ID without throwing and reported a null unsent report."; + } + + public static Task VerifyDatabaseNullabilityAsync() + { + var projectId = FirebaseCoreOptions.DefaultInstance?.ProjectId ?? throw new InvalidOperationException("Firebase.Core.Options.ProjectId returned null before Database validation."); + var database = Firebase.Database.Database.From($"https://{projectId}-default-rtdb.firebaseio.com"); + var root = database.GetRootReference(); + + if (root.Key is not null) + { + throw new InvalidOperationException($"Firebase.Database.DatabaseReference.Key returned '{root.Key}' for the root reference."); + } + + var child = root.GetChild("codex-nullability-e2e"); + child.RunTransaction(_ => TransactionResult.Abort(), null, false); + + return Task.FromResult($"Realtime Database root key was null as expected and RunTransaction accepted a null completion block on '{child.Url}'."); + } + + public static Task VerifyPerformanceNullabilityAsync() + { + var url = new NSUrl("https://example.com/codex-nullability"); + var metric = new HttpMetric(url, FirebasePerformanceHttpMethod.Get); + if (metric is null) + { + throw new InvalidOperationException("Firebase.PerformanceMonitoring.HttpMetric returned null for a valid URL."); + } + + metric.ResponseContentType = null; + metric.Start(); + metric.Stop(); + + return Task.FromResult("PerformanceMonitoring.HttpMetric accepted a valid constructor call and a null ResponseContentType without throwing."); + } + + static async Task WaitForCompletionAsync(Task task, string operation) + { + var completedTask = await Task.WhenAny(task, Task.Delay(AsyncTimeout)); + if (completedTask != task) + { + throw new TimeoutException($"{operation} did not complete within {AsyncTimeout.TotalSeconds} seconds."); + } + + return await task; + } + + static string FormatNSError(NSError error) + { + return $"{error.Domain} ({error.Code}): {error.LocalizedDescription}"; + } + + sealed class MessagingDelegateProbe : MessagingDelegate + { + public bool Invoked { get; private set; } + public string? ObservedToken { get; private set; } + + public override void DidReceiveRegistrationToken(FirebaseMessagingClient messaging, string? fcmToken) + { + Invoked = true; + ObservedToken = fcmToken; + } + } +} +#endif diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseSelfTestRunner.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseSelfTestRunner.cs new file mode 100644 index 00000000..a6f353fd --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseSelfTestRunner.cs @@ -0,0 +1,237 @@ +using System.Diagnostics; +using System.Text.Json; +using Firebase.Analytics; +using Firebase.Core; +using Firebase.Installations; +using Foundation; + +namespace FirebaseFoundationE2E; + +public static class FirebaseSelfTestRunner +{ + const string ExpectedBundleId = "com.googleapisforioscomponents.tests.firebase.e2e"; + const string ResultFileName = "firebase-foundation-e2e-result.json"; + static readonly TimeSpan InstallationsTimeout = TimeSpan.FromSeconds(45); + + public static async Task RunAsync(StatusViewController statusViewController) + { + var result = new FirebaseE2ERunResult + { + BundleId = NSBundle.MainBundle.BundleIdentifier ?? ExpectedBundleId, + StartedAtUtc = DateTimeOffset.UtcNow, + }; + + await statusViewController.AppendLineAsync($"Bundle id: {result.BundleId}"); + await statusViewController.AppendLineAsync("Running Firebase binding smoke tests..."); +#if ENABLE_NULLABILITY_VALIDATION + await statusViewController.AppendLineAsync("Firebase nullability validation mode enabled."); +#endif + + try + { + await ExecuteCaseAsync(result, statusViewController, "ConfigureApp", async () => + { + EnsureConfigFileIsBundled(); + App.Configure(); + + var defaultApp = App.DefaultInstance ?? throw new InvalidOperationException("Firebase.Core.App.DefaultInstance returned null after App.Configure()."); + var options = Options.DefaultInstance; + + result.DefaultAppName = defaultApp.Name; + result.GoogleAppId = options?.GoogleAppId; + result.ProjectId = options?.ProjectId; + + return $"Configured app '{defaultApp.Name}'."; + }); + + await ExecuteCaseAsync(result, statusViewController, "CoreSurface", async () => + { + var defaultApp = App.DefaultInstance ?? throw new InvalidOperationException("Firebase.Core.App.DefaultInstance returned null."); + var firebaseVersion = App.FirebaseVersion; + + if (string.IsNullOrWhiteSpace(firebaseVersion)) + { + throw new InvalidOperationException("Firebase.Core.App.FirebaseVersion returned an empty value."); + } + + result.FirebaseVersion = firebaseVersion; + return $"Firebase version: {firebaseVersion}; app name: {defaultApp.Name}."; + }); + +#if ENABLE_NULLABILITY_VALIDATION + await ExecuteCaseAsync(result, statusViewController, "CoreNullabilitySurface", () => + FirebaseNullabilityValidation.VerifyCoreNullabilityAsync()); + + await ExecuteCaseAsync(result, statusViewController, "AnalyticsNullabilitySurface", () => + FirebaseNullabilityValidation.VerifyAnalyticsNullabilityAsync()); + + await ExecuteCaseAsync(result, statusViewController, "AppCheckNullabilitySurface", () => + FirebaseNullabilityValidation.VerifyAppCheckNullabilityAsync()); + + await ExecuteCaseAsync(result, statusViewController, "CloudMessagingNullabilitySurface", () => + FirebaseNullabilityValidation.VerifyCloudMessagingNullabilityAsync()); + + await ExecuteCaseAsync(result, statusViewController, "CloudFirestoreNullabilitySurface", () => + FirebaseNullabilityValidation.VerifyCloudFirestoreNullabilityAsync()); + + await ExecuteCaseAsync(result, statusViewController, "CrashlyticsNullabilitySurface", () => + FirebaseNullabilityValidation.VerifyCrashlyticsNullabilityAsync()); + + await ExecuteCaseAsync(result, statusViewController, "DatabaseNullabilitySurface", () => + FirebaseNullabilityValidation.VerifyDatabaseNullabilityAsync()); + + await ExecuteCaseAsync(result, statusViewController, "PerformanceNullabilitySurface", () => + FirebaseNullabilityValidation.VerifyPerformanceNullabilityAsync()); +#endif + + await ExecuteCaseAsync(result, statusViewController, "InstallationsSurface", async () => + { + var installations = Installations.DefaultInstance ?? throw new InvalidOperationException("Firebase.Installations.Installations.DefaultInstance returned null."); + var installationId = await GetInstallationIdAsync(installations); + result.InstallationsIdPreview = installationId.Length > 16 + ? installationId[..8] + "..." + installationId[^8..] + : installationId; + return $"Installation id: {result.InstallationsIdPreview}."; + }); + + await ExecuteCaseAsync(result, statusViewController, "AnalyticsSurface", async () => + { + Analytics.SetAnalyticsCollectionEnabled(true); + Analytics.SetConsent(new Dictionary + { + [ConsentType.AnalyticsStorage] = ConsentStatus.Granted, + [ConsentType.AdStorage] = ConsentStatus.Denied, + }); + + Analytics.LogEvent(EventNamesConstants.ScreenView.ToString(), new Dictionary + { + [ParameterNamesConstants.ScreenName] = new NSString("firebase_nuget_e2e"), + [ParameterNamesConstants.ScreenClass] = new NSString(nameof(FirebaseSelfTestRunner)), + [ParameterNamesConstants.Success] = NSNumber.FromBoolean(true), + }); + + return "Analytics collection and event APIs completed without throwing."; + }); + } + catch (Exception ex) + { + result.FatalError = ex.ToString(); + E2ELogger.WriteLine($"Unhandled E2E failure: {ex}"); + await statusViewController.AppendLineAsync("Unhandled failure: " + ex.Message); + } + + result.CompletedAtUtc = DateTimeOffset.UtcNow; + result.Success = string.IsNullOrWhiteSpace(result.FatalError) && result.Cases.All(c => c.Success); + + var indentedJson = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + var compactJson = JsonSerializer.Serialize(result); + var resultFilePath = GetResultFilePath(); + Directory.CreateDirectory(Path.GetDirectoryName(resultFilePath)!); + File.WriteAllText(resultFilePath, indentedJson); + + await statusViewController.AppendLineAsync(result.Success ? "All Firebase E2E checks passed." : "Firebase E2E checks failed."); + await statusViewController.AppendLineAsync("Result file: " + resultFilePath); + + E2ELogger.WriteLine("E2E_RESULT:" + compactJson); + E2ELogger.WriteLine("E2E_STATUS:" + (result.Success ? "PASS" : "FAIL")); + } + + static async Task ExecuteCaseAsync( + FirebaseE2ERunResult result, + StatusViewController statusViewController, + string name, + Func> testCase) + { + await statusViewController.AppendLineAsync(string.Empty); + await statusViewController.AppendLineAsync("Running " + name + "..."); + + var caseResult = new FirebaseE2ETestCaseResult + { + Name = name, + }; + + var stopwatch = Stopwatch.StartNew(); + + try + { + caseResult.Detail = await testCase(); + caseResult.Success = true; + caseResult.Message = "OK"; + await statusViewController.AppendLineAsync("PASS " + name + ": " + caseResult.Detail); + } + catch (Exception ex) + { + caseResult.Success = false; + caseResult.Message = ex.Message; + caseResult.ExceptionType = ex.GetType().FullName; + caseResult.Detail = ex.ToString(); + await statusViewController.AppendLineAsync("FAIL " + name + ": " + ex.Message); + } + finally + { + stopwatch.Stop(); + caseResult.DurationMs = stopwatch.ElapsedMilliseconds; + result.Cases.Add(caseResult); + } + + if (!caseResult.Success) + { + throw new InvalidOperationException($"{name} failed: {caseResult.Message}"); + } + } + + static void EnsureConfigFileIsBundled() + { + var configPath = NSBundle.MainBundle.PathForResource("GoogleService-Info", "plist"); + if (string.IsNullOrWhiteSpace(configPath) || !File.Exists(configPath)) + { + throw new InvalidOperationException("GoogleService-Info.plist was not found in the app bundle."); + } + + if (!string.Equals(NSBundle.MainBundle.BundleIdentifier, ExpectedBundleId, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Unexpected bundle id '{NSBundle.MainBundle.BundleIdentifier}'. Expected '{ExpectedBundleId}'."); + } + } + + static async Task GetInstallationIdAsync(Installations installations) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + installations.GetInstallationId((identifier, error) => + { + if (error is not null) + { + tcs.TrySetException(new InvalidOperationException("Firebase Installations returned an error: " + error.LocalizedDescription)); + return; + } + + if (string.IsNullOrWhiteSpace(identifier)) + { + tcs.TrySetException(new InvalidOperationException("Firebase Installations returned an empty installation id.")); + return; + } + + tcs.TrySetResult(identifier); + }); + + var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(InstallationsTimeout)); + if (completedTask != tcs.Task) + { + throw new TimeoutException($"Firebase Installations did not complete within {InstallationsTimeout.TotalSeconds} seconds."); + } + + return await tcs.Task; + } + + static string GetResultFilePath() + { + var cacheDirectory = NSSearchPath.GetDirectories(NSSearchPathDirectory.CachesDirectory, NSSearchPathDomain.User).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(cacheDirectory)) + { + cacheDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + } + + return Path.Combine(cacheDirectory, ResultFileName); + } +} diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/GoogleService-Info.plist b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/GoogleService-Info.plist new file mode 100644 index 00000000..f0c69be7 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyCdASkFMC6zc42BvEtrtOnvu7gBkHDgn-Q + GCM_SENDER_ID + 188553127061 + PLIST_VERSION + 1 + BUNDLE_ID + com.googleapisforioscomponents.tests.firebase.e2e + PROJECT_ID + dotnet-ios-bindings + STORAGE_BUCKET + dotnet-ios-bindings.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:188553127061:ios:174d8ec9a49a525ba3279c + + diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/Info.plist b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/Info.plist new file mode 100644 index 00000000..66f36c54 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/Info.plist @@ -0,0 +1,14 @@ + + + + + CFBundleDisplayName + Firebase E2E + CFBundleIdentifier + com.googleapisforioscomponents.tests.firebase.e2e + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/Main.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/Main.cs new file mode 100644 index 00000000..c18547d8 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/Main.cs @@ -0,0 +1,4 @@ +using UIKit; +using FirebaseFoundationE2E; + +UIApplication.Main(args, null, typeof(AppDelegate)); diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/StatusViewController.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/StatusViewController.cs new file mode 100644 index 00000000..64046996 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/StatusViewController.cs @@ -0,0 +1,46 @@ +using UIKit; + +namespace FirebaseFoundationE2E; + +public sealed class StatusViewController : UIViewController +{ + UITextView? textView; + + public override void ViewDidLoad() + { + base.ViewDidLoad(); + + View!.BackgroundColor = UIColor.SystemBackground; + + textView = new UITextView(View.Bounds) + { + Editable = false, + AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight, + BackgroundColor = UIColor.SystemBackground, + TextColor = UIColor.Label, + Font = UIFont.FromName("Menlo-Regular", 15) ?? UIFont.SystemFontOfSize(15), + TextContainerInset = new UIEdgeInsets(24, 20, 24, 20), + Text = "Firebase NuGet E2E harness starting..." + Environment.NewLine + }; + + View.AddSubview(textView); + } + + public Task AppendLineAsync(string line) + { + InvokeOnMainThread(() => + { + if (textView is null) + { + return; + } + + var prefix = string.IsNullOrEmpty(textView.Text) ? string.Empty : Environment.NewLine; + textView.Text += prefix + line; + var end = new CoreGraphics.CGPoint(0, Math.Max(0, textView.ContentSize.Height - textView.Bounds.Height)); + textView.SetContentOffset(end, false); + }); + + return Task.CompletedTask; + } +} diff --git a/tests/E2E/Firebase.Foundation/NuGet.config b/tests/E2E/Firebase.Foundation/NuGet.config new file mode 100644 index 00000000..b5fe063c --- /dev/null +++ b/tests/E2E/Firebase.Foundation/NuGet.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/E2E/Firebase.Foundation/README.md b/tests/E2E/Firebase.Foundation/README.md new file mode 100644 index 00000000..f9c9ebf9 --- /dev/null +++ b/tests/E2E/Firebase.Foundation/README.md @@ -0,0 +1,82 @@ +# Firebase NuGet E2E Harness + +This area hosts a dedicated consumer-style end-to-end app for validating Firebase binding NuGet packages generated by this repository. + +## Current scope + +- `AdamE.Firebase.iOS.Core` +- `AdamE.Firebase.iOS.Installations` +- `AdamE.Firebase.iOS.Analytics` + +Optional local-only nullability validation can also restore and exercise: + +- `AdamE.Firebase.iOS.AppCheck` +- `AdamE.Firebase.iOS.CloudFirestore` +- `AdamE.Firebase.iOS.CloudMessaging` +- `AdamE.Firebase.iOS.Crashlytics` +- `AdamE.Firebase.iOS.Database` +- `AdamE.Firebase.iOS.PerformanceMonitoring` + +The E2E app restores the Firebase dependency closure from the local feed as well, so native binding resources are available consistently across iOS simulator and Mac Catalyst builds: + +- `AdamE.Google.iOS.GoogleAppMeasurement` +- `AdamE.Google.iOS.GoogleDataTransport` +- `AdamE.Google.iOS.GoogleUtilities` +- `AdamE.Google.iOS.Nanopb` +- `AdamE.Google.iOS.PromisesObjC` + +## Bundle id + +The E2E app uses the fixed bundle id: + +`com.googleapisforioscomponents.tests.firebase.e2e` + +## Firebase configuration + +The app includes a disposable `GoogleService-Info.plist` checked into: + +[`FirebaseFoundationE2E/GoogleService-Info.plist`](./FirebaseFoundationE2E/GoogleService-Info.plist) + +If the Firebase test app registration ever changes, update that file so it continues to match the fixed bundle id above. + +## Local package restore + +[`NuGet.config`](./NuGet.config) forces all `AdamE.*` packages to restore from the local `output/` feed, which means this app validates the packages produced by the repository rather than project references. + +## Local workflow + +1. Pack the Firebase Analytics slice and its dependencies: + +```sh +dotnet tool restore +dotnet tool run dotnet-cake -- --target=nuget --names=Firebase.Analytics +``` + +2. Run the simulator smoke test: + +```sh +tools/e2e/run-firebase-foundation.sh --package-dir output --configuration Debug +``` + +The runner writes logs and JSON results under: + +[`tests/E2E/Firebase.Foundation/artifacts`](./artifacts) + +`Release` remains available for manual runs, but the automated simulator lane uses `Debug` to avoid the heavier AOT/LLVM step on lower-memory machines. + +## Optional nullability validation mode + +This project also supports an opt-in nullability validation lane that exercises the shipped consumer APIs for the current Firebase nullability fixes and verifies those APIs still work with the broadened contracts. The lane is binding-focused: configuration-dependent Firebase errors are acceptable as long as the managed API crosses into native correctly and does not fail with a binding-layer exception. It currently covers the fixes that are safely exercisable in the E2E app; Cloud Functions is intentionally out of scope here because `emulatorOrigin` and `useFunctionsEmulatorOrigin:` are a separate selector-binding issue. + +1. Pack the Firebase slices used by the validation lane and their dependencies: + +```sh +dotnet tool restore +dotnet tool run dotnet-cake -- --target=nuget --names="Firebase.Analytics,Firebase.AppCheck,Firebase.CloudFirestore,Firebase.CloudMessaging,Firebase.Crashlytics,Firebase.Database,Firebase.PerformanceMonitoring" +``` + +2. Run the simulator validation lane: + +```sh +tools/e2e/run-firebase-foundation.sh --package-dir output --configuration Debug --enable-nullability-validation +``` diff --git a/tools/e2e/run-firebase-foundation.sh b/tools/e2e/run-firebase-foundation.sh new file mode 100755 index 00000000..b49be085 --- /dev/null +++ b/tools/e2e/run-firebase-foundation.sh @@ -0,0 +1,207 @@ +#!/bin/zsh +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: tools/e2e/run-firebase-foundation.sh [--package-dir output] [--configuration Release] [--enable-nullability-validation] +EOF +} + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +project_dir="$repo_root/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E" +project_file="$project_dir/FirebaseFoundationE2E.csproj" +bundle_id="com.googleapisforioscomponents.tests.firebase.e2e" +configuration="Release" +enable_nullability_validation="false" +package_dir="$repo_root/output" +artifacts_dir="$repo_root/tests/E2E/Firebase.Foundation/artifacts" +log_file="$artifacts_dir/firebase-foundation-sim.log" +result_file="$artifacts_dir/firebase-foundation-result.json" +restore_config="$artifacts_dir/NuGet.generated.config" +repo_restore_config="$repo_root/tests/E2E/Firebase.Foundation/NuGet.config" + +while [[ $# -gt 0 ]]; do + case "$1" in + --package-dir) + package_dir="$2" + shift 2 + ;; + --configuration) + configuration="$2" + shift 2 + ;; + --enable-nullability-validation) + enable_nullability_validation="true" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ "$package_dir" != /* ]]; then + package_dir="$repo_root/$package_dir" +fi + +mkdir -p "$artifacts_dir" +: > "$log_file" + +required_packages=( + "AdamE.Firebase.iOS.Core" + "AdamE.Firebase.iOS.Installations" + "AdamE.Firebase.iOS.Analytics" + "AdamE.Google.iOS.GoogleAppMeasurement" + "AdamE.Google.iOS.GoogleDataTransport" + "AdamE.Google.iOS.GoogleUtilities" + "AdamE.Google.iOS.Nanopb" + "AdamE.Google.iOS.PromisesObjC" +) + +msbuild_args=() +if [[ "$enable_nullability_validation" == "true" ]]; then + required_packages+=( + "AdamE.Firebase.iOS.AppCheck" + "AdamE.Firebase.iOS.CloudFirestore" + "AdamE.Firebase.iOS.CloudMessaging" + "AdamE.Firebase.iOS.Crashlytics" + "AdamE.Firebase.iOS.Database" + "AdamE.Firebase.iOS.PerformanceMonitoring" + ) + msbuild_args+=("-p:EnableNullabilityValidation=true") +fi + +for package_name in "${required_packages[@]}"; do + if ! find "$package_dir" -maxdepth 1 -name "${package_name}.*.nupkg" -print -quit | grep -q .; then + echo "Missing package in local feed: $package_name" >&2 + exit 1 + fi +done + +if [[ "$package_dir" == "$repo_root/output" ]]; then + restore_config="$repo_restore_config" +else + cat > "$restore_config" < + + + + + + + + + + + + + + + +EOF +fi + +config_file="$project_dir/GoogleService-Info.plist" +if [[ ! -f "$config_file" ]]; then + echo "Missing Firebase config file: $config_file" >&2 + exit 1 +fi + +echo "Restoring FirebaseFoundationE2E from $package_dir" +dotnet restore "$project_file" --configfile "$restore_config" "${msbuild_args[@]}" + +echo "Building FirebaseFoundationE2E for iOS simulator" +dotnet build "$project_file" \ + --configuration "$configuration" \ + --framework net9.0-ios \ + --no-restore \ + -p:Platform=iPhoneSimulator \ + -p:RuntimeIdentifier=iossimulator-arm64 \ + "${msbuild_args[@]}" + +app_path="$(find "$project_dir/bin" -path "*iPhoneSimulator/$configuration/net9.0-ios/iossimulator-arm64/FirebaseFoundationE2E.app" -print -quit)" +if [[ ! -d "$app_path" ]]; then + echo "Built app not found: $app_path" >&2 + exit 1 +fi + +simulator_udid="${E2E_SIMULATOR_UDID:-}" +if [[ -z "$simulator_udid" ]]; then + simulator_udid="$( + xcrun simctl list devices available | + sed -nE "/iPhone/ { s/.*\\(([0-9A-F-]{36})\\).*/\\1/p; q; }" + )" +fi + +if [[ -z "$simulator_udid" ]]; then + echo "No available iPhone simulator could be found." >&2 + exit 1 +fi + +echo "Using simulator: $simulator_udid" +xcrun simctl boot "$simulator_udid" >/dev/null 2>&1 || true +xcrun simctl bootstatus "$simulator_udid" -b + +log_pid="" +cleanup() { + if [[ -n "$log_pid" ]] && kill -0 "$log_pid" >/dev/null 2>&1; then + kill "$log_pid" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +(xcrun simctl spawn "$simulator_udid" log stream \ + --style compact \ + --level debug \ + --predicate "processImagePath ENDSWITH[c] 'FirebaseFoundationE2E' OR eventMessage CONTAINS[c] 'E2E_STATUS:' OR eventMessage CONTAINS[c] 'E2E_RESULT:'" \ + > "$log_file" 2>&1) & +log_pid="$!" + +echo "Installing app" +xcrun simctl uninstall "$simulator_udid" "$bundle_id" >/dev/null 2>&1 || true +xcrun simctl install "$simulator_udid" "$app_path" + +echo "Launching app" +xcrun simctl launch --terminate-running-process "$simulator_udid" "$bundle_id" >> "$log_file" 2>&1 + +data_container="$(xcrun simctl get_app_container "$simulator_udid" "$bundle_id" data)" +container_result_file="$data_container/Library/Caches/firebase-foundation-e2e-result.json" + +timeout_seconds="${E2E_TIMEOUT_SECONDS:-90}" +elapsed=0 +while [[ ! -f "$container_result_file" ]]; do + if (( elapsed >= timeout_seconds )); then + echo "Timed out waiting for E2E result file: $container_result_file" >&2 + exit 1 + fi + + sleep 2 + elapsed=$((elapsed + 2)) +done + +cp "$container_result_file" "$result_file" + +success="$( + /usr/bin/plutil -extract Success raw -o - "$result_file" 2>/dev/null || true +)" + +echo "E2E result file: $result_file" +cat "$result_file" + +if [[ "$success" == "true" ]]; then + echo + echo "Firebase foundation E2E passed." + xcrun simctl terminate "$simulator_udid" "$bundle_id" >/dev/null 2>&1 || true + exit 0 +fi + +echo +echo "Firebase foundation E2E failed." >&2 +xcrun simctl terminate "$simulator_udid" "$bundle_id" >/dev/null 2>&1 || true +exit 1