|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +# Opt-in "shape-check" suite for firebase_spm.rb's Xcodeproj/CocoaPods API |
| 4 | +# assumptions -- a companion to firebase_spm_test.rb, not a replacement for it. |
| 5 | +# |
| 6 | +# firebase_spm_test.rb mocks Xcodeproj/CocoaPods classes (MockAggregateTarget, |
| 7 | +# MockInstaller, MockRootObject, MockBuildConfig, MockTarget, MockUserProject, |
| 8 | +# MockBuildType, etc.) so firebase_spm.rb's post-install logic can be |
| 9 | +# unit-tested without CocoaPods/Xcodeproj installed. That's fast and |
| 10 | +# dependency-free, but it has a real structural ceiling: a mock can only ever |
| 11 | +# be as accurate as whoever wrote it modeled the real class to be. This exact |
| 12 | +# PR shipped a bug of that class -- `MockAggregateTarget#build_as_static?` |
| 13 | +# modeled the real `Pod::Target#build_as_static?` as a directly-settable |
| 14 | +# flag, when the real method is unconditionally `true` for every aggregate |
| 15 | +# target regardless of the Podfile's requested linkage (the real per-install |
| 16 | +# signal is `target_definition.build_type.static?` instead, which is what |
| 17 | +# `MockAggregateTarget`/`MockBuildType` model today -- see their comments in |
| 18 | +# firebase_spm_test.rb) -- so the mock could never exercise the actual bug, |
| 19 | +# and it only surfaced later, via a real `pod install`. |
| 20 | +# |
| 21 | +# This file guards against that recurring: it asserts, against the REAL |
| 22 | +# `xcodeproj`/`cocoapods` gems, that every mocked class/method in |
| 23 | +# firebase_spm_test.rb still has the shape those mocks assume. If a future |
| 24 | +# CocoaPods/Xcodeproj release changes that shape, this file fails in seconds |
| 25 | +# instead of the failure only surfacing ~20 minutes into a real `pod install` |
| 26 | +# in the E2E jobs. |
| 27 | +# |
| 28 | +# Deliberately opt-in: skips cleanly (does not fail, does not define any |
| 29 | +# tests) when `cocoapods`/`xcodeproj` aren't installed, so it's always safe to |
| 30 | +# run unconditionally in CI -- see the "Test Firebase SPM Helper" step in |
| 31 | +# tests_jest.yml, which deliberately never installs these gems (that job must |
| 32 | +# keep passing without them), and the "Verify Firebase SPM Xcodeproj/CocoaPods |
| 33 | +# API shape" step added to the `other` job in tests_e2e_other.yml, which does |
| 34 | +# have them (it reuses that job's own "Update Ruby build tools" step -- no new |
| 35 | +# gem install, no new CI job). |
| 36 | +begin |
| 37 | + require 'xcodeproj' |
| 38 | + require 'cocoapods' |
| 39 | + RNFIREBASE_SPM_SHAPE_CHECK_GEMS_AVAILABLE = true |
| 40 | +rescue LoadError => e |
| 41 | + RNFIREBASE_SPM_SHAPE_CHECK_GEMS_AVAILABLE = false |
| 42 | + puts "[firebase_spm_shape_test] Skipping: `xcodeproj`/`cocoapods` aren't " \ |
| 43 | + "installed in this Ruby environment (#{e.class}: #{e.message}). This is " \ |
| 44 | + 'expected outside of the tests_e2e_other.yml CI job -- see the header ' \ |
| 45 | + 'comment in this file.' |
| 46 | +end |
| 47 | + |
| 48 | +if RNFIREBASE_SPM_SHAPE_CHECK_GEMS_AVAILABLE |
| 49 | + require 'minitest/autorun' |
| 50 | + require 'tmpdir' |
| 51 | + |
| 52 | + class FirebaseSpmShapeTest < Minitest::Test |
| 53 | + # ── Pod::Installer (installer.aggregate_targets, and the |
| 54 | + # run_podfile_post_install_hooks method |
| 55 | + # rnfirebase_hook_cocoapods_post_install! monkey-patches -- see |
| 56 | + # MockInstaller in firebase_spm_test.rb) ── |
| 57 | + |
| 58 | + def test_installer_responds_to_aggregate_targets |
| 59 | + assert Pod::Installer.method_defined?(:aggregate_targets), |
| 60 | + 'Pod::Installer no longer exposes #aggregate_targets -- every ' \ |
| 61 | + 'rnfirebase_* post-install helper in firebase_spm.rb reads ' \ |
| 62 | + 'installer.aggregate_targets directly.' |
| 63 | + end |
| 64 | + |
| 65 | + # We can't construct a real Pod::Installer without a full `pod install` |
| 66 | + # context (a real Podfile, sandbox, lockfile, etc.), so this only checks |
| 67 | + # for the method's existence (public or private -- firebase_spm.rb's own |
| 68 | + # `was_private = installer_class.private_method_defined?(hook_method)` |
| 69 | + # guard handles either), not that aliasing/patching it actually works |
| 70 | + # end-to-end. That deeper integration is exactly what a real `pod |
| 71 | + # install` in the E2E jobs continues to cover. |
| 72 | + def test_installer_defines_run_podfile_post_install_hooks |
| 73 | + hook_defined = Pod::Installer.method_defined?(:run_podfile_post_install_hooks) || |
| 74 | + Pod::Installer.private_method_defined?(:run_podfile_post_install_hooks) |
| 75 | + assert hook_defined, |
| 76 | + "Pod::Installer#run_podfile_post_install_hooks no longer exists (public or " \ |
| 77 | + 'private) -- this is the exact method rnfirebase_hook_cocoapods_post_install! ' \ |
| 78 | + 'aliases and wraps so our post-install logic runs automatically on every ' \ |
| 79 | + '`pod install`.' |
| 80 | + end |
| 81 | + |
| 82 | + # ── Pod::AggregateTarget / Pod::Podfile::TargetDefinition / Pod::BuildType |
| 83 | + # (the real target_definition.build_type.static? signal |
| 84 | + # rnfirebase_fail_if_spm_static_linkage! depends on -- see |
| 85 | + # MockAggregateTarget/MockBuildType/MockTargetDefinition in |
| 86 | + # firebase_spm_test.rb, and that file's comment on the exact bug this |
| 87 | + # replaced) ── |
| 88 | + |
| 89 | + def test_aggregate_target_responds_to_target_definition_and_user_project |
| 90 | + assert Pod::AggregateTarget.method_defined?(:target_definition), |
| 91 | + 'Pod::AggregateTarget#target_definition no longer exists -- ' \ |
| 92 | + 'rnfirebase_fail_if_spm_static_linkage! reads ' \ |
| 93 | + '`target.target_definition.build_type.static?` directly.' |
| 94 | + assert Pod::AggregateTarget.method_defined?(:user_project), |
| 95 | + 'Pod::AggregateTarget#user_project no longer exists -- every other ' \ |
| 96 | + 'rnfirebase_* post-install helper walks `aggregate_target.user_project` ' \ |
| 97 | + 'to reach the consumer app\'s own Xcode project.' |
| 98 | + end |
| 99 | + |
| 100 | + def test_target_definition_responds_to_build_type |
| 101 | + assert Pod::Podfile::TargetDefinition.method_defined?(:build_type), |
| 102 | + 'Pod::Podfile::TargetDefinition#build_type no longer exists -- this is ' \ |
| 103 | + 'the real object `target.target_definition` resolves to.' |
| 104 | + end |
| 105 | + |
| 106 | + def test_build_type_responds_to_static |
| 107 | + assert Pod::BuildType.method_defined?(:static?), |
| 108 | + 'Pod::BuildType#static? no longer exists -- this is the exact real ' \ |
| 109 | + 'signal rnfirebase_fail_if_spm_static_linkage! branches on (replacing ' \ |
| 110 | + 'the old, always-true AggregateTarget#build_as_static? assumption).' |
| 111 | + end |
| 112 | + |
| 113 | + # Not just a shape check: unlike Pod::Installer/Pod::AggregateTarget |
| 114 | + # (which need a full `pod install` to construct), Pod::BuildType has |
| 115 | + # public, no-install-context factory methods -- so this exercises the |
| 116 | + # exact real values rnfirebase_fail_if_spm_static_linkage! branches on, |
| 117 | + # not just that the method name still exists. |
| 118 | + def test_build_type_static_matches_real_linkage_semantics |
| 119 | + refute Pod::BuildType.dynamic_framework.static? |
| 120 | + refute Pod::BuildType.dynamic_library.static? |
| 121 | + assert Pod::BuildType.static_framework.static? |
| 122 | + assert Pod::BuildType.static_library.static? |
| 123 | + end |
| 124 | + |
| 125 | + # ── Xcodeproj::Project::Object::XCRemoteSwiftPackageReference / |
| 126 | + # XCSwiftPackageProductDependency (the two classes |
| 127 | + # rnfirebase_add/remove_spm_core_to/from_app_target read and write |
| 128 | + # directly -- see the Mock stand-ins defined under the real |
| 129 | + # `Xcodeproj` namespace in firebase_spm_test.rb) ── |
| 130 | + |
| 131 | + def test_swift_package_reference_and_product_dependency_shape |
| 132 | + project = new_scratch_project |
| 133 | + pkg_class = Xcodeproj::Project::Object::XCRemoteSwiftPackageReference |
| 134 | + ref_class = Xcodeproj::Project::Object::XCSwiftPackageProductDependency |
| 135 | + |
| 136 | + pkg = project.new(pkg_class) |
| 137 | + pkg.repositoryURL = 'https://github.com/firebase/firebase-ios-sdk.git' |
| 138 | + pkg.requirement = { kind: 'upToNextMajorVersion', minimumVersion: '12.10.0' } |
| 139 | + assert_equal 'https://github.com/firebase/firebase-ios-sdk.git', pkg.repositoryURL |
| 140 | + assert_equal({ kind: 'upToNextMajorVersion', minimumVersion: '12.10.0' }, pkg.requirement) |
| 141 | + |
| 142 | + ref = project.new(ref_class) |
| 143 | + ref.product_name = 'FirebaseCore' |
| 144 | + ref.package = pkg |
| 145 | + assert_equal 'FirebaseCore', ref.product_name |
| 146 | + assert_same pkg, ref.package |
| 147 | + |
| 148 | + # firebase_spm_test.rb's own Mock stand-ins for these two classes |
| 149 | + # (`package=`/`remove_from_project`) assume real Xcodeproj auto-tracks |
| 150 | + # referrers on a has_one assignment, and unwinds that tracking on |
| 151 | + # removal -- confirm that's still true against the real gem. |
| 152 | + assert_includes pkg.referrers, ref |
| 153 | + ref.remove_from_project |
| 154 | + refute_includes pkg.referrers, ref |
| 155 | + end |
| 156 | + |
| 157 | + # ── Xcodeproj::Project::Object::PBXProject (`root_object`) and |
| 158 | + # Xcodeproj::Project itself (`native_targets`, `new`, `save`) -- see |
| 159 | + # MockRootObject/MockUserProject in firebase_spm_test.rb ── |
| 160 | + |
| 161 | + def test_project_and_root_object_shape |
| 162 | + project = new_scratch_project |
| 163 | + assert_respond_to project, :native_targets |
| 164 | + assert_respond_to project, :save |
| 165 | + assert_respond_to project, :new |
| 166 | + assert_respond_to project.root_object, :package_references |
| 167 | + assert_empty project.root_object.package_references |
| 168 | + end |
| 169 | + |
| 170 | + # ── Xcodeproj::Project::Object::AbstractTarget / PBXNativeTarget (the |
| 171 | + # real target methods rnfirebase_upsert_shell_script_phase! and |
| 172 | + # rnfirebase_add/remove_spm_core_to/from_app_target call directly -- |
| 173 | + # see MockTarget/MockPhase in firebase_spm_test.rb) ── |
| 174 | + |
| 175 | + def test_native_target_shell_script_phase_shape |
| 176 | + project = new_scratch_project |
| 177 | + target = project.new(Xcodeproj::Project::Object::PBXNativeTarget) |
| 178 | + |
| 179 | + assert_respond_to target, :shell_script_build_phases |
| 180 | + assert_respond_to target, :new_shell_script_build_phase |
| 181 | + assert_respond_to target, :build_configurations |
| 182 | + assert_respond_to target, :build_settings |
| 183 | + |
| 184 | + phase = target.new_shell_script_build_phase('[RNFB] Shape Check') |
| 185 | + assert_includes target.shell_script_build_phases, phase |
| 186 | + %i[name shell_script shell_path always_out_of_date input_paths output_paths].each do |accessor| |
| 187 | + assert_respond_to phase, accessor |
| 188 | + assert_respond_to phase, "#{accessor}=" |
| 189 | + end |
| 190 | + end |
| 191 | + |
| 192 | + # `package_product_dependencies` only exists on `PBXNativeTarget`, not on |
| 193 | + # every `AbstractTarget` subclass -- production code's |
| 194 | + # `target.respond_to?(:package_product_dependencies)` guard (in |
| 195 | + # rnfirebase_add_spm_core_to_app_target and |
| 196 | + # rnfirebase_remove_spm_core_from_app_target) exists specifically because |
| 197 | + # of this asymmetry, so it's worth asserting both sides of it. |
| 198 | + def test_only_native_target_has_package_product_dependencies |
| 199 | + assert Xcodeproj::Project::Object::PBXNativeTarget.method_defined?(:package_product_dependencies) |
| 200 | + refute Xcodeproj::Project::Object::PBXAggregateTarget.method_defined?(:package_product_dependencies) |
| 201 | + refute Xcodeproj::Project::Object::PBXLegacyTarget.method_defined?(:package_product_dependencies) |
| 202 | + end |
| 203 | + |
| 204 | + # ── Xcodeproj::Project::Object::XCBuildConfiguration (the element type of |
| 205 | + # AbstractTarget#build_configurations -- MockBuildConfig's real |
| 206 | + # counterpart in firebase_spm_test.rb) ── |
| 207 | + |
| 208 | + def test_build_configuration_responds_to_name |
| 209 | + assert Xcodeproj::Project::Object::XCBuildConfiguration.method_defined?(:name) |
| 210 | + end |
| 211 | + |
| 212 | + private |
| 213 | + |
| 214 | + # A real, in-memory `Xcodeproj::Project` -- `Xcodeproj::Project.new(path)` |
| 215 | + # never touches disk by itself (it only builds the default in-memory |
| 216 | + # object graph), so this is safe to construct in every test without ever |
| 217 | + # calling `#save`. `path` only needs to be a plausible location, not an |
| 218 | + # existing `.xcodeproj`. |
| 219 | + def new_scratch_project |
| 220 | + Xcodeproj::Project.new(File.join(Dir.mktmpdir('rnfb-spm-shape-check'), 'ShapeCheck.xcodeproj')) |
| 221 | + end |
| 222 | + end |
| 223 | +end |
0 commit comments