From a4dacb8b284b3d39a4ab324d0055615c9efda83a Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Sat, 29 Aug 2026 17:07:04 -0400 Subject: [PATCH 1/2] Make Maps delivery validation independent --- scripts/check-maps-resource-manifest.py | 296 +++++++++++++----------- tools/e2e/check-consumer-shapes.sh | 201 ++++++++++++++-- 2 files changed, 347 insertions(+), 150 deletions(-) diff --git a/scripts/check-maps-resource-manifest.py b/scripts/check-maps-resource-manifest.py index e993efdf..90954521 100755 --- a/scripts/check-maps-resource-manifest.py +++ b/scripts/check-maps-resource-manifest.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate Google Maps BundleResource declarations against the pinned SDK archive.""" +"""Validate Google Maps resources against the pinned SDK archive.""" from __future__ import annotations @@ -20,11 +20,12 @@ EXPECTED_ARCHIVE_SHA256 = ( "81bbd92c2d627087ae222ae955e5f746590812d7389b9d800add15e4004b6431" ) +ARCHIVE_URL = "https://dl.google.com/dl/cpdc/33a7ac549361ab23/GoogleMaps-9.2.0.tar.gz" +ARCHIVE_RESOURCE_ROOT = "Maps/Resources/GoogleMapsResources/GoogleMaps.bundle" +EXPECTED_RESOURCE_FILE_COUNT = 190 RESOURCE_PROPERTY = "_GoogleMapsResourcesBaseFolder" RESOURCE_TOKEN = f"$({RESOURCE_PROPERTY})" LOGICAL_ROOT = "GoogleMaps.bundle" -RESTORE_TARGET = "_GMpsDownloadedItems" -EXPECTED_APP_ITEM_CONDITION = "('$(OutputType)'!='Library' OR '$(IsAppExtension)'=='True')" def repository_root() -> Path: @@ -42,7 +43,16 @@ def parse_arguments() -> argparse.Namespace: parser.add_argument( "--archive", type=Path, - help="Use an existing Google Maps .tar.gz instead of downloading the declared URL", + help="Use an existing Google Maps .tar.gz instead of downloading the pinned URL", + ) + parser.add_argument( + "--copy-bundle-to", + type=Path, + metavar="DIRECTORY", + help=( + "After all checks pass, copy the verified bundle to " + "DIRECTORY/GoogleMaps.bundle; the destination must not already exist" + ), ) return parser.parse_args() @@ -51,118 +61,31 @@ def elements(parent: ET.Element, name: str) -> list[ET.Element]: return [element for element in parent.iter() if element.tag.rsplit("}", 1)[-1] == name] -def direct_children(parent: ET.Element, name: str) -> list[ET.Element]: - return [child for child in list(parent) if child.tag.rsplit("}", 1)[-1] == name] - - def normalize_relative_path(value: str, label: str, errors: list[str]) -> str: normalized = value.replace("\\", "/") path = PurePosixPath(normalized) - if not normalized or normalized.startswith("/") or any(part in ("", ".", "..") for part in path.parts): + if ( + not normalized + or normalized.startswith("/") + or path.as_posix() != normalized + or any(part in ("", ".", "..") for part in path.parts) + ): errors.append(f"{label} is not a normalized relative path: {value!r}") return normalized -def one_text(parent: ET.Element, name: str, errors: list[str]) -> str: - matches = elements(parent, name) - if len(matches) != 1 or not (matches[0].text or "").strip(): - errors.append(f"expected exactly one non-empty {name}, found {len(matches)}") - return "" - return (matches[0].text or "").strip() - - -def parse_targets(targets_path: Path) -> tuple[str, str, list[str], list[str], list[str]]: +def parse_targets(targets_path: Path) -> tuple[list[str], list[str], list[str]]: errors: list[str] = [] try: root = ET.parse(targets_path).getroot() except (OSError, ET.ParseError) as exc: raise RuntimeError(f"could not parse {targets_path}: {exc}") from exc - downloads = elements(root, "XamarinBuildDownload") - if len(downloads) != 1: - errors.append(f"expected exactly one XamarinBuildDownload item, found {len(downloads)}") - download = downloads[0] if downloads else root - archive_url = one_text(download, "Url", errors) - archive_kind = one_text(download, "Kind", errors) - if archive_kind and archive_kind.lower() != "tgz": - errors.append(f"XamarinBuildDownload Kind is {archive_kind!r}, expected 'Tgz'") - if archive_url and not archive_url.startswith("https://"): - errors.append(f"archive URL must use HTTPS: {archive_url}") - - properties = elements(root, RESOURCE_PROPERTY) - if len(properties) != 1 or not (properties[0].text or "").strip(): - errors.append(f"expected exactly one non-empty {RESOURCE_PROPERTY}, found {len(properties)}") - archive_resource_root = "" - else: - resource_base = (properties[0].text or "").strip().replace("\\", "/") - prefix = "$(XamarinBuildDownloadDir)$(_GoogleMapsItemsFolder)/" - if not resource_base.startswith(prefix): - errors.append(f"{RESOURCE_PROPERTY} must start with {prefix!r}: {resource_base}") - archive_resource_root = "" - else: - archive_resource_root = resource_base[len(prefix) :].rstrip("/") - if not archive_resource_root.endswith(f"/{LOGICAL_ROOT}"): - errors.append( - f"{RESOURCE_PROPERTY} must resolve to {LOGICAL_ROOT}: {archive_resource_root}" - ) - - restore_targets = [ - target for target in direct_children(root, "Target") if target.get("Name") == RESTORE_TARGET - ] - if len(restore_targets) != 1: - errors.append( - f"expected exactly one project-level Target named {RESTORE_TARGET}, " - f"found {len(restore_targets)}" - ) - restore_target = restore_targets[0] if restore_targets else root - if restore_targets and restore_target.get("Condition", "").strip(): - errors.append(f"Target {RESTORE_TARGET} must not have a Condition") - - all_restore_hooks = [ - item - for item in elements(root, "XamarinBuildRestoreResources") - if item.get("Include") == RESTORE_TARGET - ] - if len(all_restore_hooks) != 1: - errors.append( - f"expected exactly one XamarinBuildRestoreResources hook for {RESTORE_TARGET}, " - f"found {len(all_restore_hooks)}" - ) - - hook_groups: list[tuple[ET.Element, ET.Element]] = [] - for item_group in direct_children(root, "ItemGroup"): - for item in direct_children(item_group, "XamarinBuildRestoreResources"): - if item.get("Include") == RESTORE_TARGET: - hook_groups.append((item_group, item)) - - if len(hook_groups) != 1: - errors.append( - f"expected one project-level ItemGroup to schedule {RESTORE_TARGET}, " - f"found {len(hook_groups)}" - ) - else: - hook_group, restore_hook = hook_groups[0] - if hook_group.get("Condition", "").strip() != EXPECTED_APP_ITEM_CONDITION: - errors.append( - f"{RESTORE_TARGET} ItemGroup Condition is {hook_group.get('Condition', '')!r}; " - f"expected {EXPECTED_APP_ITEM_CONDITION!r}" - ) - if restore_hook.get("Condition", "").strip(): - errors.append(f"XamarinBuildRestoreResources hook for {RESTORE_TARGET} must not have a Condition") - if downloads and download not in list(hook_group): - errors.append("XamarinBuildDownload and its restore hook must share the same ItemGroup") - includes: list[str] = [] logical_names: list[str] = [] - bundle_resources = elements(restore_target, "BundleResource") - all_bundle_resources = elements(root, "BundleResource") - if len(bundle_resources) != len(all_bundle_resources): - errors.append( - f"all BundleResource items must be declared by {RESTORE_TARGET}; " - f"found {len(all_bundle_resources) - len(bundle_resources)} elsewhere" - ) + bundle_resources = elements(root, "BundleResource") if not bundle_resources: - errors.append(f"Target {RESTORE_TARGET} declares no BundleResource items") + errors.append("Maps.targets declares no BundleResource items") for index, resource in enumerate(bundle_resources, start=1): include = resource.get("Include", "") @@ -204,7 +127,7 @@ def parse_targets(targets_path: Path) -> tuple[str, str, list[str], list[str], l f"expected {expected_logical_name!r}" ) - return archive_url, archive_resource_root, includes, logical_names, errors + return includes, logical_names, errors def download_archive(url: str, destination: Path) -> None: @@ -277,20 +200,94 @@ def compare_sets(actual: set[str], expected: set[str], actual_label: str) -> lis return errors +def copy_verified_bundle( + archive_path: Path, + resource_root: str, + relative_files: list[str], + destination_parent: Path, +) -> Path: + """Copy regular bundle files from a verified archive without trusting tar paths.""" + + destination_parent = destination_parent.expanduser().resolve() + if destination_parent.exists() and not destination_parent.is_dir(): + raise RuntimeError(f"bundle destination parent is not a directory: {destination_parent}") + destination_parent.mkdir(parents=True, exist_ok=True) + + destination_bundle = destination_parent / LOGICAL_ROOT + if destination_bundle.exists() or destination_bundle.is_symlink(): + raise RuntimeError(f"bundle destination already exists: {destination_bundle}") + + normalized_root = resource_root.strip("/") + prefix = f"{normalized_root}/" + expected_files = sorted(relative_files) + + try: + with tempfile.TemporaryDirectory( + prefix=".googlemaps-bundle-", dir=destination_parent + ) as staging_directory, tarfile.open(archive_path, "r:gz") as archive: + staging_bundle = Path(staging_directory) / LOGICAL_ROOT + + for member in archive.getmembers(): + member_name = member.name + while member_name.startswith("./"): + member_name = member_name[2:] + if not member_name.startswith(prefix): + continue + + relative_name = member_name[len(prefix) :] + if not relative_name: + continue + if member.isdir(): + relative_name = relative_name.rstrip("/") + relative_path = PurePosixPath(relative_name) + if ( + relative_path.is_absolute() + or relative_path.as_posix() != relative_name + or any(part in ("", ".", "..") for part in relative_path.parts) + ): + raise RuntimeError(f"unsafe archive resource path: {member.name!r}") + + output_path = staging_bundle.joinpath(*relative_path.parts) + if member.isdir(): + output_path.mkdir(parents=True, exist_ok=True) + continue + if not member.isfile(): + raise RuntimeError(f"archive resource is not a regular file: {member.name}") + + output_path.parent.mkdir(parents=True, exist_ok=True) + source = archive.extractfile(member) + if source is None: + raise RuntimeError(f"could not read archive resource: {member.name}") + with source, output_path.open("xb") as destination: + shutil.copyfileobj(source, destination) + + copied_files = sorted( + path.relative_to(staging_bundle).as_posix() + for path in staging_bundle.rglob("*") + if path.is_file() + ) + if copied_files != expected_files: + raise RuntimeError("copied bundle file set differs from the verified archive manifest") + + staging_bundle.rename(destination_bundle) + except (OSError, tarfile.TarError) as exc: + raise RuntimeError(f"could not copy verified Google Maps resources: {exc}") from exc + + return destination_bundle + + def main() -> int: args = parse_arguments() targets_path = args.targets.resolve() try: - archive_url, resource_root, includes, logical_names, errors = parse_targets(targets_path) + includes, logical_names, errors = parse_targets(targets_path) with tempfile.TemporaryDirectory(prefix="maps-resource-manifest-") as temp_dir: if args.archive: archive_path = args.archive.resolve() else: - if not archive_url: - raise RuntimeError("cannot download archive because Maps.targets has no valid URL") archive_path = Path(temp_dir) / "GoogleMaps.tar.gz" - download_archive(archive_url, archive_path) + download_archive(ARCHIVE_URL, archive_path) actual_sha256 = sha256(archive_path) if actual_sha256 != EXPECTED_ARCHIVE_SHA256: @@ -298,40 +295,73 @@ def main() -> int: f"archive SHA-256 is {actual_sha256}, expected {EXPECTED_ARCHIVE_SHA256}" ) - archive_files, archive_errors = archive_resource_files(archive_path, resource_root) + archive_files, archive_errors = archive_resource_files( + archive_path, ARCHIVE_RESOURCE_ROOT + ) errors.extend(archive_errors) - except (OSError, RuntimeError) as exc: - print(f"Maps resource manifest check failed: {exc}", file=sys.stderr) - return 1 - errors.extend(duplicate_messages(includes, "BundleResource Include")) - errors.extend(duplicate_messages(logical_names, "LogicalName")) - errors.extend(duplicate_messages(archive_files, "archive resource path")) + errors.extend(duplicate_messages(includes, "BundleResource Include")) + errors.extend(duplicate_messages(logical_names, "LogicalName")) + errors.extend(duplicate_messages(archive_files, "archive resource path")) - include_set = set(includes) - archive_set = set(archive_files) - errors.extend(compare_sets(include_set, archive_set, "BundleResource Include")) - expected_logical_names = {f"{LOGICAL_ROOT}/{path}" for path in archive_set} - errors.extend( - compare_sets(set(logical_names), expected_logical_names, "LogicalName") - ) + include_set = set(includes) + archive_set = set(archive_files) + errors.extend(compare_sets(include_set, archive_set, "BundleResource Include")) + expected_logical_names = {f"{LOGICAL_ROOT}/{path}" for path in archive_set} + errors.extend( + compare_sets(set(logical_names), expected_logical_names, "LogicalName") + ) - print(f"Targets: {targets_path}") - print(f"Archive: {archive_url}") - print(f"SHA-256: {actual_sha256}") - print( - f"Resources: {len(includes)} declarations, {len(include_set)} unique target paths, " - f"{len(archive_files)} archive files" - ) + if len(includes) != EXPECTED_RESOURCE_FILE_COUNT: + errors.append( + f"expected {EXPECTED_RESOURCE_FILE_COUNT} BundleResource declarations, " + f"found {len(includes)}" + ) + if len(include_set) != EXPECTED_RESOURCE_FILE_COUNT: + errors.append( + f"expected {EXPECTED_RESOURCE_FILE_COUNT} unique target paths, " + f"found {len(include_set)}" + ) + if len(archive_files) != EXPECTED_RESOURCE_FILE_COUNT: + errors.append( + f"expected {EXPECTED_RESOURCE_FILE_COUNT} archive files, " + f"found {len(archive_files)}" + ) - if errors: - print(f"Maps resource manifest check failed with {len(errors)} error(s):", file=sys.stderr) - for error in errors: - print(f" - {error}", file=sys.stderr) - return 1 + print(f"Targets: {targets_path}") + print(f"Archive URL: {ARCHIVE_URL}") + print(f"Archive resource root: {ARCHIVE_RESOURCE_ROOT}") + print(f"SHA-256: {actual_sha256}") + print( + f"Resources: {len(includes)} declarations, {len(include_set)} unique target paths, " + f"{len(archive_files)} archive files" + ) + + if errors: + print( + f"Maps resource manifest check failed with {len(errors)} error(s):", + file=sys.stderr, + ) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + copied_bundle = None + if args.copy_bundle_to: + copied_bundle = copy_verified_bundle( + archive_path, + ARCHIVE_RESOURCE_ROOT, + archive_files, + args.copy_bundle_to, + ) - print("Maps resource manifest check passed.") - return 0 + print("Maps resource manifest check passed.") + if copied_bundle: + print(f"Verified bundle: {copied_bundle}") + return 0 + except (OSError, RuntimeError) as exc: + print(f"Maps resource manifest check failed: {exc}", file=sys.stderr) + return 1 if __name__ == "__main__": diff --git a/tools/e2e/check-consumer-shapes.sh b/tools/e2e/check-consumer-shapes.sh index 77c3ac5d..1c7a7a14 100755 --- a/tools/e2e/check-consumer-shapes.sh +++ b/tools/e2e/check-consumer-shapes.sh @@ -3,7 +3,7 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: tools/e2e/check-consumer-shapes.sh --target [options] +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) @@ -21,6 +21,7 @@ A binding package's .targets historically gated its items on OutputType != 'Libr library shape is where transitive delivery quietly breaks. Targets differ in how the native payload arrives, and the assertions follow that: + Maps static xcframework -- verified upstream bundle at app root, symbol linked into app 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 @@ -46,16 +47,38 @@ done [[ "$package_dir" != /* ]] && package_dir="$repo_root/$package_dir" case "$target" in + Maps) + package_id="AdamE.Google.iOS.Maps" + # GoogleMaps.xcframework is static. Its resource bundle is distributed beside the framework in + # the upstream archive and must arrive exactly once at the app root. + delivery="static" + resource_bundle="GoogleMaps.bundle" + managed_probe_expression='Google.Maps.GeometryUtils.Distance(new CoreLocation.CLLocationCoordinate2D(0, 0), new CoreLocation.CLLocationCoordinate2D(1, 1))' + lib_project="MapsLib" + probe_symbol="GMSGeometryDistance" + probe_symbol_exact="_GMSGeometryDistance" + expected_resource_file_count=190 + compare_expected_bundle="true" + forbidden_dynamic_framework="GoogleMaps.framework" + source_targets="$repo_root/source/Google/Maps/Maps.targets" + # Calling the managed binding above creates the native reference; no extra DllImport is needed. + native_probe_decl="" + native_probe_call="" + ;; 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" + managed_probe_expression="typeof(Google.Places.AutocompleteFilter).FullName!" lib_project="PlacesLib" probe_symbol="GMSPlaceRectangularLocationOption" + probe_symbol_exact="" expected_resource_file_count=59 + compare_expected_bundle="false" + forbidden_dynamic_framework="" + source_targets="" # 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. @@ -73,7 +96,7 @@ case "$target" in framework_dir="GoogleSignIn.framework" framework_binary="GoogleSignIn" resource_bundle="GoogleSignIn.bundle" - managed_probe_type="Google.SignIn.SignIn" + managed_probe_expression="typeof(Google.SignIn.SignIn).FullName!" 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 @@ -81,6 +104,11 @@ case "$target" in native_probe_decl="" native_probe_call="" expected_resource_file_count="" + probe_symbol="" + probe_symbol_exact="" + compare_expected_bundle="false" + forbidden_dynamic_framework="" + source_targets="" ;; *) echo "Unknown target: $target" >&2; exit 1 ;; esac @@ -118,10 +146,8 @@ else fi echo "Testing $package_id $package_version from $nupkg" -msbuild_args=() -[[ "$allow_xcode_mismatch" == "true" ]] && msbuild_args+=("-p:ValidateXcodeVersion=false") - failures=0 +completed="false" pass() { print -r -- " PASS $1"; } fail() { print -r -- " FAIL $1" >&2; failures=$((failures + 1)); } @@ -132,22 +158,75 @@ 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" +# XBD-delivered packages use this fresh directory; embedded packages safely ignore the property. +# The trailing slash is required by legacy targets that concatenate the property with a folder name. +xbd_dir="$work/xbd/" +mkdir -p "$NUGET_PACKAGES" "$xbd_dir" +msbuild_args=("-p:XamarinBuildDownloadDir=$xbd_dir") +[[ "$allow_xcode_mismatch" == "true" ]] && msbuild_args+=("-p:ValidateXcodeVersion=false") # 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" +diagnostics_dir="$repo_root/tests/E2E/Google.Foundation/artifacts/consumer-shapes-$target" # 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 + local exit_status=$? + + if [[ "$completed" != "true" ]]; then + mkdir -p "$diagnostics_dir" + cp "$work"/*.log(N) "$work"/*.diff(N) "$work"/*.txt(N) "$diagnostics_dir/" 2>/dev/null || true + print -r -- "Diagnostics copied to $diagnostics_dir" >&2 print -r -- "Scaffold kept for inspection: $work" >&2 else rm -rf "$work" fi + + return "$exit_status" } trap cleanup EXIT +expected_bundle="" +if [[ "$compare_expected_bundle" == "true" ]]; then + expected_bundle_parent="$work/verified-upstream" + if python3 "$repo_root/scripts/check-maps-resource-manifest.py" \ + --copy-bundle-to "$expected_bundle_parent" > "$work/maps-resource-manifest.log" 2>&1; then + expected_bundle="$expected_bundle_parent/$resource_bundle" + if [[ -d "$expected_bundle" ]]; then + pass "verified upstream $resource_bundle materialized" + else + fail "verified upstream checker did not materialize $expected_bundle" + exit 1 + fi + else + fail "could not validate and materialize the upstream $resource_bundle" + tail -25 "$work/maps-resource-manifest.log" >&2 + exit 1 + fi +fi + +if [[ -n "$source_targets" ]]; then + echo + echo "Packaged MSBuild integration" + for folder in build buildTransitive; do + packaged_targets="$work/$folder.targets" + if unzip -p "$nupkg" "$folder/$package_id.targets" > "$packaged_targets" 2>/dev/null; then + if diff -u "$source_targets" "$packaged_targets" > "$work/$folder-targets.diff"; then + pass "$folder/$package_id.targets matches source" + else + fail "$folder/$package_id.targets differs from $source_targets (see $work/$folder-targets.diff)" + fi + else + fail "$folder/$package_id.targets is missing from the selected package" + fi + done + + if (( failures > 0 )); then + exit 1 + fi +fi + cat > "$work/NuGet.config" < @@ -225,6 +304,24 @@ EOF EOF } +write_file_manifest() { + local root="$1" destination="$2" + (cd "$root" && find . -type f -print | sed 's|^\./||' | LC_ALL=C sort) > "$destination" +} + +assert_restored_package() { + local shape="$1" package_id_lower="${package_id:l}" + local restored_nupkg="$NUGET_PACKAGES/$package_id_lower/$package_version/$package_id_lower.$package_version.nupkg" + + if [[ ! -f "$restored_nupkg" ]]; then + fail "$shape: restored package is missing at $restored_nupkg" + elif cmp -s "$nupkg" "$restored_nupkg"; then + pass "$shape: restore consumed the selected local package" + else + fail "$shape: restored package differs from $nupkg" + fi +} + # 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. @@ -296,9 +393,56 @@ assert_app() { return fi - if [[ -d "$app_path/$resource_bundle" ]]; then + local app_resource_bundle="$app_path/$resource_bundle" + if [[ "$compare_expected_bundle" == "true" ]]; then + local bundle_matches bundle_count actual_manifest expected_manifest content_mismatches relative_file + bundle_matches="$(find "$app_path" -type d -name "$resource_bundle" -print)" + bundle_count="$(print -r -- "$bundle_matches" | sed '/^$/d' | wc -l | tr -d ' ')" + if [[ "$bundle_count" != "1" ]]; then + $report "$shape: expected exactly one $resource_bundle, found $bundle_count" + return + fi + + app_resource_bundle="$(print -r -- "$bundle_matches" | sed -n '1p')" + if [[ "$app_resource_bundle" == "$app_path/$resource_bundle" ]]; then + pass "$shape: exactly one root $resource_bundle is present" + else + $report "$shape: $resource_bundle is not at the app root ($app_resource_bundle)" + fi + + expected_manifest="$work/expected-bundle-files.txt" + actual_manifest="$work/$shape-bundle-files.txt" + [[ -s "$expected_manifest" ]] || write_file_manifest "$expected_bundle" "$expected_manifest" + write_file_manifest "$app_resource_bundle" "$actual_manifest" + + if diff -u "$expected_manifest" "$actual_manifest" > "$work/$shape-bundle.diff"; then + local actual_count + actual_count="$(wc -l < "$actual_manifest" | tr -d ' ')" + if [[ -n "$expected_resource_file_count" && "$actual_count" -ne "$expected_resource_file_count" ]]; then + $report "$shape: $resource_bundle contains $actual_count files; expected $expected_resource_file_count" + else + pass "$shape: bundle file set matches verified upstream ($actual_count files)" + fi + + content_mismatches="$work/$shape-content-mismatches.txt" + : > "$content_mismatches" + while IFS= read -r relative_file; do + if ! cmp -s "$expected_bundle/$relative_file" "$app_resource_bundle/$relative_file"; then + print -r -- "$relative_file" >> "$content_mismatches" + fi + done < "$expected_manifest" + + if [[ -s "$content_mismatches" ]]; then + $report "$shape: bundle contents differ from verified upstream (see $content_mismatches)" + else + pass "$shape: bundle contents are byte-for-byte identical to verified upstream" + fi + else + $report "$shape: bundle file set differs from verified upstream (see $work/$shape-bundle.diff)" + fi + elif [[ -d "$app_resource_bundle" ]]; then local count - count="$(find "$app_path/$resource_bundle" -type f | wc -l | tr -d ' ')" + count="$(find "$app_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 @@ -311,20 +455,39 @@ assert_app() { # 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 [[ -n "$probe_symbol_exact" ]]; then + symbol_count="$(nm -U "$binary" 2>/dev/null \ + | awk -v expected="$probe_symbol_exact" '$NF == expected { count++ } END { print count + 0 }')" + else + symbol_count="$(nm "$binary" 2>/dev/null | grep -c "$probe_symbol" || true)" + fi 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 + + if [[ -n "$forbidden_dynamic_framework" ]]; then + local framework_count + framework_count=0 + if [[ -d "$app_path/Frameworks" ]]; then + framework_count="$(find "$app_path/Frameworks" -type d -name "$forbidden_dynamic_framework" \ + -print | wc -l | tr -d ' ')" + fi + if [[ "$framework_count" == "0" ]]; then + pass "$shape: no dynamic $forbidden_dynamic_framework copy is present" + else + $report "$shape: static framework was unexpectedly copied to App.app/Frameworks/$forbidden_dynamic_framework" + fi + fi } # ------------------------------------------------------------------ shape: direct echo echo "Shape: direct (app -> package)" direct="$work/direct" -write_app_sources "$direct" "typeof($managed_probe_type).FullName!" +write_app_sources "$direct" "$managed_probe_expression" cat > "$direct/DirectApp.csproj" < $app_props @@ -338,6 +501,9 @@ cat > "$direct/DirectApp.csproj" < "$work/direct.log" 2>&1; then + if [[ "$target" == "Maps" ]]; then + assert_restored_package "direct" + fi assert_app "direct" "$(find "$direct/bin" -name "DirectApp.app" -print -quit)" else fail "direct: build failed (see $work/direct.log)" @@ -368,7 +534,7 @@ namespace ShapeProbe.Lib; public static class Probe { - public static string Describe() => typeof($managed_probe_type).FullName!; + public static object Describe() => $managed_probe_expression; } EOF @@ -389,9 +555,12 @@ EOF # 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 +# the scaffold path canonicalised above, Maps, Places and SignIn all 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 + if [[ "$target" == "Maps" ]]; then + assert_restored_package "library" + fi assert_app "library" "$(find "$lib_root/App/bin" -name "LibraryApp.app" -print -quit)" else fail "library: build failed (see $work/library.log)" @@ -401,12 +570,10 @@ 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 +completed="true" if (( known_gaps > 0 )); then echo "Consumer-shape checks passed, with $known_gaps known gap(s) reported above." else From 82be6fe29bd49202dbf750fb26b93c1edb1f66e2 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Sat, 29 Aug 2026 17:07:20 -0400 Subject: [PATCH 2/2] Add Maps runtime validation lane --- .../google-foundation-validation.yml | 68 ++- .github/workflows/maps-resource-integrity.yml | 74 ---- scripts/check-maps-consumers.sh | 396 ------------------ .../GoogleFoundationE2E.csproj | 18 +- .../GoogleSelfTestRunner.cs | 5 + .../GoogleFoundationE2E/MapsCases.cs | 167 ++++++++ tests/E2E/Google.Foundation/README.md | 28 +- tools/e2e/run-google-foundation.sh | 8 +- 8 files changed, 280 insertions(+), 484 deletions(-) delete mode 100644 .github/workflows/maps-resource-integrity.yml delete mode 100755 scripts/check-maps-consumers.sh create mode 100644 tests/E2E/Google.Foundation/GoogleFoundationE2E/MapsCases.cs diff --git a/.github/workflows/google-foundation-validation.yml b/.github/workflows/google-foundation-validation.yml index dc4cfd81..01cb8d39 100644 --- a/.github/workflows/google-foundation-validation.yml +++ b/.github/workflows/google-foundation-validation.yml @@ -10,8 +10,11 @@ on: - "global.json" - "Xamarin.Google.sln" - "*.cake" + - "icons/googleiosmaps_128x128.png" - "icons/googleiosplaces_128x128.png" + - "scripts/check-maps-resource-manifest.py" - "source/AssemblyInfo.cs" + - "source/Google/Maps/**" - "source/Google/Places/**" - "tests/E2E/Google.Foundation/**" - "tools/e2e/check-consumer-shapes.sh" @@ -29,8 +32,11 @@ on: - "global.json" - "Xamarin.Google.sln" - "*.cake" + - "icons/googleiosmaps_128x128.png" - "icons/googleiosplaces_128x128.png" + - "scripts/check-maps-resource-manifest.py" - "source/AssemblyInfo.cs" + - "source/Google/Maps/**" - "source/Google/Places/**" - "tests/E2E/Google.Foundation/**" - "tools/e2e/check-consumer-shapes.sh" @@ -108,6 +114,66 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: google-foundation-validation-diagnostics + name: google-foundation-places-diagnostics + path: tests/E2E/Google.Foundation/artifacts/ + if-no-files-found: ignore + + maps: + 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.Maps + run: dotnet tool run dotnet-cake -- --target=nuget --names=Google.Maps + + - name: Maps resource manifest check + run: python3 scripts/check-maps-resource-manifest.py + + - name: Direct and transitive consumer checks + run: >- + tools/e2e/check-consumer-shapes.sh + --target Maps + --package-dir output + + - name: Simulator E2E + run: >- + tools/e2e/run-google-foundation.sh + --target Maps + --package-dir output + --configuration Debug + + - name: Upload diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: google-foundation-maps-diagnostics path: tests/E2E/Google.Foundation/artifacts/ if-no-files-found: ignore diff --git a/.github/workflows/maps-resource-integrity.yml b/.github/workflows/maps-resource-integrity.yml deleted file mode 100644 index 6621cabc..00000000 --- a/.github/workflows/maps-resource-integrity.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Maps Resource Integrity - -on: - pull_request: - paths: - - ".github/workflows/maps-resource-integrity.yml" - - "Directory.Build.props" - - "global.json" - - "scripts/check-maps-consumers.sh" - - "scripts/check-maps-resource-manifest.py" - - "source/Google/Maps/**" - push: - branches: - - main - paths: - - ".github/workflows/maps-resource-integrity.yml" - - "Directory.Build.props" - - "global.json" - - "scripts/check-maps-consumers.sh" - - "scripts/check-maps-resource-manifest.py" - - "source/Google/Maps/**" - workflow_dispatch: - -permissions: - contents: read - -jobs: - manifest: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Compare Maps targets with the upstream archive - run: python3 scripts/check-maps-resource-manifest.py - - consumers: - needs: manifest - runs-on: macos-15 - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Toolchain versions - run: | - xcodebuild -version - xcode-select -p - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - - - name: Restore .NET workload - run: dotnet workload restore source/Google/Maps/Maps.csproj - - - name: Restore Maps package - run: dotnet restore source/Google/Maps/Maps.csproj - - - name: Pack Maps - run: dotnet pack source/Google/Maps/Maps.csproj --configuration Release --no-restore --output output - - - name: Direct and transitive consumer checks - run: scripts/check-maps-consumers.sh --package-dir output --allow-xcode-mismatch - - - name: Upload diagnostics - if: failure() - uses: actions/upload-artifact@v4 - with: - name: maps-resource-integrity-diagnostics - path: artifacts/maps-resource-integrity/ - if-no-files-found: ignore diff --git a/scripts/check-maps-consumers.sh b/scripts/check-maps-consumers.sh deleted file mode 100755 index 3bb9c383..00000000 --- a/scripts/check-maps-consumers.sh +++ /dev/null @@ -1,396 +0,0 @@ -#!/bin/zsh -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: scripts/check-maps-consumers.sh [options] - - --package-dir Local NuGet feed (default: output) - --package-version AdamE.Google.iOS.Maps version to consume - --allow-xcode-mismatch Pass ValidateXcodeVersion=false for local diagnostics - --keep-work Retain the temporary consumer projects after a failure - -Builds direct and transitive net10.0-ios consumers of the locally packed Maps package. Verifies -the native symbol is linked and the app contains exactly one complete GoogleMaps.bundle. -EOF -} - -repo_root="$(cd "$(dirname "$0")/.." && pwd)" -source_targets="$repo_root/source/Google/Maps/Maps.targets" -package_id="AdamE.Google.iOS.Maps" -resource_bundle="GoogleMaps.bundle" -package_dir="$repo_root/output" -package_version="" -allow_xcode_mismatch="false" -keep_work="false" - -while [[ $# -gt 0 ]]; do - case "$1" in - --package-dir) package_dir="$2"; shift 2 ;; - --package-version) package_version="$2"; shift 2 ;; - --allow-xcode-mismatch) allow_xcode_mismatch="true"; shift ;; - --keep-work) keep_work="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" - -if [[ -n "$package_version" ]]; then - nupkg="$package_dir/$package_id.$package_version.nupkg" -else - nupkgs=("$package_dir"/"$package_id".*.nupkg(N)) - if (( ${#nupkgs[@]} > 1 )); then - echo "Multiple $package_id packages found in $package_dir; pass --package-version." >&2 - printf ' %s\n' "${nupkgs[@]}" >&2 - exit 1 - fi - nupkg="${nupkgs[1]:-}" - package_version="${${nupkg:t}#$package_id.}" - package_version="${package_version%.nupkg}" -fi - -if [[ -z "${nupkg:-}" || ! -f "$nupkg" ]]; then - echo "No $package_id package found in $package_dir" >&2 - exit 1 -fi - -echo "Testing $package_id $package_version from $nupkg" - -# NuGet treats /var and /private/var as different project identities even though one is a symlink. -# Resolve mktemp's result so the ProjectReference graph in the transitive shape remains connected. -work="$(cd "$(mktemp -d)" && pwd -P)" -# Maps.targets concatenates this property while it is evaluated, before XBD normalizes it. -xbd_dir="$work/xbd/" -packages_dir="$work/packages" -artifacts_dir="$repo_root/artifacts/maps-resource-integrity" -mkdir -p "$xbd_dir" "$packages_dir" -# Keep the generated projects on the same pinned SDK/workload set as the repository. The -# .NET SDK resolves global.json from the project tree, not from this script's working directory. -cp "$repo_root/global.json" "$work/global.json" - -failures=0 -completed="false" -pass() { print -r -- " PASS $1"; } -fail() { print -r -- " FAIL $1" >&2; failures=$((failures + 1)); } - -cleanup() { - local exit_status=$? - - if [[ "$completed" != "true" ]]; then - mkdir -p "$artifacts_dir" - cp "$work"/*.diff(N) "$work"/*.log(N) "$work"/*.txt(N) "$artifacts_dir/" 2>/dev/null || true - print -r -- "Diagnostics copied to $artifacts_dir" >&2 - fi - - if [[ "$completed" != "true" && "$keep_work" == "true" ]]; then - print -r -- "Diagnostic scaffold kept at $work" >&2 - else - rm -rf "$work" - fi - - return "$exit_status" -} -trap cleanup EXIT - -echo -echo "Packaged MSBuild integration" -for folder in build buildTransitive; do - packaged_targets="$work/$folder.targets" - if unzip -p "$nupkg" "$folder/$package_id.targets" > "$packaged_targets" 2>/dev/null; then - if cmp -s "$source_targets" "$packaged_targets"; then - pass "$folder/$package_id.targets matches source" - else - fail "$folder/$package_id.targets differs from source/Google/Maps/Maps.targets" - fi - else - fail "$folder/$package_id.targets is missing from the package" - fi -done - -if (( failures > 0 )); then - echo "$failures package integration check(s) failed." >&2 - exit 1 -fi - -cat > "$work/NuGet.config" < - - - - - - - - - - - - - - - -EOF - -app_properties=' - net10.0-ios - Exe - enable - 15.0 - iossimulator-arm64 - iPhoneSimulator - false - manual' - -msbuild_args=("-p:XamarinBuildDownloadDir=$xbd_dir") -[[ "$allow_xcode_mismatch" == "true" ]] && msbuild_args+=("-p:ValidateXcodeVersion=false") - -write_app_sources() { - local directory="$1" - local distance_expression="$2" - mkdir -p "$directory" - - cat > "$directory/Main.cs" <<'EOF' -using UIKit; -UIApplication.Main(args, null, typeof(MapsConsumer.AppDelegate)); -EOF - - cat > "$directory/AppDelegate.cs" < "$directory/Info.plist" <<'EOF' - - - - - CFBundleIdentifier - com.googleapisforioscomponents.tests.mapsconsumer - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - -EOF -} - -expected_manifest="$work/expected-bundle-files.txt" - -locate_upstream_bundle() { - local matches count - matches="$(find "$xbd_dir" -type d -path '*/Maps/Resources/GoogleMapsResources/GoogleMaps.bundle' -print)" - count="$(print -r -- "$matches" | sed '/^$/d' | wc -l | tr -d ' ')" - if [[ "$count" != "1" ]]; then - print -r -- "expected one extracted upstream GoogleMaps.bundle, found $count" >&2 - return 1 - fi - print -r -- "$matches" -} - -write_file_manifest() { - local root="$1" - local destination="$2" - (cd "$root" && find . -type f -print | sed 's|^\./||' | LC_ALL=C sort) > "$destination" -} - -assert_app() { - local shape="$1" - local app_path="$2" - local upstream_bundle bundle_count app_bundle actual_manifest content_mismatches - local binary_name binary symbol_count relative_path - - if [[ -z "$app_path" || ! -d "$app_path" ]]; then - fail "$shape: app bundle was not produced" - return - fi - - if ! upstream_bundle="$(locate_upstream_bundle)"; then - fail "$shape: could not resolve the extracted upstream resource bundle" - return - fi - if [[ ! -s "$expected_manifest" ]]; then - write_file_manifest "$upstream_bundle" "$expected_manifest" - fi - - bundle_count="$(find "$app_path" -type d -name "$resource_bundle" -print | wc -l | tr -d ' ')" - if [[ "$bundle_count" != "1" ]]; then - fail "$shape: expected exactly one $resource_bundle, found $bundle_count" - return - fi - - app_bundle="$(find "$app_path" -type d -name "$resource_bundle" -print -quit)" - if [[ "$app_bundle" != "$app_path/$resource_bundle" ]]; then - fail "$shape: $resource_bundle is not at the app root ($app_bundle)" - else - pass "$shape: exactly one root $resource_bundle is present" - fi - - actual_manifest="$work/$shape-bundle-files.txt" - write_file_manifest "$app_bundle" "$actual_manifest" - if diff -u "$expected_manifest" "$actual_manifest" > "$work/$shape-bundle.diff"; then - pass "$shape: bundle file set matches upstream ($(wc -l < "$actual_manifest" | tr -d ' ') files)" - - content_mismatches="$work/$shape-content-mismatches.txt" - : > "$content_mismatches" - while IFS= read -r relative_path; do - if ! cmp -s "$upstream_bundle/$relative_path" "$app_bundle/$relative_path"; then - print -r -- "$relative_path" >> "$content_mismatches" - fi - done < "$expected_manifest" - - if [[ -s "$content_mismatches" ]]; then - fail "$shape: bundle contents differ from upstream (see $content_mismatches)" - else - pass "$shape: bundle contents are byte-for-byte identical to upstream" - fi - else - fail "$shape: bundle file set differs from upstream (see $work/$shape-bundle.diff)" - fi - - binary_name="$(/usr/bin/plutil -extract CFBundleExecutable raw -o - "$app_path/Info.plist" 2>/dev/null || true)" - [[ -z "$binary_name" ]] && binary_name="${${app_path:t}%.app}" - binary="$app_path/$binary_name" - if [[ ! -f "$binary" ]]; then - fail "$shape: app executable is missing at $binary" - return - fi - - symbol_count="$(nm -U "$binary" 2>/dev/null | awk '$NF == "_GMSGeometryDistance" { count++ } END { print count + 0 }')" - if [[ "${symbol_count:-0}" -gt 0 ]]; then - pass "$shape: GMSGeometryDistance is linked into the app" - else - fail "$shape: GMSGeometryDistance is absent from the app binary" - fi - - if find "$app_path/Frameworks" -type d -name 'GoogleMaps.framework' -print -quit 2>/dev/null | grep -c . >/dev/null; then - fail "$shape: static GoogleMaps.framework was unexpectedly copied into App.app/Frameworks" - else - pass "$shape: no dynamic GoogleMaps.framework copy is present" - fi -} - -build_and_assert() { - local shape="$1" - local project="$2" - local assembly_name="$3" - local project_directory="${project:h}" - local log="$work/$shape.log" - local restored_nupkg="$packages_dir/${package_id:l}/$package_version/${package_id:l}.$package_version.nupkg" - - if ! dotnet restore "$project" \ - --configfile "$work/NuGet.config" \ - --packages "$packages_dir" \ - "${msbuild_args[@]}" > "$log" 2>&1; then - fail "$shape: restore failed (see $log)" - tail -25 "$log" >&2 - return - fi - - if [[ ! -f "$restored_nupkg" ]]; then - fail "$shape: the restored package is missing at $restored_nupkg" - return - elif ! cmp -s "$nupkg" "$restored_nupkg"; then - fail "$shape: restore did not consume the locally packed Maps package" - return - else - pass "$shape: restore consumed the locally packed Maps package" - fi - - if ! dotnet build "$project" \ - --configuration Debug \ - --no-restore \ - "${msbuild_args[@]}" >> "$log" 2>&1; then - fail "$shape: build failed (see $log)" - tail -25 "$log" >&2 - return - fi - - assert_app "$shape" "$(find "$project_directory/bin" -type d -name "$assembly_name.app" -print -quit)" -} - -echo -echo "Shape: direct (app -> package)" -direct="$work/direct" -write_app_sources "$direct" 'Google.Maps.GeometryUtils.Distance(new CoreLocation.CLLocationCoordinate2D(0, 0), new CoreLocation.CLLocationCoordinate2D(1, 1))' -cat > "$direct/DirectApp.csproj" < - $app_properties - DirectApp - MapsConsumer - - - - - -EOF -build_and_assert "direct" "$direct/DirectApp.csproj" "DirectApp" - -echo -echo "Shape: library (app -> class library -> package)" -library_root="$work/library" -mkdir -p "$library_root/Lib" -cat > "$library_root/Lib/MapsLib.csproj" < - - net10.0-ios - Library - enable - 15.0 - false - - - - - -EOF -cat > "$library_root/Lib/Probe.cs" <<'EOF' -namespace MapsConsumer.Library; - -public static class Probe -{ - public static double Distance() => Google.Maps.GeometryUtils.Distance( - new CoreLocation.CLLocationCoordinate2D(0, 0), - new CoreLocation.CLLocationCoordinate2D(1, 1)); -} -EOF - -write_app_sources "$library_root/App" 'MapsConsumer.Library.Probe.Distance()' -cat > "$library_root/App/LibraryApp.csproj" < - $app_properties - LibraryApp - MapsConsumer - - - - - -EOF -build_and_assert "library" "$library_root/App/LibraryApp.csproj" "LibraryApp" - -echo -if (( failures > 0 )); then - echo "$failures Maps consumer check(s) failed." >&2 - exit 1 -fi - -completed="true" -echo "All Maps consumer checks passed." diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj index 69cef9aa..6222f123 100644 --- a/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj @@ -16,13 +16,11 @@ false - + Places 7.4.0.3 + 9.2.0.8 @@ -41,10 +39,18 @@ $(DefineConstants);ENABLE_TARGET_PLACES + + $(DefineConstants);ENABLE_TARGET_MAPS + + + + + + <_Parameter1>GoogleE2ETarget @@ -58,5 +64,9 @@ <_Parameter1>TargetPackageVersion <_Parameter2>$(PlacesPackageVersion) + + <_Parameter1>TargetPackageVersion + <_Parameter2>$(MapsPackageVersion) + diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs index 8b0d367e..11356879 100644 --- a/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs @@ -43,6 +43,11 @@ public static async Task RunAsync(StatusViewController statusViewController) case "Places": await PlacesCases.RunAsync(result, statusViewController, ExecuteCaseAsync); break; +#endif +#if ENABLE_TARGET_MAPS + case "Maps": + await MapsCases.RunAsync(result, statusViewController, ExecuteCaseAsync); + break; #endif default: throw new NotSupportedException( diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/MapsCases.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/MapsCases.cs new file mode 100644 index 00000000..af65fa7f --- /dev/null +++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/MapsCases.cs @@ -0,0 +1,167 @@ +#if ENABLE_TARGET_MAPS +using System.Runtime.InteropServices; +using CoreLocation; +using Foundation; +using ObjCRuntime; +using MapsMapView = Google.Maps.MapView; + +namespace GoogleFoundationE2E; + +/// +/// Delivery-focused checks for AdamE.Google.iOS.Maps. +/// +/// These checks require neither an API key nor network access. They prove that the managed binding, +/// native symbols, Objective-C classes, and packaged resources reached the app without initializing +/// the Maps service or contacting its backend. +/// +public static class MapsCases +{ + const string BundleName = "GoogleMaps"; + const int ExpectedBundleFileCount = 190; + + public static async Task RunAsync( + GoogleE2ERunResult result, + StatusViewController status, + Func>, Task> execute) + { + await execute(result, status, "maps-managed-binding-loads", VerifyManagedBindingLoadsAsync); + await execute(result, status, "maps-pinvoke-linkage", VerifyPInvokeLinkageAsync); + await execute(result, status, "maps-objc-class-lookup", VerifyObjCClassLookupAsync); + await execute(result, status, "maps-resource-bundle-present", VerifyResourceBundlePresentAsync); + await execute(result, status, "maps-resource-bundle-contents", VerifyResourceBundleContentsAsync); + } + + static Task VerifyManagedBindingLoadsAsync() + { + var type = typeof(MapsMapView); + var name = type.Assembly.GetName(); + + return Task.FromResult($"{type.FullName} loaded from {name.Name} {name.Version}."); + } + + [DllImport("__Internal", EntryPoint = "GMSGeometryDistance")] + static extern double GMSGeometryDistance( + CLLocationCoordinate2D fromCoordinate, + CLLocationCoordinate2D toCoordinate); + + static Task VerifyPInvokeLinkageAsync() + { + var fromCoordinate = new CLLocationCoordinate2D(37.7749, -122.4194); + var toCoordinate = new CLLocationCoordinate2D(37.7849, -122.4094); + + try + { + var distance = GMSGeometryDistance(fromCoordinate, toCoordinate); + if (!double.IsFinite(distance) || distance <= 0) + { + throw new InvalidOperationException( + $"GMSGeometryDistance resolved but returned invalid distance {distance}."); + } + + return Task.FromResult($"GMSGeometryDistance resolved and returned {distance:F2} meters."); + } + catch (EntryPointNotFoundException ex) + { + throw new InvalidOperationException( + "GMSGeometryDistance did not resolve, so the GoogleMaps 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 GoogleMaps native framework " + + "was not linked into the app. This is a package delivery failure.", ex); + } + } + + static Task VerifyObjCClassLookupAsync() + { + string[] expectedClasses = ["GMSMapView", "GMSCameraPosition", "GMSMarker"]; + + 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)}."); + } + + static Task VerifyResourceBundlePresentAsync() + { + var appBundlePath = NSBundle.MainBundle.BundlePath; + var expectedBundlePath = Path.Combine(appBundlePath, BundleName + ".bundle"); + var matches = Directory + .GetDirectories(appBundlePath, BundleName + ".bundle", SearchOption.AllDirectories) + .Select(Path.GetFullPath) + .ToArray(); + + if (matches.Length != 1) + { + throw new InvalidOperationException( + $"Expected exactly one {BundleName}.bundle in the app, found {matches.Length}: " + + string.Join(", ", matches)); + } + + if (!string.Equals(matches[0], Path.GetFullPath(expectedBundlePath), StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"{BundleName}.bundle was delivered at {matches[0]}, expected the app root at " + + $"{expectedBundlePath}."); + } + + return Task.FromResult($"Exactly one root {BundleName}.bundle resolved at {matches[0]}."); + } + + static Task VerifyResourceBundleContentsAsync() + { + var bundlePath = Path.Combine(NSBundle.MainBundle.BundlePath, BundleName + ".bundle"); + if (!Directory.Exists(bundlePath)) + { + throw new InvalidOperationException($"{BundleName}.bundle was not found at the app root."); + } + + string[] expectedFiles = + [ + "Info.plist", + "PrivacyInfo.xcprivacy", + "Assets.car", + Path.Combine("GMSCacheStorage.momd", "Storage.mom"), + Path.Combine("GMSCoreResources.bundle", "en.lproj", "GMSCore.strings"), + ]; + + var missingOrEmpty = expectedFiles + .Where(relative => + { + var path = Path.Combine(bundlePath, relative); + return !File.Exists(path) || new FileInfo(path).Length == 0; + }) + .ToArray(); + + if (missingOrEmpty.Length > 0) + { + throw new InvalidOperationException( + $"{BundleName}.bundle is missing expected non-empty files: " + + string.Join(", ", missingOrEmpty)); + } + + var fileCount = Directory.GetFiles(bundlePath, "*", SearchOption.AllDirectories).Length; + if (fileCount != ExpectedBundleFileCount) + { + throw new InvalidOperationException( + $"{BundleName}.bundle contains {fileCount} files; expected the " + + $"{ExpectedBundleFileCount}-file Maps 9.2.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/README.md b/tests/E2E/Google.Foundation/README.md index 90947090..bbcea086 100644 --- a/tests/E2E/Google.Foundation/README.md +++ b/tests/E2E/Google.Foundation/README.md @@ -10,7 +10,8 @@ binding-layer failures such as `EntryPointNotFoundException`, `DllNotFoundExcept ## Current scope and baseline -The sole runtime adapter is `AdamE.Google.iOS.Places` 7.4.0.3. +Runtime adapters currently cover `AdamE.Google.iOS.Places` 7.4.0.3 and +`AdamE.Google.iOS.Maps` 9.2.0.8. The behavioral expectations were established against the 7.4.0.2 pre-migration package, which used `Xamarin.Build.Download` to fetch the Google Places SDK during the consumer build. The harness does @@ -25,13 +26,24 @@ The Places adapter verifies: - The bundle contains the baseline's 59 files, including representative data, localized string, and image files. -The companion `check-package-structure.sh` and `check-offline-build.sh` scripts inspect the package -and prove the consumer build no longer downloads native content. The harness does not compare stored -baselines, exercise a physical device, or test the Places backend. +The Maps expectations are established against the 9.2.0.8 package while it still uses +`Xamarin.Build.Download`. Like the Places adapter, its checks are independent of how the SDK arrives. +The Maps adapter verifies: + +- `Google.Maps.MapView` loads from the restored binding assembly. +- `GMSGeometryDistance` resolves and returns a finite, positive distance. +- `GMSMapView`, `GMSCameraPosition`, and `GMSMarker` are present in the Objective-C runtime. +- Exactly one `GoogleMaps.bundle` is present at the app root. +- The bundle contains the upstream SDK's 190 files, including representative privacy, asset, model, + and nested resource files. + +For self-contained targets, the companion `check-package-structure.sh` and `check-offline-build.sh` +scripts inspect the package and prove the consumer build no longer downloads native content. The +harness does not compare stored baselines, exercise a physical device, or test Google backends. ## Run it -Pack Places, then launch the runtime checks on an available iPhone simulator: +Pack a target, then launch its runtime checks on an available iPhone simulator: ```sh dotnet tool restore @@ -39,8 +51,10 @@ 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 +For the Maps baseline, replace `Google.Places` and `Places` with `Google.Maps` and `Maps`. + +If the package directory contains exactly one matching nonsymbol package, 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: diff --git a/tools/e2e/run-google-foundation.sh b/tools/e2e/run-google-foundation.sh index c226b657..5b816d0b 100755 --- a/tools/e2e/run-google-foundation.sh +++ b/tools/e2e/run-google-foundation.sh @@ -62,9 +62,13 @@ mkdir -p "$artifacts_dir" 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. +# Map each runtime adapter to its package and version property. Every supported target must have +# explicit package-specific runtime cases in the harness. case "$target" in + Maps) + package_id="AdamE.Google.iOS.Maps" + package_version_property="MapsPackageVersion" + ;; Places) package_id="AdamE.Google.iOS.Places" package_version_property="PlacesPackageVersion"