From 57cf86a286d84190c1acf2b225027462f9be2609 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Fri, 10 Apr 2026 04:09:23 -0400 Subject: [PATCH] Fix ABTesting UpdateExperiments overflow policy binding --- source/Firebase/ABTesting/ApiDefinition.cs | 2 +- source/Firebase/ABTesting/Enums.cs | 8 +- .../FirebaseRuntimeDriftCases.cs | 123 ++++++++++++++++++ .../runtime-drift-cases.json | 11 ++ tools/e2e/run-firebase-foundation.sh | 16 ++- 5 files changed, 156 insertions(+), 4 deletions(-) diff --git a/source/Firebase/ABTesting/ApiDefinition.cs b/source/Firebase/ABTesting/ApiDefinition.cs index d076c1baf..ee25c0c81 100644 --- a/source/Firebase/ABTesting/ApiDefinition.cs +++ b/source/Firebase/ABTesting/ApiDefinition.cs @@ -19,7 +19,7 @@ interface ExperimentController { // -(void)updateExperimentsWithServiceOrigin:(NSString * _Nonnull)origin events:(FIRLifecycleEvents * _Nonnull)events policy:(ABTExperimentPayloadExperimentOverflowPolicy)policy lastStartTime:(NSTimeInterval)lastStartTime payloads:(NSArray * _Nonnull)payloads completionHandler:(void (^ _Nullable)(NSError * _Nullable))completionHandler; [Export ("updateExperimentsWithServiceOrigin:events:policy:lastStartTime:payloads:completionHandler:")] - void UpdateExperiments (string origin, LifecycleEvents events, NSObject policy, double lastStartTime, NSData [] payloads, [NullAllowed] Action completionHandler); + void UpdateExperiments (string origin, LifecycleEvents events, ExperimentPayloadExperimentOverflowPolicy policy, double lastStartTime, NSData [] payloads, [NullAllowed] Action completionHandler); // -(NSTimeInterval)latestExperimentStartTimestampBetweenTimestamp:(NSTimeInterval)timestamp andPayloads:(NSArray * _Nonnull)payloads; [Export ("latestExperimentStartTimestampBetweenTimestamp:andPayloads:")] diff --git a/source/Firebase/ABTesting/Enums.cs b/source/Firebase/ABTesting/Enums.cs index 99d89462b..94baed294 100644 --- a/source/Firebase/ABTesting/Enums.cs +++ b/source/Firebase/ABTesting/Enums.cs @@ -1,4 +1,8 @@ -using System; - namespace Firebase.ABTesting { + public enum ExperimentPayloadExperimentOverflowPolicy : int { + UnrecognizedValue = 999, + Unspecified = 0, + DiscardOldest = 1, + IgnoreNewest = 2 + } } diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs index 3d27fa34c..9b0ec298d 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs @@ -1,5 +1,11 @@ using System.Reflection; +#if ENABLE_RUNTIME_DRIFT_CASE_ABTESTING_UPDATEEXPERIMENTS +using Firebase.ABTesting; +using Foundation; +using ObjCRuntime; +#endif + #if ENABLE_RUNTIME_DRIFT_CASE_ABTESTING_ACTIVATEEXPERIMENT using Firebase.ABTesting; using Foundation; @@ -72,6 +78,123 @@ public static async Task ExecuteConfiguredCaseAsync() ?.Value; } +#if ENABLE_RUNTIME_DRIFT_CASE_ABTESTING_UPDATEEXPERIMENTS + static async Task VerifyABTestingUpdateExperimentsAsync() + { + const string selector = "updateExperimentsWithServiceOrigin:events:policy:lastStartTime:payloads:completionHandler:"; + + var signature = typeof(ExperimentController).GetMethod( + nameof(ExperimentController.UpdateExperiments), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] + { + typeof(string), + typeof(LifecycleEvents), + typeof(ExperimentPayloadExperimentOverflowPolicy), + typeof(double), + typeof(NSData[]), + typeof(Action) + }, + modifiers: null); + if (signature is null) + { + throw new InvalidOperationException( + $"Expected managed API '{nameof(ExperimentController.UpdateExperiments)}(string, {typeof(LifecycleEvents).FullName}, {typeof(ExperimentPayloadExperimentOverflowPolicy).FullName}, double, {typeof(NSData[]).FullName}, {typeof(Action).FullName})' was not found."); + } + + var parameters = signature.GetParameters(); + if (parameters.Length != 6 || parameters[2].ParameterType != typeof(ExperimentPayloadExperimentOverflowPolicy)) + { + throw new InvalidOperationException( + $"Managed signature regression: expected policy parameter type '{typeof(ExperimentPayloadExperimentOverflowPolicy).FullName}', observed '{parameters.ElementAtOrDefault(2)?.ParameterType.FullName ?? ""}'."); + } + + var controller = ExperimentController.SharedInstance; + if (controller is null) + { + throw new InvalidOperationException("Firebase.ABTesting.ExperimentController.SharedInstance returned null after App.Configure()."); + } + + var events = new LifecycleEvents + { + SetExperimentEventName = new NSString("codex_set_experiment"), + ActivateExperimentEventName = new NSString("codex_activate_experiment"), + ClearExperimentEventName = new NSString("codex_clear_experiment"), + TimeoutExperimentEventName = new NSString("codex_timeout_experiment"), + ExpireExperimentEventName = new NSString("codex_expire_experiment"), + }; + var policy = ExperimentPayloadExperimentOverflowPolicy.DiscardOldest; + var payloads = Array.Empty(); + var completionInvoked = false; + NSError? completionError = null; + NSException? marshaledException = null; + MarshalObjectiveCExceptionMode? marshaledExceptionMode = null; + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnMarshalObjectiveCException(object? sender, MarshalObjectiveCExceptionEventArgs args) + { + marshaledException ??= args.Exception; + marshaledExceptionMode ??= args.ExceptionMode; + } + + Runtime.MarshalObjectiveCException += OnMarshalObjectiveCException; + try + { + try + { + controller.UpdateExperiments("codex", events, policy, -1, payloads, error => + { + completionInvoked = true; + completionError = error; + completionSource.TrySetResult(true); + }); + } + catch (ObjCException ex) + { + throw new InvalidOperationException( + $"Selector '{selector}' should not throw with the corrected enum binding, but observed {ex.GetType().FullName}. " + + $"Managed policy argument type: {policy.GetType().FullName}. Policy value: {(int)policy}. " + + $"Payload array type: {payloads.GetType().FullName}. Payload count: {payloads.Length}. " + + $"NSException.Name: {FormatDetail(marshaledException?.Name?.ToString())}. " + + $"NSException.Reason: {FormatDetail(marshaledException?.Reason)}. " + + $"Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}.", + ex); + } + + var completedTask = await Task.WhenAny(completionSource.Task, Task.Delay(AsyncTimeout)); + if (completedTask != completionSource.Task) + { + throw new TimeoutException( + $"Selector '{selector}' did not invoke its completion callback within {AsyncTimeout.TotalSeconds} seconds."); + } + + if (!completionInvoked) + { + throw new InvalidOperationException( + $"Selector '{selector}' completed without throwing, but the completion callback was never marked as invoked."); + } + + if (marshaledException is not null) + { + throw new InvalidOperationException( + $"Selector '{selector}' completed, but Runtime.MarshalObjectiveCException captured unexpected NSException.Name '{marshaledException.Name}'. " + + $"Reason: {FormatDetail(marshaledException.Reason)}. Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}."); + } + + return + $"Selector '{selector}' completed without ObjC exception. " + + $"Managed policy argument type: {parameters[2].ParameterType.FullName}. Policy value: {(int)policy}. " + + $"Payload array type: {payloads.GetType().FullName}. Payload count: {payloads.Length}. " + + $"CompletionInvoked: {completionInvoked}. CompletionError: {FormatDetail(completionError?.LocalizedDescription)}."; + } + finally + { + Runtime.MarshalObjectiveCException -= OnMarshalObjectiveCException; + } + } +#endif + #if ENABLE_RUNTIME_DRIFT_CASE_ABTESTING_ACTIVATEEXPERIMENT static Task VerifyABTestingActivateExperimentAsync() { diff --git a/tests/E2E/Firebase.Foundation/runtime-drift-cases.json b/tests/E2E/Firebase.Foundation/runtime-drift-cases.json index 814845257..d50956705 100644 --- a/tests/E2E/Firebase.Foundation/runtime-drift-cases.json +++ b/tests/E2E/Firebase.Foundation/runtime-drift-cases.json @@ -1,5 +1,16 @@ { "cases": [ + { + "id": "abtesting-updateexperiments", + "method": "VerifyABTestingUpdateExperimentsAsync", + "bindingPackage": "AdamE.Firebase.iOS.ABTesting", + "packages": [ + { + "id": "AdamE.Firebase.iOS.ABTesting", + "version": "12.6.0" + } + ] + }, { "id": "abtesting-activateexperiment", "method": "VerifyABTestingActivateExperimentAsync", diff --git a/tools/e2e/run-firebase-foundation.sh b/tools/e2e/run-firebase-foundation.sh index 7e703fcb2..e74793275 100755 --- a/tools/e2e/run-firebase-foundation.sh +++ b/tools/e2e/run-firebase-foundation.sh @@ -20,6 +20,7 @@ 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" +packages_cache_dir="$artifacts_dir/packages" runtime_drift_manifest="$repo_root/tests/E2E/Firebase.Foundation/runtime-drift-cases.json" runtime_drift_props="$artifacts_dir/runtime-drift-case.generated.props" runtime_drift_info="$artifacts_dir/runtime-drift-case.info" @@ -63,6 +64,9 @@ fi mkdir -p "$artifacts_dir" : > "$log_file" +rm -rf "$packages_cache_dir" +mkdir -p "$packages_cache_dir" + if [[ "$enable_nullability_validation" == "true" && -n "$runtime_drift_case" ]]; then echo "--enable-nullability-validation and --runtime-drift-case cannot be used together." >&2 exit 1 @@ -80,6 +84,7 @@ required_packages=( ) msbuild_args=() +restore_args=() if [[ "$enable_nullability_validation" == "true" ]]; then required_packages+=( "AdamE.Firebase.iOS.AppCheck" @@ -157,6 +162,7 @@ PY "-p:RuntimeDriftCaseMethod=$runtime_drift_method" "-p:RuntimeDriftCasePropsPath=$runtime_drift_props" ) + restore_args+=("--force-evaluate") echo "Runtime drift case: $runtime_drift_case ($runtime_drift_binding_package)" fi @@ -198,7 +204,15 @@ if [[ ! -f "$config_file" ]]; then fi echo "Restoring FirebaseFoundationE2E from $package_dir" -dotnet restore "$project_file" --configfile "$restore_config" "${msbuild_args[@]}" +dotnet restore "$project_file" --configfile "$restore_config" --packages "$packages_cache_dir" "${restore_args[@]}" "${msbuild_args[@]}" + +echo "Cleaning FirebaseFoundationE2E for iOS simulator" +dotnet clean "$project_file" \ + --configuration "$configuration" \ + --framework net9.0-ios \ + -p:Platform=iPhoneSimulator \ + -p:RuntimeIdentifier=iossimulator-arm64 \ + "${msbuild_args[@]}" echo "Building FirebaseFoundationE2E for iOS simulator" dotnet build "$project_file" \