Skip to content

Commit 24c2724

Browse files
russellwheatleymikehardy
authored andcommitted
test(app, ios): add opt-in Xcodeproj/CocoaPods shape-check suite, cite mocks
Add firebase_spm_shape_test.rb: an opt-in Minitest suite that asserts, against the real xcodeproj/cocoapods gems, that every class/method firebase_spm_test.rb mocks still has the shape those mocks assume. Skips cleanly when the gems aren't installed (e.g. the dependency-free "Test Firebase SPM Helper" CI step), and runs for real in tests_e2e_other.yml's macOS job right after its existing `gem update cocoapods xcodeproj` step -- no new job, no new gem installs. This is the fast, seconds-long check that would have caught the build_as_static?-is-always-true false positive that previously shipped past the mocked suite and only surfaced via a real pod install. Also retrofit real-class/method citation comments onto the four Mock* classes in firebase_spm_test.rb that were missing them (MockRootObject, MockBuildConfig, MockAggregateTarget, MockInstaller), matching the convention already used on MockBuildType and the Xcodeproj module stand-ins. Verified against the real gems (system Ruby 2.6, cocoapods 1.16.2/xcodeproj 1.27.0): 11 runs, 46 assertions, 0 failures.
1 parent 095690a commit 24c2724

3 files changed

Lines changed: 253 additions & 0 deletions

File tree

.github/workflows/tests_e2e_other.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ jobs:
201201
max_attempts: 3
202202
command: gem update cocoapods xcodeproj
203203

204+
- name: Verify Firebase SPM Xcodeproj/CocoaPods API shape
205+
run: ruby packages/app/__tests__/firebase_spm_shape_test.rb
206+
204207
- name: Pods Cache Restore
205208
# https://github.com/actions/cache/releases
206209
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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

packages/app/__tests__/firebase_spm_test.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ def save
7272
end
7373
end
7474

75+
# Stands in for `Xcodeproj::Project::Object::PBXProject` (the real
76+
# `Xcodeproj::Project#root_object`) -- specifically its `package_references`
77+
# attribute (a `has_many :package_references, [XCRemoteSwiftPackageReference,
78+
# XCLocalSwiftPackageReference]` collection), the only part of it
79+
# rnfirebase_add_spm_core_to_app_target / rnfirebase_remove_spm_core_from_app_target
80+
# read and mutate directly.
7581
class MockRootObject
7682
attr_accessor :package_references
7783

@@ -130,6 +136,12 @@ def remove_from_project
130136
end
131137
end
132138

139+
# Stands in for `Xcodeproj::Project::Object::XCBuildConfiguration`, the real
140+
# element type of `AbstractTarget#build_configurations`. Only `name` is
141+
# modeled here -- production code never reads `config.build_settings`
142+
# directly on this object, it always goes through
143+
# `target.build_settings(config.name)` instead (see `MockTarget#build_settings`
144+
# above), so `name` is the only real attribute this mock needs to expose.
133145
class MockBuildConfig
134146
attr_reader :name
135147

@@ -166,6 +178,14 @@ def initialize(static: false)
166178
end
167179
end
168180

181+
# Stands in for `Pod::AggregateTarget` -- specifically its `user_project`
182+
# (`Xcodeproj::Project`) and `target_definition`
183+
# (`Pod::Podfile::TargetDefinition`) attributes, the two real properties
184+
# every `rnfirebase_*` post-install helper in firebase_spm.rb actually reads
185+
# off an `installer.aggregate_targets` entry. Deliberately does *not* model
186+
# `Pod::Target#build_as_static?`/`#build_type` (the real aggregate target's
187+
# own, always-static build type) -- see `MockBuildType`'s comment above for
188+
# why that would model the wrong signal entirely for this check.
169189
class MockAggregateTarget
170190
attr_reader :user_project, :name, :target_definition
171191

@@ -222,6 +242,13 @@ def yellow
222242
end
223243
end
224244

245+
# Stands in for `Pod::Installer` -- specifically its `aggregate_targets`
246+
# attribute (a plain `attr_reader`), the only part of the real installer
247+
# every `rnfirebase_*` post-install helper in firebase_spm.rb actually reads.
248+
# `rnfirebase_hook_cocoapods_post_install!` itself is tested separately,
249+
# against a fake class shaped like `Pod::Installer` (see
250+
# `new_fake_cocoapods_installer_class` below), since there's no real
251+
# `Pod::Installer` to alias/wrap without a full CocoaPods environment.
225252
class MockInstaller
226253
attr_reader :aggregate_targets
227254

0 commit comments

Comments
 (0)