diff --git a/.github/workflows/google-foundation-validation.yml b/.github/workflows/google-foundation-validation.yml new file mode 100644 index 00000000..0781dc8d --- /dev/null +++ b/.github/workflows/google-foundation-validation.yml @@ -0,0 +1,97 @@ +name: Google Foundation Validation + +on: + pull_request: + paths: + - ".github/workflows/google-foundation-validation.yml" + - ".config/dotnet-tools.json" + - ".gitignore" + - "Directory.Build.props" + - "global.json" + - "Xamarin.Google.sln" + - "*.cake" + - "icons/googleiosplaces_128x128.png" + - "source/AssemblyInfo.cs" + - "source/Google/Places/**" + - "tests/E2E/Google.Foundation/**" + - "tools/e2e/check-consumer-shapes.sh" + - "tools/e2e/run-google-foundation.sh" + push: + branches: + - main + paths: + - ".github/workflows/google-foundation-validation.yml" + - ".config/dotnet-tools.json" + - ".gitignore" + - "Directory.Build.props" + - "global.json" + - "Xamarin.Google.sln" + - "*.cake" + - "icons/googleiosplaces_128x128.png" + - "source/AssemblyInfo.cs" + - "source/Google/Places/**" + - "tests/E2E/Google.Foundation/**" + - "tools/e2e/check-consumer-shapes.sh" + - "tools/e2e/run-google-foundation.sh" + workflow_dispatch: + +permissions: + contents: read + +jobs: + places: + runs-on: macos-26 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode 26.6 + run: | + sudo xcode-select --switch /Applications/Xcode_26.6.app/Contents/Developer + xcodebuild -version + xcode-select -p + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Install .NET iOS workload + run: | + dotnet workload install ios + dotnet workload list + + - name: Install CocoaPods + run: | + if ! command -v pod >/dev/null 2>&1; then + sudo gem install cocoapods + fi + pod --version + + - name: Restore tools + run: dotnet tool restore + + - name: Pack Google.Places + run: dotnet tool run dotnet-cake -- --target=nuget --names=Google.Places + + - name: Direct and transitive consumer checks + run: >- + tools/e2e/check-consumer-shapes.sh + --target Places + --package-dir output + + - name: Simulator E2E + run: >- + tools/e2e/run-google-foundation.sh + --target Places + --package-dir output + --configuration Debug + + - name: Upload diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: google-foundation-validation-diagnostics + path: tests/E2E/Google.Foundation/artifacts/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index e7b2459f..2f5d3b00 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ tools/* !tools/e2e/ tools/e2e/* !tools/e2e/run-firebase-foundation.sh +!tools/e2e/run-google-foundation.sh +!tools/e2e/check-consumer-shapes.sh tmp-nugets *.userprefs *.DS_Store diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/AppDelegate.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/AppDelegate.cs new file mode 100644 index 00000000..6d73f20e --- /dev/null +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/AppDelegate.cs @@ -0,0 +1,26 @@ +using Foundation; +using UIKit; + +namespace GoogleFoundationE2E; + +[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(); + + _ = GoogleSelfTestRunner.RunAsync(statusViewController); + + return true; + } +} diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/E2EContracts.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/E2EContracts.cs new file mode 100644 index 00000000..1bd7f631 --- /dev/null +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/E2EContracts.cs @@ -0,0 +1,23 @@ +namespace GoogleFoundationE2E; + +public sealed class GoogleE2ERunResult +{ + public string BundleId { get; set; } = string.Empty; + public string Target { get; set; } = string.Empty; + public string? PackageVersion { 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 GoogleE2ETestCaseResult +{ + 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/Google.Foundation/GoogleFoundationE2E/E2ELogger.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/E2ELogger.cs new file mode 100644 index 00000000..6703eed2 --- /dev/null +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/E2ELogger.cs @@ -0,0 +1,9 @@ +namespace GoogleFoundationE2E; + +public static class E2ELogger +{ + public static void WriteLine(string message) + { + Console.WriteLine(message); + } +} diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj new file mode 100644 index 00000000..7899fa4b --- /dev/null +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj @@ -0,0 +1,64 @@ + + + + net10.0-ios + iossimulator-arm64 + + Exe + enable + true + 15.0 + GoogleFoundationE2E + GoogleFoundationE2E + Resources + AnyCPU;iPhoneSimulator + Debug;Release + false + + + + + Places + 7.4.0.2 + + + + iPhoneSimulator + + + + iossimulator-arm64 + + + + None + + + + $(DefineConstants);ENABLE_TARGET_PLACES + + + + + + + + + <_Parameter1>GoogleE2ETarget + <_Parameter2>$(GoogleE2ETarget) + + + + <_Parameter1>TargetPackageVersion + <_Parameter2>$(PlacesPackageVersion) + + + diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs new file mode 100644 index 00000000..8b0d367e --- /dev/null +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text.Json; +using Foundation; + +namespace GoogleFoundationE2E; + +public static class GoogleSelfTestRunner +{ + const string ResultFileName = "google-foundation-e2e-result.json"; + + public static async Task RunAsync(StatusViewController statusViewController) + { + var target = ReadAssemblyMetadata("GoogleE2ETarget") ?? "Places"; + + var result = new GoogleE2ERunResult + { + BundleId = NSBundle.MainBundle.BundleIdentifier ?? string.Empty, + Target = target, + PackageVersion = ReadAssemblyMetadata("TargetPackageVersion"), + StartedAtUtc = DateTimeOffset.UtcNow, + }; + + await statusViewController.AppendLineAsync("Target: " + target); + await statusViewController.AppendLineAsync("Package version: " + (result.PackageVersion ?? "unknown")); + + try + { + // The trimmer warns (IL2045) that it may strip AssemblyMetadataAttribute. It currently + // does not, but if that ever changes the version would silently read as null and an A/B + // comparison between two package versions would look valid while comparing one package + // against itself. Refuse to run rather than produce a result that cannot be trusted. + if (string.IsNullOrWhiteSpace(result.PackageVersion)) + { + throw new InvalidOperationException( + "TargetPackageVersion assembly metadata is missing, so this run cannot report " + + "which package version it exercised. Results would not be trustworthy."); + } + + switch (target) + { +#if ENABLE_TARGET_PLACES + case "Places": + await PlacesCases.RunAsync(result, statusViewController, ExecuteCaseAsync); + break; +#endif + default: + throw new NotSupportedException( + $"No E2E cases are compiled in for target '{target}'. " + + "Build with -p:GoogleE2ETarget=."); + } + } + 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.Count > 0 + && 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 Google E2E checks passed." : "Google E2E checks failed."); + await statusViewController.AppendLineAsync("Result file: " + resultFilePath); + + E2ELogger.WriteLine("E2E_RESULT:" + compactJson); + E2ELogger.WriteLine("E2E_STATUS:" + (result.Success ? "PASS" : "FAIL")); + } + + public static async Task ExecuteCaseAsync( + GoogleE2ERunResult result, + StatusViewController statusViewController, + string name, + Func> testCase) + { + await statusViewController.AppendLineAsync(string.Empty); + await statusViewController.AppendLineAsync("Running " + name + "..."); + + var caseResult = new GoogleE2ETestCaseResult + { + 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(); + E2ELogger.WriteLine($"FAIL {name}: {ex}"); + await statusViewController.AppendLineAsync("FAIL " + name + ": " + ex.Message); + } + finally + { + stopwatch.Stop(); + caseResult.DurationMs = stopwatch.ElapsedMilliseconds; + result.Cases.Add(caseResult); + } + } + + static string? ReadAssemblyMetadata(string key) => + typeof(GoogleSelfTestRunner).Assembly + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == key)?.Value; + + 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/Google.Foundation/GoogleFoundationE2E/Info.plist b/tests/E2E/Google.Foundation/GoogleFoundationE2E/Info.plist new file mode 100644 index 00000000..8242cb65 --- /dev/null +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/Info.plist @@ -0,0 +1,14 @@ + + + + + CFBundleDisplayName + Google E2E + CFBundleIdentifier + com.googleapisforioscomponents.tests.googlefoundatione2e + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/Main.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/Main.cs new file mode 100644 index 00000000..6a4e72f3 --- /dev/null +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/Main.cs @@ -0,0 +1,4 @@ +using UIKit; +using GoogleFoundationE2E; + +UIApplication.Main(args, null, typeof(AppDelegate)); diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/PlacesCases.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/PlacesCases.cs new file mode 100644 index 00000000..a4fedf82 --- /dev/null +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/PlacesCases.cs @@ -0,0 +1,171 @@ +#if ENABLE_TARGET_PLACES +using System.Runtime.InteropServices; +using CoreLocation; +using Foundation; +using Google.Places; +using ObjCRuntime; + +namespace GoogleFoundationE2E; + +/// +/// Delivery-focused checks for AdamE.Google.iOS.Places. +/// +/// These validate that the native SDK reached the app bundle and linked, not that the Places +/// backend works. Every check is keyless on purpose: CI must not need an API key. Native or +/// configuration errors are acceptable; binding-layer failures are not. +/// +public static class PlacesCases +{ + const string BundleName = "GooglePlaces"; + const int ExpectedBundleFileCount = 59; + + public static async Task RunAsync( + GoogleE2ERunResult result, + StatusViewController status, + Func>, Task> execute) + { + await execute(result, status, "places-managed-binding-loads", VerifyManagedBindingLoadsAsync); + await execute(result, status, "places-pinvoke-linkage", VerifyPInvokeLinkageAsync); + await execute(result, status, "places-objc-class-lookup", VerifyObjCClassLookupAsync); + await execute(result, status, "places-resource-bundle-present", VerifyResourceBundlePresentAsync); + await execute(result, status, "places-resource-bundle-contents", VerifyResourceBundleContentsAsync); + } + + /// + /// Loads a type from the managed binding assembly. Beyond checking the assembly shipped, this + /// keeps a hard reference to it: an app that never touches the binding lets the trimmer drop the + /// assembly, and with it the native payload, which would make every check below fail for a + /// reason that has nothing to do with packaging. + /// + static Task VerifyManagedBindingLoadsAsync() + { + var type = typeof(AutocompleteFilter); + var name = type.Assembly.GetName(); + + return Task.FromResult($"{type.FullName} loaded from {name.Name} {name.Version}."); + } + + /// + /// Resolves a plain C symbol out of the GooglePlaces static framework. The symbol only exists in + /// the app binary if the xcframework actually linked, so EntryPointNotFoundException is the + /// precise signature of a delivery failure. + /// + /// This declares its own P/Invoke rather than calling the binding's + /// AutocompleteFilter.PlaceRectangularLocationOption, because that binding declares an NSObject + /// return type, which the marshaller rejects with MarshalDirectiveException while generating the + /// IL stub -- before the native symbol is ever looked up. Routing through it would make this case + /// pass without proving anything. (That binding bug predates the packaging change and reproduces + /// on the XBD-delivered package; it is tracked separately.) + /// + [DllImport("__Internal", EntryPoint = "GMSPlaceRectangularLocationOption")] + static extern IntPtr GMSPlaceRectangularLocationOption( + CLLocationCoordinate2D northEastBounds, + CLLocationCoordinate2D southWestBounds); + + static Task VerifyPInvokeLinkageAsync() + { + var northEast = new CLLocationCoordinate2D(37.785, -122.395); + var southWest = new CLLocationCoordinate2D(37.775, -122.415); + + try + { + var handle = GMSPlaceRectangularLocationOption(northEast, southWest); + return Task.FromResult( + $"GMSPlaceRectangularLocationOption resolved and returned handle 0x{handle:x}."); + } + catch (EntryPointNotFoundException ex) + { + throw new InvalidOperationException( + "GMSPlaceRectangularLocationOption did not resolve, so the GooglePlaces native library " + + "was not linked into the app. This is a package delivery failure.", ex); + } + catch (DllNotFoundException ex) + { + throw new InvalidOperationException( + "The __Internal native library was not found, so the GooglePlaces static framework " + + "was not linked into the app. This is a package delivery failure.", ex); + } + } + + /// + /// Proves the Objective-C classes are present in the loaded image, which is what ForceLoad and + /// the -ObjC linker flag exist to guarantee. Pure runtime lookup: no SDK initialization, no key. + /// + static Task VerifyObjCClassLookupAsync() + { + string[] expectedClasses = ["GMSPlacesClient", "GMSPlace", "GMSAutocompleteFilter"]; + + var missing = expectedClasses + .Where(name => (IntPtr)Class.GetHandle(name) == IntPtr.Zero) + .ToArray(); + + if (missing.Length > 0) + { + throw new InvalidOperationException( + "Objective-C classes missing from the loaded image: " + string.Join(", ", missing) + + ". ForceLoad or the -ObjC linker flag did not survive packaging."); + } + + return Task.FromResult($"Resolved {expectedClasses.Length} Objective-C classes: {string.Join(", ", expectedClasses)}."); + } + + /// + /// GooglePlaces.xcframework is static, so its internal Resources are not copied automatically. + /// The package has to place GooglePlaces.bundle at the app root itself. This is the check with + /// no build-time failure mode: get it wrong and the SDK silently loses its assets and strings. + /// + static Task VerifyResourceBundlePresentAsync() + { + var bundlePath = NSBundle.MainBundle.PathForResource(BundleName, "bundle"); + + if (string.IsNullOrWhiteSpace(bundlePath) || !Directory.Exists(bundlePath)) + { + throw new InvalidOperationException( + $"{BundleName}.bundle was not found in the app bundle. The package did not deliver its " + + "BundleResource items."); + } + + return Task.FromResult($"{BundleName}.bundle resolved at {bundlePath}."); + } + + static Task VerifyResourceBundleContentsAsync() + { + var bundlePath = NSBundle.MainBundle.PathForResource(BundleName, "bundle"); + + if (string.IsNullOrWhiteSpace(bundlePath)) + { + throw new InvalidOperationException($"{BundleName}.bundle was not found in the app bundle."); + } + + // A spot check across the three content shapes in the bundle: a loose data file, a + // localized strings file, and an image asset. + string[] expectedFiles = + [ + "oss_licenses_places.txt.gz", + Path.Combine("en.lproj", "GooglePlaces.strings"), + "sad_cloud@2x.png", + ]; + + var missing = expectedFiles + .Where(relative => !File.Exists(Path.Combine(bundlePath, relative))) + .ToArray(); + + if (missing.Length > 0) + { + throw new InvalidOperationException( + $"{BundleName}.bundle is present but missing expected files: " + string.Join(", ", missing)); + } + + var fileCount = Directory.GetFiles(bundlePath, "*", SearchOption.AllDirectories).Length; + + if (fileCount != ExpectedBundleFileCount) + { + throw new InvalidOperationException( + $"{BundleName}.bundle contains {fileCount} files; expected the " + + $"{ExpectedBundleFileCount}-file Places 7.4.0 delivery baseline."); + } + + return Task.FromResult($"{BundleName}.bundle contains {fileCount} files including all {expectedFiles.Length} spot-checked entries."); + } +} +#endif diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/StatusViewController.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/StatusViewController.cs new file mode 100644 index 00000000..7d5f5685 --- /dev/null +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/StatusViewController.cs @@ -0,0 +1,46 @@ +using UIKit; + +namespace GoogleFoundationE2E; + +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 = "Google 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/Google.Foundation/NuGet.config b/tests/E2E/Google.Foundation/NuGet.config new file mode 100644 index 00000000..b5fe063c --- /dev/null +++ b/tests/E2E/Google.Foundation/NuGet.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/E2E/Google.Foundation/README.md b/tests/E2E/Google.Foundation/README.md new file mode 100644 index 00000000..05fcadcd --- /dev/null +++ b/tests/E2E/Google.Foundation/README.md @@ -0,0 +1,110 @@ +# Google NuGet runtime harness + +This consumer-style iOS simulator app validates the runtime delivery behavior of Google binding +NuGet packages produced by this repository. It answers whether the managed binding loads, its native +SDK links and loads, and required resources reach the built app. + +The checks are keyless. Native service, backend, or configuration failures may be acceptable, while +binding-layer failures such as `EntryPointNotFoundException`, `DllNotFoundException`, +`TypeLoadException`, or `ObjCException` indicate a delivery problem. + +## Current scope and baseline + +The sole runtime adapter is `AdamE.Google.iOS.Places` 7.4.0.2. + +That package is the pre-migration behavioral baseline: it uses `Xamarin.Build.Download` to fetch the +Google Places SDK during the consumer build. The harness deliberately does not assert how the native +SDK arrives. It asserts the resulting consumer behavior, so the same checks can be run unchanged +before and after a package-delivery refactor. + +The Places adapter verifies: + +- `Google.Places.AutocompleteFilter` loads from the restored binding assembly. +- `GMSPlaceRectangularLocationOption` resolves from the linked app binary. +- `GMSPlacesClient`, `GMSPlace`, and `GMSAutocompleteFilter` are present in the Objective-C runtime. +- `GooglePlaces.bundle` is present at the app root. +- The bundle contains the baseline's 59 files, including representative data, localized string, and + image files. + +This harness does not inspect the `.nupkg`, prove offline builds, compare stored baselines, exercise a +physical device, or test the Places backend. Those concerns belong in separate package-delivery +checks when the package format changes. + +## Run it + +Pack Places, then launch the runtime checks on an available iPhone simulator: + +```sh +dotnet tool restore +dotnet tool run dotnet-cake -- --target=nuget --names=Google.Places +tools/e2e/run-google-foundation.sh --target Places --package-dir output +``` + +If the package directory contains exactly one `AdamE.Google.iOS.Places.*.nupkg`, the runner derives +and records its version. Zero or multiple matching packages are rejected so a run cannot silently +exercise the wrong artifact. + +When the directory contains multiple versions, select one explicitly: + +```sh +tools/e2e/run-google-foundation.sh \ + --target Places \ + --package-dir output \ + --package-version 7.4.0.2 +``` + +`NuGet.config` maps `AdamE.*` packages to the repository's local `output/` feed. For another package +directory, the runner generates an equivalent restore configuration under the ignored `artifacts/` +directory. + +Set `E2E_SIMULATOR_UDID` to choose a simulator. Otherwise, the runner uses the first available iPhone +simulator. `E2E_TIMEOUT_SECONDS` changes the default 90-second result timeout. + +## Results and diagnostics + +The app writes a structured result that includes the selected target and requested NuGet version. +The runner copies it to: + +```text +tests/E2E/Google.Foundation/artifacts/result.json +``` + +The same ignored directory contains the simulator log and target-neutral build diagnostics: + +- `simulator.log` +- `manifest-iossimulator-arm64/app-files.txt` +- `manifest-iossimulator-arm64/native-symbols.txt` +- `manifest-iossimulator-arm64/linked-libraries.txt` +- `manifest-iossimulator-arm64/llvm-segment-count.txt` + +These files aid failure investigation; they are not golden baselines. + +## Adding another Google package + +The host app is reusable, while each binding needs an explicit runtime adapter. Add a target by: + +1. Adding its version property, conditional compilation symbol, `PackageReference`, and + `TargetPackageVersion` metadata to `GoogleFoundationE2E.csproj`. +2. Adding a case class with keyless, package-specific checks and routing it from + `GoogleSelfTestRunner`. +3. Mapping the target to its package ID and version property in `run-google-foundation.sh`. + +A useful adapter normally includes a managed type reference so trimming cannot discard the binding, +then proves native delivery with an exported symbol or Objective-C class lookup. Resource checks must +match the SDK's actual form: static frameworks may need an app-root bundle, while dynamic frameworks +can carry bundles inside `App.app/Frameworks`. + +## Target framework and Xcode + +The harness targets `net10.0-ios`, matching the repository's current packages. `global.json` pins the +workload set used by the repository, and .NET for iOS enforces the compatible Xcode major/minor +version. + +For a deliberate local toolchain experiment, `--allow-xcode-mismatch` passes +`ValidateXcodeVersion=false`. CI and trusted runtime validation should use the matching Xcode instead. + +## Bundle identifier + +The test app uses the generic public identifier +`com.googleapisforioscomponents.tests.googlefoundatione2e`. The runner reads it from `Info.plist` for +simulator install, launch, and result collection, so it is not duplicated in shell code. diff --git a/tools/e2e/check-consumer-shapes.sh b/tools/e2e/check-consumer-shapes.sh new file mode 100755 index 00000000..77c3ac5d --- /dev/null +++ b/tools/e2e/check-consumer-shapes.sh @@ -0,0 +1,414 @@ +#!/bin/zsh +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: tools/e2e/check-consumer-shapes.sh --target [options] + + --package-dir Local NuGet feed (default: output) + --package-version Exact package version (required when the feed contains multiple) + --allow-xcode-mismatch Pass ValidateXcodeVersion=false (local escape hatch, not for CI) + +Scaffolds throwaway consumer projects in a temp directory and builds them against the packed +package, to check that native payload and resources reach the app through more than the simplest +project shape. + +Shapes covered: + direct app -> package + library app -> class library -> package (exercises buildTransitive/) + +A binding package's .targets historically gated its items on OutputType != 'Library', so the +library shape is where transitive delivery quietly breaks. + +Targets differ in how the native payload arrives, and the assertions follow that: + Places static xcframework -- bundle unpacked to the app root, symbol linked into the app binary + SignIn dynamic xcframework -- .framework copied to App.app/Frameworks with its bundle inside +EOF +} + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +target="Places" +package_version="" +package_dir="$repo_root/output" +allow_xcode_mismatch="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) target="$2"; shift 2 ;; + --package-version) package_version="$2"; shift 2 ;; + --package-dir) package_dir="$2"; shift 2 ;; + --allow-xcode-mismatch) allow_xcode_mismatch="true"; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;; + esac +done + +[[ "$package_dir" != /* ]] && package_dir="$repo_root/$package_dir" + +case "$target" in + Places) + package_id="AdamE.Google.iOS.Places" + # GooglePlaces.xcframework is a static framework: the SDK links it into the app binary and + # does not copy any .framework directory, so the package has to place the bundle at the app root. + delivery="static" + resource_bundle="GooglePlaces.bundle" + managed_probe_type="Google.Places.AutocompleteFilter" + lib_project="PlacesLib" + probe_symbol="GMSPlaceRectangularLocationOption" + expected_resource_file_count=59 + # A trivial app that never reaches a native entry point lets the linker drop the static + # library, which is correct behaviour and not a delivery failure. Call into the native SDK so + # the symbol assertion below actually measures whether the package delivered it. + native_probe_decl='[System.Runtime.InteropServices.DllImport("__Internal", EntryPoint = "GMSPlaceRectangularLocationOption")] + static extern System.IntPtr NativeProbe(CoreLocation.CLLocationCoordinate2D ne, CoreLocation.CLLocationCoordinate2D sw);' + native_probe_call='System.Console.WriteLine(NativeProbe(new CoreLocation.CLLocationCoordinate2D(1, 1), new CoreLocation.CLLocationCoordinate2D(0, 0)));' + ;; + SignIn) + package_id="AdamE.Google.iOS.SignIn" + # GoogleSignIn.xcframework is a dynamic framework (MH_DYLIB, install name + # @rpath/GoogleSignIn.framework/GoogleSignIn). The SDK copies the whole .framework into + # App.app/Frameworks, and GoogleSignIn.bundle rides along inside it -- so the bundle is never + # expected at the app root, and the native code is never linked into the app binary. + delivery="dynamic" + framework_dir="GoogleSignIn.framework" + framework_binary="GoogleSignIn" + resource_bundle="GoogleSignIn.bundle" + managed_probe_type="Google.SignIn.SignIn" + lib_project="SignInLib" + # Touching the managed type is enough here: the framework is a native reference, so the SDK + # copies it whenever the binding assembly survives the linker. There is no DllImport probe + # because a dynamic framework's symbols live in the dylib, not in "__Internal". + native_probe_decl="" + native_probe_call="" + expected_resource_file_count="" + ;; + *) echo "Unknown target: $target" >&2; exit 1 ;; +esac + +if [[ -n "$package_version" ]]; then + nupkg="$package_dir/$package_id.$package_version.nupkg" + if [[ ! -f "$nupkg" ]]; then + echo "No $package_id $package_version package found at $nupkg" >&2 + exit 1 + fi +else + candidates=("$package_dir"/"$package_id".*.nupkg(N)) + nupkgs=() + for candidate in "${candidates[@]}"; do + [[ "$candidate" == *.symbols.nupkg ]] && continue + nupkgs+=("$candidate") + done + + case ${#nupkgs[@]} in + 0) + echo "No $package_id package found in $package_dir" >&2 + exit 1 + ;; + 1) + nupkg="${nupkgs[1]}" + package_version="${${nupkg:t}#$package_id.}" + package_version="${package_version%.nupkg}" + ;; + *) + echo "Multiple $package_id packages found in $package_dir; pass --package-version:" >&2 + printf ' %s\n' "${nupkgs[@]:t}" >&2 + exit 1 + ;; + esac +fi +echo "Testing $package_id $package_version from $nupkg" + +msbuild_args=() +[[ "$allow_xcode_mismatch" == "true" ]] && msbuild_args+=("-p:ValidateXcodeVersion=false") + +failures=0 +pass() { print -r -- " PASS $1"; } +fail() { print -r -- " FAIL $1" >&2; failures=$((failures + 1)); } + +# Resolve the symlink: macOS mktemp -d returns /var/folders/... which is a symlink to +# /private/var/folders/.... NuGet compares the two, silently drops the ProjectReference edge, +# and the library shape below fails for a reason that has nothing to do with packaging. +work="$(cd "$(mktemp -d)" && pwd -P)" +# Keep the selected local package isolated from any same-version copy in the user's global cache. +# This makes the check prove the nupkg named above rather than whichever copy NuGet restored first. +export NUGET_PACKAGES="$work/packages" +mkdir -p "$NUGET_PACKAGES" +# The scaffold lives outside the repository tree, so give its projects the same pinned SDK and +# workload set that packed the selected artifact. Without this, a fresh CI host can install the +# pinned iOS workload successfully and then resolve the consumer against a different workload set. +cp "$repo_root/global.json" "$work/global.json" +# Keep the scaffold when something fails -- these are throwaway projects, but a failure is not +# diagnosable without the build logs and the produced .app. +cleanup() { + if (( failures > 0 )); then + print -r -- "Scaffold kept for inspection: $work" >&2 + else + rm -rf "$work" + fi +} +trap cleanup EXIT + +cat > "$work/NuGet.config" < + + + + + + + + + + + + + + + +EOF + +app_props=' + net10.0-ios + Exe + enable + 15.0 + iossimulator-arm64 + iPhoneSimulator + false + manual' + +# $2 is an expression that touches the binding's managed surface. Without it the app never +# references the binding assembly, the linker is free to drop it, and the native payload never has +# to be linked -- which would make the symbol assertion below fail for reasons unrelated to +# packaging. +write_app_sources() { + local dir="$1" probe_expr="$2" + mkdir -p "$dir" + cat > "$dir/Main.cs" <<'EOF' +using UIKit; +UIApplication.Main(args, null, typeof(ShapeProbe.AppDelegate)); +EOF + cat > "$dir/AppDelegate.cs" < "$dir/Info.plist" <<'EOF' + + + + + CFBundleIdentifier + com.googleapisforioscomponents.tests.shapeprobe + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + +EOF +} + +# Reports a check that is expected to fail today because of a defect that predates this harness. +# Visible in the output, but does not fail the run -- otherwise the only way to keep CI green would +# be to delete the check, and the gap would stop being visible at all. +known_gaps=0 +known_gap() { print -r -- " KNOWN $1"; known_gaps=$((known_gaps + 1)); } + +# $3: pass "known-gap" to downgrade this shape's failures to warnings. +assert_app() { + local shape="$1" app_path="$2" mode="${3:-strict}" + local report=fail + [[ "$mode" == "known-gap" ]] && report=known_gap + + if [[ -z "$app_path" || ! -d "$app_path" ]]; then + $report "$shape: app bundle was not produced" + return + fi + + # Read the executable name from Info.plist rather than deriving it from the bundle name. + local exe_name binary + exe_name="$(/usr/bin/plutil -extract CFBundleExecutable raw -o - "$app_path/Info.plist" 2>/dev/null || true)" + [[ -z "$exe_name" ]] && exe_name="$(basename "${app_path%.app}")" + binary="$app_path/$exe_name" + + if [[ ! -f "$binary" ]]; then + $report "$shape: app binary not found at $binary (bundle contains: $(ls "$app_path" | tr '\n' ' '))" + return + fi + + if [[ "$delivery" == "dynamic" ]]; then + # The framework is copied whole into App.app/Frameworks, so the bundle is expected inside it + # rather than at the app root, and the native symbols stay in the dylib. + local fw="$app_path/Frameworks/$framework_dir" + + if [[ -d "$fw" ]]; then + pass "$shape: $framework_dir present in App.app/Frameworks" + else + $report "$shape: $framework_dir missing from App.app/Frameworks" + return + fi + + if [[ -f "$fw/$framework_binary" ]]; then + pass "$shape: $framework_dir/$framework_binary delivered ($(file -b "$fw/$framework_binary" | head -1))" + else + $report "$shape: $framework_dir/$framework_binary missing" + fi + + if [[ -d "$fw/$resource_bundle" ]]; then + local count + count="$(find "$fw/$resource_bundle" -type f | wc -l | tr -d ' ')" + pass "$shape: $resource_bundle present inside $framework_dir ($count files)" + else + $report "$shape: $resource_bundle missing from $framework_dir" + fi + + # A copied framework that the app never links is still a delivery failure at runtime. + local link_count + link_count="$(otool -L "$binary" 2>/dev/null | grep -c "$framework_dir/$framework_binary" || true)" + if [[ "${link_count:-0}" -gt 0 ]]; then + pass "$shape: app binary links @rpath/$framework_dir/$framework_binary" + else + $report "$shape: app binary has no load command for $framework_dir/$framework_binary" + fi + + # The bundle must not also be dropped at the app root -- that would mean something is copying + # it a second time, which is what the dead SignIn.targets used to attempt. + if [[ -d "$app_path/$resource_bundle" ]]; then + $report "$shape: $resource_bundle unexpectedly duplicated at the app root" + fi + return + fi + + if [[ -d "$app_path/$resource_bundle" ]]; then + local count + count="$(find "$app_path/$resource_bundle" -type f | wc -l | tr -d ' ')" + if [[ -n "$expected_resource_file_count" && "$count" -ne "$expected_resource_file_count" ]]; then + $report "$shape: $resource_bundle contains $count files; expected $expected_resource_file_count" + else + pass "$shape: $resource_bundle present in app bundle ($count files)" + fi + else + $report "$shape: $resource_bundle missing from app bundle" + fi + + # Do not use `grep -q` here: it exits on the first match, nm takes SIGPIPE, and with pipefail the + # successful match is reported as a failed pipeline. grep -c consumes all input, so no SIGPIPE. + local symbol_count + symbol_count="$(nm "$binary" 2>/dev/null | grep -c "$probe_symbol" || true)" + + if [[ "${symbol_count:-0}" -gt 0 ]]; then + pass "$shape: $probe_symbol linked into the app binary ($symbol_count symbol(s))" + else + $report "$shape: $probe_symbol not found in $exe_name" + fi +} + +# ------------------------------------------------------------------ shape: direct +echo +echo "Shape: direct (app -> package)" +direct="$work/direct" +write_app_sources "$direct" "typeof($managed_probe_type).FullName!" +cat > "$direct/DirectApp.csproj" < + $app_props + DirectApp + ShapeProbe + + + + + +EOF + +if dotnet build "$direct/DirectApp.csproj" -c Debug "${msbuild_args[@]}" > "$work/direct.log" 2>&1; then + assert_app "direct" "$(find "$direct/bin" -name "DirectApp.app" -print -quit)" +else + fail "direct: build failed (see $work/direct.log)" + tail -20 "$work/direct.log" >&2 +fi + +# ------------------------------------------------------------------ shape: via class library +echo +echo "Shape: library (app -> class library -> package)" +lib_root="$work/library" +mkdir -p "$lib_root/Lib" +cat > "$lib_root/Lib/$lib_project.csproj" < + + net10.0-ios + Library + enable + 15.0 + false + + + + + +EOF +cat > "$lib_root/Lib/Probe.cs" < typeof($managed_probe_type).FullName!; +} +EOF + +write_app_sources "$lib_root/App" "ShapeProbe.Lib.Probe.Describe()" +cat > "$lib_root/App/LibraryApp.csproj" < + $app_props + LibraryApp + ShapeProbe + + + + + +EOF + +# This shape was previously reported as a known gap, on the theory that a package's MSBuild +# integration could not reach an app consuming it through a ProjectReference. That was an artefact +# of the scaffold rather than a packaging defect: mktemp -d handed back a symlinked /var path, NuGet +# dropped the ProjectReference edge, and the app silently never referenced the package at all. With +# the scaffold path canonicalised above, Places and SignIn both deliver correctly through a class +# library, so this is a strict check. +if dotnet build "$lib_root/App/LibraryApp.csproj" -c Debug "${msbuild_args[@]}" > "$work/library.log" 2>&1; then + assert_app "library" "$(find "$lib_root/App/bin" -name "LibraryApp.app" -print -quit)" +else + fail "library: build failed (see $work/library.log)" + grep -m3 "Undefined symbols\|error :" "$work/library.log" | sed 's/^/ /' || true +fi + +echo +if (( failures > 0 )); then + echo "$failures consumer-shape check(s) failed." >&2 + diagnostics_dir="$repo_root/tests/E2E/Google.Foundation/artifacts/consumer-shapes-$target" + mkdir -p "$diagnostics_dir" + cp "$work"/*.log "$diagnostics_dir/" 2>/dev/null || true + exit 1 +fi + +if (( known_gaps > 0 )); then + echo "Consumer-shape checks passed, with $known_gaps known gap(s) reported above." +else + echo "All consumer-shape checks passed." +fi diff --git a/tools/e2e/run-google-foundation.sh b/tools/e2e/run-google-foundation.sh new file mode 100755 index 00000000..c226b657 --- /dev/null +++ b/tools/e2e/run-google-foundation.sh @@ -0,0 +1,276 @@ +#!/bin/zsh +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: tools/e2e/run-google-foundation.sh [options] + + --package-dir Local NuGet feed to restore AdamE.* from (default: output) + --configuration Debug or Release (default: Debug) + --target Binding target to exercise (default: Places) + --package-version Version of the target package to restore + --allow-xcode-mismatch Set ValidateXcodeVersion=false. Opt-in escape hatch for local machines + whose Xcode does not exactly match the .NET for iOS workload band. + Leave this off in CI. + +Exercises binding NuGet packages from a local feed in a consumer-style app. Verifies package +delivery -- native linkage and bundled resources -- not backend behavior. No API keys required. +EOF +} + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +harness_root="$repo_root/tests/E2E/Google.Foundation" +project_dir="$harness_root/GoogleFoundationE2E" +project_file="$project_dir/GoogleFoundationE2E.csproj" +# Read from Info.plist rather than duplicating it: install/launch target the bundle id the app was +# actually built with, so the two cannot drift. +bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$project_dir/Info.plist")" + +configuration="Debug" +target="Places" +package_version="" +package_dir="$repo_root/output" +allow_xcode_mismatch="false" + +artifacts_dir="$harness_root/artifacts" + +while [[ $# -gt 0 ]]; do + case "$1" in + --package-dir) package_dir="$2"; shift 2 ;; + --configuration) configuration="$2"; shift 2 ;; + --target) target="$2"; shift 2 ;; + --package-version) package_version="$2"; shift 2 ;; + --allow-xcode-mismatch) allow_xcode_mismatch="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 + +log_file="$artifacts_dir/simulator.log" +result_file="$artifacts_dir/result.json" +restore_config="$artifacts_dir/NuGet.generated.config" +repo_restore_config="$harness_root/NuGet.config" +packages_cache_dir="$artifacts_dir/packages" + +mkdir -p "$artifacts_dir" +: > "$log_file" + +rm -rf "$packages_cache_dir" +mkdir -p "$packages_cache_dir" + +# Map the runtime adapter to its package and version property. Places is intentionally the only +# implemented adapter; adding another target requires an explicit package-specific runtime case. +case "$target" in + Places) + package_id="AdamE.Google.iOS.Places" + package_version_property="PlacesPackageVersion" + ;; + *) echo "Unknown target: $target" >&2; exit 1 ;; +esac + +if [[ ! -d "$package_dir" ]]; then + echo "Package directory does not exist: $package_dir" >&2 + exit 1 +fi + +if [[ -n "$package_version" ]]; then + nupkg="$package_dir/$package_id.$package_version.nupkg" + if [[ ! -f "$nupkg" ]]; then + echo "Package not found: $nupkg" >&2 + exit 1 + fi +else + package_matches=() + while IFS= read -r package_match; do + package_matches+=("$package_match") + done < <( + find "$package_dir" -maxdepth 1 -type f -name "$package_id.*.nupkg" \ + ! -name "*.symbols.nupkg" -print | sort + ) + + case "${#package_matches[@]}" in + 0) + echo "No $package_id package found in $package_dir." >&2 + echo "Pack the target first or pass --package-dir explicitly." >&2 + exit 1 + ;; + 1) + nupkg="${package_matches[1]}" + package_file_name="$(basename "$nupkg")" + package_version="${package_file_name#${package_id}.}" + package_version="${package_version%.nupkg}" + ;; + *) + echo "Multiple $package_id packages found in $package_dir:" >&2 + for package_match in "${package_matches[@]}"; do + echo " $package_match" >&2 + done + echo "Pass --package-version to select one explicitly." >&2 + exit 1 + ;; + esac +fi + +msbuild_args=( + "-p:GoogleE2ETarget=$target" + "-p:$package_version_property=$package_version" +) +if [[ "$allow_xcode_mismatch" == "true" ]]; then + echo "WARNING: ValidateXcodeVersion=false -- building against a non-matching Xcode." >&2 + msbuild_args+=("-p:ValidateXcodeVersion=false") +fi + +if [[ "$package_dir" == "$repo_root/output" ]]; then + restore_config="$repo_restore_config" +else + cat > "$restore_config" < + + + + + + + + + + + + + + + +EOF +fi + +platform="iPhoneSimulator" +rid="iossimulator-arm64" + +echo "Package: $nupkg" +echo "Restoring GoogleFoundationE2E ($target $package_version) from $package_dir" +dotnet restore "$project_file" \ + --configfile "$restore_config" \ + --packages "$packages_cache_dir" \ + --force-evaluate \ + "${msbuild_args[@]}" + +echo "Cleaning GoogleFoundationE2E" +dotnet clean "$project_file" \ + --configuration "$configuration" \ + --framework net10.0-ios \ + -p:Platform="$platform" \ + -p:RuntimeIdentifier="$rid" \ + "${msbuild_args[@]}" >/dev/null + +echo "Building GoogleFoundationE2E for $rid" +dotnet build "$project_file" \ + --configuration "$configuration" \ + --framework net10.0-ios \ + --no-restore \ + -p:Platform="$platform" \ + -p:RuntimeIdentifier="$rid" \ + "${msbuild_args[@]}" + +app_path="$(find "$project_dir/bin" -path "*$platform/$configuration/net10.0-ios/$rid/GoogleFoundationE2E.app" -print -quit)" +if [[ ! -d "$app_path" ]]; then + echo "Built app not found under $project_dir/bin" >&2 + exit 1 +fi +echo "App: $app_path" + +# Capture target-neutral diagnostics for failed-run investigation. These are not golden baselines; +# the runtime cases below make the delivery assertions. +capture_manifest() { + local out_dir="$1" + mkdir -p "$out_dir" + (cd "$app_path" && find . -type f | sed 's|^\./||' | sort) > "$out_dir/app-files.txt" + nm -gU "$app_path/GoogleFoundationE2E" 2>/dev/null \ + | awk 'NF { print $NF }' \ + | sort -u > "$out_dir/native-symbols.txt" || true + otool -L "$app_path/GoogleFoundationE2E" 2>/dev/null \ + | sed '1s|^.*:|GoogleFoundationE2E:|' > "$out_dir/linked-libraries.txt" || true + otool -l "$app_path/GoogleFoundationE2E" 2>/dev/null \ + | grep -c "__LLVM" > "$out_dir/llvm-segment-count.txt" || echo 0 > "$out_dir/llvm-segment-count.txt" + echo "Manifest captured: $out_dir" +} + +capture_manifest "$artifacts_dir/manifest-$rid" + +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] 'GoogleFoundationE2E' 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/google-foundation-e2e-result.json" + +timeout_seconds="${E2E_TIMEOUT_SECONDS:-90}" +echo "Waiting up to $timeout_seconds seconds for E2E result" + +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" + +xcrun simctl terminate "$simulator_udid" "$bundle_id" >/dev/null 2>&1 || true + +if [[ "$success" == "true" ]]; then + echo + echo "Google foundation E2E ($target) passed." + exit 0 +fi + +echo +echo "Google foundation E2E ($target) failed." >&2 +exit 1