diff --git a/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb b/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb new file mode 100644 index 000000000000..89a308293c37 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb @@ -0,0 +1,100 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require "test/unit" +require "fileutils" +require "cocoapods" +require_relative "../spm.rb" + +# These tests exercise the real `Pod::Project` UUID machinery (via the +# `xcodeproj`/`cocoapods` gems) because the bug they guard against is emergent +# from how `Pod::Project` hands out UUIDs, and cannot be observed against a mock. +class SPMTests < Test::Unit::TestCase + PodSpecStub = Struct.new(:name) + InstallerStub = Struct.new(:pods_project) + + POD_NAME = "ReactNativeEnrichedMarkdown" + TMP_DIR = File.join(Dir.tmpdir, "rn-spm-test") + + def setup + FileUtils.rm_rf(TMP_DIR) + FileUtils.mkdir_p(TMP_DIR) + end + + def teardown + FileUtils.rm_rf(TMP_DIR) + end + + def build_project(num_pods) + path = File.join(TMP_DIR, "Pods.xcodeproj") + FileUtils.mkdir_p(path) + project = Pod::Project.new(path) + num_pods.times { |i| project.new_target(:static_library, "Pod#{i}", :ios) } + project.new_target(:static_library, POD_NAME, :ios) + project.save + project + end + + def inject_spm(project) + manager = SPMManager.new + manager.dependency( + PodSpecStub.new(POD_NAME), + url: "https://github.com/software-mansion-labs/RaTeX.git", + requirement: { kind: "upToNextMajorVersion", minimumVersion: "0.1.0" }, + products: ["RaTeX"] + ) + manager.apply_on_post_install(InstallerStub.new(project)) + project.save + end + + # Simulates the state after an on-disk reload / incremental `pod install`: + # existing objects keep their counter-based UUIDs, but the generator counters + # reset to zero. This is what makes `Pod::Project#generate_uuid` hand back a + # UUID (`00000000`) that already belongs to the root object. + def simulate_reload(project) + project.instance_variable_set(:@generated_uuids, []) + project.instance_variable_set(:@available_uuids, []) + end + + def assert_loadable_project(path) + reopened = nil + assert_nothing_raised("Pods project must reload cleanly after SPM injection") do + reopened = Xcodeproj::Project.open(path) + end + root_uuid = reopened.root_object.uuid + assert( + reopened.objects_by_uuid[root_uuid].is_a?(Xcodeproj::Project::Object::PBXProject), + "rootObject UUID must resolve to a PBXProject" + ) + package_uuids = reopened.root_object.package_references.map(&:uuid) + assert( + package_uuids.none? { |uuid| uuid == root_uuid }, + "injected package reference must not collide with the root object UUID" + ) + reopened + end + + def test_spm_injection_on_freshly_generated_project_reloads_cleanly + project = build_project(88) + inject_spm(project) + assert_loadable_project(project.path) + end + + def test_spm_injection_after_project_reload_does_not_collide_with_root_object + project = build_project(88) + simulate_reload(project) + inject_spm(project) + assert_loadable_project(project.path) + end + + def test_injected_uuids_are_unique_across_all_objects + project = build_project(88) + simulate_reload(project) + inject_spm(project) + reopened = assert_loadable_project(project.path) + uuids = reopened.objects.map(&:uuid) + assert_equal(uuids.length, uuids.uniq.length, "all object UUIDs must be unique") + end +end diff --git a/packages/react-native/scripts/cocoapods/spm.rb b/packages/react-native/scripts/cocoapods/spm.rb index 0995dfbfc972..baa2a04ee9d0 100644 --- a/packages/react-native/scripts/cocoapods/spm.rb +++ b/packages/react-native/scripts/cocoapods/spm.rb @@ -53,6 +53,29 @@ def apply_on_post_install(installer) private + # Creates a new object in the project with a UUID guaranteed not to collide + # with any UUID already present in the project. + # + # `Pod::Project` overrides `generate_available_uuid_list` with a fast, + # counter-based scheme (`0`) that deliberately skips + # collision checks, on the assumption that the whole Pods project is generated + # in a single pass. That assumption does not hold here: we run in a + # `post_install` hook, and the generator's counter can be out of sync with the + # UUIDs already assigned to existing objects (e.g. when the project has been + # reloaded from disk during an incremental install, the counter restarts at 0 + # while the root object still occupies `00000000`). Using `project.new` + # directly can therefore hand back a UUID that is already in use and overwrite + # an existing object (notably the root `PBXProject`), producing a Pods project + # Xcode refuses to load. We keep the deterministic scheme but probe forward + # until we find a UUID that is actually free. + def new_object(project, klass) + uuid = project.generate_uuid + uuid = project.generate_uuid while project.objects_by_uuid.key?(uuid) + object = klass.new(project, uuid) + object.initialize_defaults + object + end + def log(msg) ::Pod::UI.puts "[SPM] #{msg}" end @@ -76,7 +99,7 @@ def add_spm_to_target(project, target, url, requirement, products) pkg_class = Xcodeproj::Project::Object::XCLocalSwiftPackageReference pkg = project.root_object.package_references.find { |p| p.class == pkg_class && p.relative_path == url } if !pkg - pkg = project.new(pkg_class) + pkg = new_object(project, pkg_class) pkg.relative_path = url log(" Adding local package to workspace: #{pkg.inspect}") project.root_object.package_references << pkg @@ -85,7 +108,7 @@ def add_spm_to_target(project, target, url, requirement, products) pkg_class = Xcodeproj::Project::Object::XCRemoteSwiftPackageReference pkg = project.root_object.package_references.find { |p| p.class == pkg_class && p.repositoryURL == url } if !pkg - pkg = project.new(pkg_class) + pkg = new_object(project, pkg_class) pkg.repositoryURL = url pkg.requirement = requirement log(" Adding remote package to workspace: #{pkg.inspect}") @@ -101,7 +124,7 @@ def add_spm_to_target(project, target, url, requirement, products) next if ref log(" Adding product dependency #{product_name} to #{target.name}") - ref = project.new(ref_class) + ref = new_object(project, ref_class) ref.package = pkg ref.product_name = product_name target.package_product_dependencies << ref