diff --git a/packages/react-native/scripts/spm/__tests__/spm-fixture-app.js b/packages/react-native/scripts/spm/__tests__/spm-fixture-app.js
new file mode 100644
index 00000000000..1b266b79dae
--- /dev/null
+++ b/packages/react-native/scripts/spm/__tests__/spm-fixture-app.js
@@ -0,0 +1,400 @@
+/**
+ * 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.
+ *
+ * @format
+ * @noflow
+ */
+
+'use strict';
+
+// An app on disk for driving `setup-apple-spm.js` end to end: a plain Xcode
+// project with a user-owned scheme, node_modules with one library of each kind
+// the tooling treats differently, and two versions of local artifact slots.
+// Lives beside the tests, not in __fixtures__, because jest.config.js hides
+// scripts/**/__fixtures__/ from the module loader.
+
+const {withProjectSetting} = require('./pbxproj-variants');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const plist = require('plist');
+
+const REACT_NATIVE_ROOT = path.resolve(__dirname, '..', '..', '..');
+const APP_DEPLOYMENT_TARGET = '16.4';
+const ARTIFACT_VERSIONS = ['0.90.0', '0.90.1'];
+const TARGET_UUID = 'AA0000000000000000000101';
+const PLUGIN_FRAMEWORKS = ['AcmeHostKit', 'AcmeHostExtras'];
+
+const USER_SCHEME = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+function write(file, contents) {
+ fs.mkdirSync(path.dirname(file), {recursive: true});
+ fs.writeFileSync(file, contents);
+}
+
+function writeJson(file, value) {
+ write(file, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+function writeFrameworkXcframework(dir, frameworkName, header) {
+ const xcframework = path.join(dir, `${frameworkName}.xcframework`);
+ const identifier = 'ios-arm64_x86_64-simulator';
+ const framework = path.join(
+ xcframework,
+ identifier,
+ `${frameworkName}.framework`,
+ );
+ write(path.join(framework, 'Headers', `${frameworkName}.h`), header);
+ write(
+ path.join(framework, 'Modules', 'module.modulemap'),
+ `framework module ${frameworkName} {\n umbrella header "${frameworkName}.h"\n export *\n}\n`,
+ );
+ write(path.join(framework, frameworkName), 'not-a-real-mach-o');
+ write(
+ path.join(xcframework, 'Info.plist'),
+ plist.build({
+ CFBundlePackageType: 'XFWK',
+ XCFrameworkFormatVersion: '1.0',
+ AvailableLibraries: [
+ {
+ LibraryIdentifier: identifier,
+ LibraryPath: `${frameworkName}.framework`,
+ SupportedArchitectures: ['arm64', 'x86_64'],
+ SupportedPlatform: 'ios',
+ SupportedPlatformVariant: 'simulator',
+ },
+ ],
+ }),
+ );
+ return xcframework;
+}
+
+function writeHeadersXcframework(dir, name, header) {
+ const xcframework = path.join(dir, `${name}.xcframework`);
+ const identifier = 'ios-arm64_x86_64-simulator';
+ write(path.join(xcframework, identifier, 'Headers', `${name}.h`), header);
+ write(
+ path.join(xcframework, 'Info.plist'),
+ plist.build({
+ CFBundlePackageType: 'XFWK',
+ XCFrameworkFormatVersion: '1.0',
+ AvailableLibraries: [
+ {
+ LibraryIdentifier: identifier,
+ LibraryPath: `lib${name}.a`,
+ HeadersPath: 'Headers',
+ SupportedArchitectures: ['arm64', 'x86_64'],
+ SupportedPlatform: 'ios',
+ SupportedPlatformVariant: 'simulator',
+ },
+ ],
+ }),
+ );
+ return xcframework;
+}
+
+// One cache slot, as download-spm-artifacts.js leaves it. Headers carry the
+// version so two versions differ, and are identical across flavors, which
+// flavored-frameworks.js requires.
+function writeArtifactSlot(dir, version) {
+ const header = `// React Native ${version}\nvoid Header(void);\n`;
+ const artifacts = {};
+ for (const [artifactName, frameworkName] of [
+ ['React', 'React'],
+ ['ReactNativeDependencies', 'ReactNativeDependencies'],
+ ['hermes-engine', 'hermesvm'],
+ ]) {
+ artifacts[artifactName] = {
+ xcframeworkPath: writeFrameworkXcframework(dir, frameworkName, header),
+ };
+ }
+ for (const name of ['ReactNativeHeaders', 'ReactNativeDependenciesHeaders']) {
+ artifacts[name] = {
+ xcframeworkPath: writeHeadersXcframework(dir, name, header),
+ };
+ }
+ write(path.join(dir, 'hermes-headers', 'hermes', 'hermes.h'), header);
+ writeJson(path.join(dir, 'artifacts.json'), artifacts);
+}
+
+// Prints the CLI config the way `@react-native-community/cli config` does, so
+// the tooling runs its real config-command path. Relative to the app, so the
+// command pinned into the injection marker names no machine path.
+const AUTOLINKING_CONFIG_SCRIPT = `'use strict';
+const path = require('path');
+const root = __dirname;
+const lib = (name, ios = {}) => {
+ const libRoot = path.join(root, 'node_modules', name);
+ return {name, root: libRoot, platforms: {ios}};
+};
+const config = {
+ root,
+ reactNativePath: path.join(root, 'node_modules', 'react-native'),
+ project: {ios: {sourceDir: path.join(root, 'ios')}},
+ dependencies: {
+ 'react-native-acme-native': lib('react-native-acme-native'),
+ 'react-native-acme-widgets': lib('react-native-acme-widgets', {
+ podspecPath: path.join(
+ root,
+ 'node_modules',
+ 'react-native-acme-widgets',
+ 'react-native-acme-widgets.podspec',
+ ),
+ }),
+ '@acme/react-native-scoped': lib('@acme/react-native-scoped'),
+ 'react-native-acme-host': lib('react-native-acme-host'),
+ },
+};
+process.stdout.write(JSON.stringify(config, null, 2));
+`;
+
+const PLUGIN_SCRIPT = `'use strict';
+const fs = require('fs');
+const path = require('path');
+// Read at call time: Jest caches this module, and the tests change the list.
+module.exports = function plugin() {
+ const names = JSON.parse(
+ fs.readFileSync(path.join(__dirname, 'frameworks.json'), 'utf8'),
+ );
+ const prebuilt = flavor => name =>
+ path.join(__dirname, 'prebuilt', flavor, name + '.xcframework');
+ return {
+ flavoredFrameworks: names.map(name => ({
+ id: name,
+ frameworkName: name,
+ linkage: 'dynamic',
+ flavors: {debug: prebuilt('debug')(name), release: prebuilt('release')(name)},
+ })),
+ watchPaths: [path.join(__dirname, 'precompiled')],
+ };
+};
+`;
+
+function writeLibraries(nodeModules) {
+ // (a) Ships its own Package.swift. The .swiftpm/xcuserdata Xcode keeps in a
+ // package root it opened exists before the first sync, as it does for real.
+ const selfManaged = path.join(nodeModules, 'react-native-acme-native');
+ writeJson(path.join(selfManaged, 'package.json'), {
+ name: 'react-native-acme-native',
+ version: '1.0.0',
+ swiftpmConfig: {name: 'AcmeNative'},
+ });
+ write(
+ path.join(selfManaged, 'Package.swift'),
+ `// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "AcmeNative",
+ platforms: [.iOS(.v15)],
+ products: [.library(name: "AcmeNative", targets: ["AcmeNative"])],
+ targets: [.target(name: "AcmeNative", path: "ios")]
+)
+`,
+ );
+ write(path.join(selfManaged, 'ios', 'AcmeNative.m'), '');
+ fs.mkdirSync(path.join(selfManaged, '.swiftpm', 'xcuserdata'), {
+ recursive: true,
+ });
+
+ // (b) Podspec only, whose module_name is not derivable from the npm name.
+ const podspecOnly = path.join(nodeModules, 'react-native-acme-widgets');
+ writeJson(path.join(podspecOnly, 'package.json'), {
+ name: 'react-native-acme-widgets',
+ version: '1.0.0',
+ });
+ write(
+ path.join(podspecOnly, 'react-native-acme-widgets.podspec'),
+ `Pod::Spec.new do |s|
+ s.name = "react-native-acme-widgets"
+ s.module_name = "AcmeWidgets"
+ s.version = "1.0.0"
+ s.platforms = { :ios => "13.4" }
+ s.source_files = "ios/**/*.{h,m,mm}"
+ s.dependency "React-Core"
+end
+`,
+ );
+ write(path.join(podspecOnly, 'ios', 'AcmeWidgets.h'), '');
+ write(path.join(podspecOnly, 'ios', 'AcmeWidgets.m'), '');
+
+ // (c) Scoped npm name, manifest under ios/, Swift name derived from npm.
+ const scoped = path.join(nodeModules, '@acme', 'react-native-scoped');
+ writeJson(path.join(scoped, 'package.json'), {
+ name: '@acme/react-native-scoped',
+ version: '1.0.0',
+ });
+ write(
+ path.join(scoped, 'ios', 'Package.swift'),
+ `// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "ReactNativeScoped",
+ platforms: [.iOS(.v15)],
+ products: [.library(name: "ReactNativeScoped", targets: ["ReactNativeScoped"])],
+ targets: [.target(name: "ReactNativeScoped", path: "Sources")]
+)
+`,
+ );
+ write(path.join(scoped, 'ios', 'Sources', 'Scoped.swift'), '');
+
+ // (d) Autolinking plugin host with precompiled flavored frameworks.
+ const pluginHost = path.join(nodeModules, 'react-native-acme-host');
+ writeJson(path.join(pluginHost, 'package.json'), {
+ name: 'react-native-acme-host',
+ version: '1.0.0',
+ swiftpmConfig: {autolinkingPlugin: './spm-plugin.js'},
+ });
+ write(path.join(pluginHost, 'spm-plugin.js'), PLUGIN_SCRIPT);
+ for (const flavor of ['debug', 'release']) {
+ for (const name of PLUGIN_FRAMEWORKS) {
+ writeFrameworkXcframework(
+ path.join(pluginHost, 'prebuilt', flavor),
+ name,
+ `void ${name}(void);\n`,
+ );
+ }
+ }
+
+ return {selfManaged, podspecOnly, scoped, pluginHost};
+}
+
+// A stand-in for `node` on PATH while the scheme pre-action runs: it answers
+// the pre-action's react-native lookup for real and only records the sync
+// dispatch, so a test sees the stale-check verdict without a second sync.
+function writeFakeNode(toolsDir) {
+ const dispatchLog = path.join(toolsDir, 'dispatched-sync.log');
+ write(
+ path.join(toolsDir, 'node'),
+ `#!/bin/sh
+if [ "$1" = "--print" ]; then
+ exec "${process.execPath}" "$@"
+fi
+echo "$*" >> "${dispatchLog}"
+`,
+ );
+ fs.chmodSync(path.join(toolsDir, 'node'), 0o755);
+ return dispatchLog;
+}
+
+/**
+ * Lays out the app in a fresh temp dir. The caller owns cleanup().
+ */
+function createSpmFixtureApp() {
+ const createdRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-lifecycle-'));
+ const tmpRoot = fs.realpathSync(createdRoot);
+ const projectRoot = path.join(tmpRoot, 'app');
+ const appRoot = path.join(projectRoot, 'ios');
+ const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj');
+ const nodeModules = path.join(projectRoot, 'node_modules');
+ const toolsDir = path.join(tmpRoot, 'tools');
+
+ writeJson(path.join(projectRoot, 'package.json'), {
+ name: 'my-app',
+ version: '0.0.1',
+ private: true,
+ });
+ write(
+ path.join(projectRoot, 'autolinking-config.js'),
+ AUTOLINKING_CONFIG_SCRIPT,
+ );
+ write(
+ path.join(xcodeprojPath, 'project.pbxproj'),
+ withProjectSetting(
+ fs.readFileSync(
+ path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'),
+ 'utf8',
+ ),
+ APP_DEPLOYMENT_TARGET,
+ ),
+ );
+ write(
+ path.join(xcodeprojPath, 'xcshareddata', 'xcschemes', 'MyApp.xcscheme'),
+ USER_SCHEME,
+ );
+ write(path.join(appRoot, 'MyApp', 'AppDelegate.swift'), '');
+
+ fs.mkdirSync(nodeModules, {recursive: true});
+ fs.symlinkSync(REACT_NATIVE_ROOT, path.join(nodeModules, 'react-native'));
+ const libs = writeLibraries(nodeModules);
+ const pluginFrameworksFile = path.join(libs.pluginHost, 'frameworks.json');
+ writeJson(pluginFrameworksFile, PLUGIN_FRAMEWORKS.slice(0, 1));
+
+ const artifactsDirs = {};
+ for (const version of ARTIFACT_VERSIONS) {
+ const dir = path.join(tmpRoot, 'artifacts', version);
+ writeArtifactSlot(path.join(dir, 'debug'), version);
+ writeArtifactSlot(path.join(dir, 'release'), version);
+ artifactsDirs[version] = dir;
+ }
+
+ return {
+ projectRoot,
+ appRoot,
+ xcodeprojPath,
+ pbxprojPath: path.join(xcodeprojPath, 'project.pbxproj'),
+ schemePath: path.join(
+ xcodeprojPath,
+ 'xcshareddata',
+ 'xcschemes',
+ 'MyApp.xcscheme',
+ ),
+ markerPath: path.join(xcodeprojPath, '.spm-injected.json'),
+ configCommand: ['node', 'autolinking-config.js'],
+ artifactsDirs,
+ libs,
+ absentPluginWatchPath: path.join(libs.pluginHost, 'precompiled'),
+ toolsDir,
+ dispatchLog: writeFakeNode(toolsDir),
+ forbiddenPaths: [
+ ...new Set([tmpRoot, createdRoot, os.homedir(), REACT_NATIVE_ROOT]),
+ ].filter(p => p !== path.parse(p).root),
+ setPluginFrameworks(names) {
+ writeJson(pluginFrameworksFile, names);
+ },
+ cleanup() {
+ fs.rmSync(tmpRoot, {recursive: true, force: true});
+ },
+ };
+}
+
+module.exports = {
+ APP_DEPLOYMENT_TARGET,
+ ARTIFACT_VERSIONS,
+ PLUGIN_FRAMEWORKS,
+ REACT_NATIVE_ROOT,
+ createSpmFixtureApp,
+};
diff --git a/packages/react-native/scripts/spm/__tests__/spm-invariants-test.js b/packages/react-native/scripts/spm/__tests__/spm-invariants-test.js
new file mode 100644
index 00000000000..75a1d1381ae
--- /dev/null
+++ b/packages/react-native/scripts/spm/__tests__/spm-invariants-test.js
@@ -0,0 +1,672 @@
+/**
+ * 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.
+ *
+ * @format
+ * @noflow
+ */
+
+'use strict';
+
+const {checkSpmInvariants} = require('./spm-invariants');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+
+const PBXPROJ = 'MyApp.xcodeproj/project.pbxproj';
+const MARKER = 'MyApp.xcodeproj/.spm-injected.json';
+const SCHEME = 'MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme';
+const AUTOLINKED = 'build/generated/autolinking/Package.swift';
+const XCFRAMEWORKS = 'build/xcframeworks/Package.swift';
+const CODEGEN = 'build/generated/ios/Package.swift';
+const SCAFFOLDED = '../node_modules/react-native-acme-widgets/Package.swift';
+const XCCONFIG = 'ReactNative.xcconfig';
+
+const GOOD_FILES = {
+ [PBXPROJ]: `// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ objects = {
+ AA0000000000000000000001 /* Project object */ = {
+ isa = PBXProject;
+ targets = (
+ AA0000000000000000000002 /* MyApp */,
+ );
+ };
+ AA0000000000000000000002 /* MyApp */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = AA0000000000000000000003;
+ buildPhases = (
+ AA0000000000000000000005 /* Embed React Native Flavored Frameworks */,
+ );
+ name = MyApp;
+ };
+ AA0000000000000000000003 = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AA0000000000000000000004 /* Debug */,
+ );
+ };
+ AA0000000000000000000004 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ REACT_NATIVE_PATH = "../node_modules/react-native";
+ "RN_SPM_REACT_FRAMEWORK[sdk=iphonesimulator*]" = "$(SRCROOT)/build/xcframeworks/React.framework";
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "$(RN_SPM_REACT_BINARY)",
+ );
+ };
+ name = Debug;
+ };
+ AA0000000000000000000005 /* Embed React Native Flavored Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ inputPaths = (
+ "$(RN_SPM_REACT_FRAMEWORK)",
+ );
+ outputPaths = (
+ "$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/React.framework",
+ );
+ shellPath = /bin/bash;
+ shellScript = "set -euo pipefail\\n/usr/bin/rsync -a \\"$SOURCE/\\" \\"$DESTINATION/\\" 2>/dev/null\\n";
+ };
+ };
+ rootObject = AA0000000000000000000001 /* Project object */;
+}
+`,
+ [MARKER]: `${JSON.stringify(
+ {
+ rootUuid: 'AA0000000000000000000001',
+ linkedFrameworks: [{id: 'react', frameworkName: 'React'}],
+ configCommand: ['node', 'autolinking-config.js'],
+ scheme: {file: 'MyApp.xcscheme', created: false},
+ },
+ null,
+ 2,
+ )}\n`,
+ [SCHEME]: `
+
+
+
+
+
+
+
+
+
+
+`,
+ [AUTOLINKED]: `// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "Autolinked",
+ platforms: [.iOS("16.4")],
+ products: [
+ .library(name: "Autolinked", targets: ["AutolinkedAggregate"]),
+ ],
+ dependencies: [
+ .package(name: "AcmeWidgets", path: "libs/AcmeWidgets"),
+ .package(path: "../ios"),
+ ],
+ targets: [
+ .target(
+ name: "AutolinkedAggregate",
+ dependencies: [.product(name: "AcmeWidgets", package: "AcmeWidgets"), .product(name: "ReactAppHeaders", package: "ios"), .target(name: "AutolinkedHelper")],
+ path: "AutolinkedAggregate"
+ ),
+ .target(name: "AutolinkedHelper", path: "AutolinkedHelper"),
+ ]
+)
+`,
+ [XCFRAMEWORKS]: `// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "ReactNative",
+ products: [
+ .library(name: "ReactNativeHeaders", targets: ["ReactNativeHeaders"]),
+ ],
+ targets: [
+ .binaryTarget(name: "ReactNativeHeaders", path: "ReactNativeHeaders.xcframework"),
+ ]
+)
+`,
+ [CODEGEN]: `// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "React-GeneratedCode",
+ platforms: [.iOS(.v15)],
+ products: [
+ .library(name: "ReactAppHeaders", targets: ["ReactAppHeaders"]),
+ ],
+ targets: [
+ .target(name: "ReactAppHeaders", path: "ReactAppHeaders"),
+ ]
+)
+`,
+ [SCAFFOLDED]: `// swift-tools-version: 6.0
+// AUTO-SCAFFOLDED by react-native spm scaffold — safe to edit & commit via patch-package.
+import PackageDescription
+
+let package = Package(
+ name: "AcmeWidgets",
+ platforms: [.iOS("16.4")],
+ products: [
+ .library(name: "AcmeWidgets", targets: ["AcmeWidgets"]),
+ ],
+ dependencies: [
+ .package(name: "ReactNative", path: "../../../../xcframeworks"),
+ ],
+ targets: [
+ .target(
+ name: "AcmeWidgets",
+ dependencies: [.product(name: "ReactNativeHeaders", package: "ReactNative")],
+ path: "."
+ ),
+ ]
+)
+`,
+};
+
+/** `from` must occur in the file, so an edit can never silently miss. */
+function edit(file, from, to) {
+ const original = GOOD_FILES[file];
+ if (!original.includes(from)) {
+ throw new Error(`${file} does not contain ${JSON.stringify(from)}`);
+ }
+ return {[file]: original.replace(from, to)};
+}
+
+describe('checkSpmInvariants', () => {
+ let root;
+
+ beforeEach(() => {
+ root = fs.realpathSync(
+ fs.mkdtempSync(path.join(os.tmpdir(), 'spm-invariants-')),
+ );
+ });
+
+ afterEach(() => fs.rmSync(root, {recursive: true, force: true}));
+
+ // Lays out GOOD_FILES (with `overrides`; null deletes a file) and runs the
+ // checker. The scaffolded manifest is reached the way the tooling links it:
+ // through the build/generated/autolinking/libs/ alias of the library.
+ function check(overrides = {}, options = {}) {
+ const appRoot = path.join(root, 'ios');
+ const files = {...GOOD_FILES, ...overrides};
+ for (const [file, contents] of Object.entries(files)) {
+ if (contents == null) {
+ continue;
+ }
+ const filePath = path.join(appRoot, file);
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
+ fs.writeFileSync(filePath, contents);
+ }
+ const libs = path.join(appRoot, 'build/generated/autolinking/libs');
+ fs.mkdirSync(libs, {recursive: true});
+ fs.symlinkSync(
+ path.join(root, 'node_modules', 'react-native-acme-widgets'),
+ path.join(libs, 'AcmeWidgets'),
+ );
+ return checkSpmInvariants({
+ appRoot,
+ xcodeprojPath: path.join(appRoot, 'MyApp.xcodeproj'),
+ expectedDeploymentTarget: '16.4',
+ forbiddenPaths: [root, '/Users/someone'],
+ ...options,
+ });
+ }
+
+ function violation(rule, file) {
+ return expect.objectContaining({rule, file, detail: expect.any(String)});
+ }
+
+ it('reports nothing for a well-formed injected project', () => {
+ expect(check()).toEqual([]);
+ });
+
+ describe('R1: no machine paths in committed outputs', () => {
+ it('reports a build setting with an absolute value', () => {
+ expect(
+ check(
+ edit(
+ PBXPROJ,
+ 'REACT_NATIVE_PATH = "../node_modules/react-native";',
+ 'HERMES_CLI_PATH = "/opt/hermes-compiler/hermesc/osx-bin/hermesc";',
+ ),
+ ),
+ ).toEqual([violation('machine-path', PBXPROJ)]);
+ });
+
+ it('reports an absolute entry of an array build setting', () => {
+ expect(
+ check(edit(PBXPROJ, '"$(RN_SPM_REACT_BINARY)",', '/opt/React,')),
+ ).toEqual([violation('machine-path', PBXPROJ)]);
+ });
+
+ it('reports the temp root inside a relative-looking value', () => {
+ expect(
+ check(
+ edit(
+ PBXPROJ,
+ '"../node_modules/react-native"',
+ `"$(SRCROOT)${root}/node_modules/react-native"`,
+ ),
+ ),
+ ).toEqual([violation('machine-path', PBXPROJ)]);
+ });
+
+ it('reports a home directory baked into a scheme pre-action', () => {
+ expect(
+ check(
+ edit(
+ SCHEME,
+ '# Resolve node.
',
+ 'RN_DIR="/Users/someone/app/node_modules/react-native"
',
+ ),
+ ),
+ ).toEqual([violation('machine-path', SCHEME)]);
+ });
+
+ it('reports an absolute path in pbxproj script text that is not an allowlisted system tool', () => {
+ expect(
+ check(edit(PBXPROJ, '/usr/bin/rsync', '/opt/homebrew/bin/rsync')),
+ ).toEqual([violation('machine-path', PBXPROJ)]);
+ });
+
+ it('allows the system tools React Native pins by absolute path', () => {
+ expect(
+ check(edit(PBXPROJ, '2>/dev/null', '&& /usr/bin/codesign --verify')),
+ ).toEqual([]);
+ });
+
+ it('reports an absolute path recorded in the marker', () => {
+ expect(check(edit(MARKER, '"node",', '"/usr/local/bin/node",'))).toEqual([
+ violation('machine-path', MARKER),
+ ]);
+ });
+
+ it('reports an absolute value in an xcconfig the tool writes', () => {
+ expect(
+ check({[XCCONFIG]: 'REACT_NATIVE_PATH = /opt/react-native\n'}),
+ ).toEqual([violation('machine-path', XCCONFIG)]);
+ });
+
+ it('reports a missing pbxproj', () => {
+ expect(check({[PBXPROJ]: null})).toEqual(
+ expect.arrayContaining([violation('machine-path', PBXPROJ)]),
+ );
+ });
+
+ it('reports a missing marker', () => {
+ expect(check({[MARKER]: null})).toEqual(
+ expect.arrayContaining([violation('machine-path', MARKER)]),
+ );
+ });
+ });
+
+ describe('R2: the scheme pre-action keeps its line breaks', () => {
+ it('reports a script written with raw line breaks, which XML reads as spaces', () => {
+ expect(
+ check(
+ edit(
+ SCHEME,
+ 'set -euo pipefail
# Resolve node.
',
+ 'set -euo pipefail\n\n# Resolve node.\n',
+ ),
+ ),
+ ).toEqual([violation('scheme-script', SCHEME)]);
+ });
+
+ it('reports a script whose body after the first line is all comment', () => {
+ expect(
+ check(
+ edit(
+ SCHEME,
+ 'NODE_BINARY="$(command -v node 2>/dev/null)"
',
+ '# NODE_BINARY="$(command -v node)"
',
+ ),
+ ),
+ ).toEqual([violation('scheme-script', SCHEME)]);
+ });
+
+ it('reports a scheme that is not well-formed XML', () => {
+ expect(check(edit(SCHEME, '', ''))).toEqual([
+ violation('scheme-script', SCHEME),
+ ]);
+ });
+
+ it('ignores a pre-action that React Native did not add', () => {
+ expect(
+ check(
+ edit(
+ SCHEME,
+ '',
+ `
+
+
+
+ `,
+ ),
+ ),
+ ).toEqual([]);
+ });
+
+ it('reports a project with no scheme carrying the sync pre-action', () => {
+ expect(check({[SCHEME]: null})).toEqual([
+ violation('scheme-script', 'MyApp.xcodeproj/xcshareddata/xcschemes'),
+ ]);
+ });
+ });
+
+ describe('R3: Package.swift names are unique and resolve', () => {
+ it('reports a duplicated target name', () => {
+ expect(
+ check(
+ edit(
+ AUTOLINKED,
+ '.target(name: "AutolinkedHelper", path: "AutolinkedHelper"),',
+ '.target(name: "AutolinkedHelper", path: "AutolinkedHelper"),\n .target(name: "AutolinkedHelper", path: "Other"),',
+ ),
+ ),
+ ).toEqual([violation('package-names', AUTOLINKED)]);
+ });
+
+ it('reports target names that differ only in case', () => {
+ expect(
+ check(
+ edit(
+ AUTOLINKED,
+ '.target(name: "AutolinkedHelper", path: "AutolinkedHelper"),',
+ '.target(name: "AutolinkedHelper", path: "AutolinkedHelper"),\n .target(name: "autolinkedhelper", path: "Other"),',
+ ),
+ ),
+ ).toEqual([violation('package-names', AUTOLINKED)]);
+ });
+
+ it('reports product names that differ only in case', () => {
+ expect(
+ check(
+ edit(
+ XCFRAMEWORKS,
+ '.library(name: "ReactNativeHeaders", targets: ["ReactNativeHeaders"]),',
+ '.library(name: "ReactNativeHeaders", targets: ["ReactNativeHeaders"]),\n .library(name: "reactnativeheaders", targets: ["ReactNativeHeaders"]),',
+ ),
+ ),
+ ).toEqual([violation('package-names', XCFRAMEWORKS)]);
+ });
+
+ it('reports a target name that is not a C99 identifier', () => {
+ expect(
+ check(
+ edit(
+ SCAFFOLDED,
+ '.target(\n name: "AcmeWidgets",',
+ '.target(\n name: "acme-widgets",',
+ ),
+ ),
+ ).toEqual([
+ expect.objectContaining({
+ rule: 'package-names',
+ file: SCAFFOLDED,
+ detail: expect.stringContaining('not a C99 identifier'),
+ }),
+ expect.objectContaining({
+ rule: 'package-names',
+ file: SCAFFOLDED,
+ detail: expect.stringContaining('lists target "AcmeWidgets"'),
+ }),
+ ]);
+ });
+
+ it('reports a product name that is not a C99 identifier', () => {
+ expect(
+ check(
+ edit(
+ SCAFFOLDED,
+ '.library(name: "AcmeWidgets",',
+ '.library(name: "Acme Widgets",',
+ ),
+ ),
+ ).toEqual([
+ violation('package-names', AUTOLINKED),
+ violation('package-names', SCAFFOLDED),
+ ]);
+ });
+
+ it('reports a target dependency on a target the package does not declare', () => {
+ expect(
+ check(
+ edit(
+ AUTOLINKED,
+ '.target(name: "AutolinkedHelper")]',
+ '.target(name: "AutolinkedMissing")]',
+ ),
+ ),
+ ).toEqual([violation('package-names', AUTOLINKED)]);
+ });
+
+ it('reports a product from a package the manifest does not depend on', () => {
+ expect(
+ check(
+ edit(
+ AUTOLINKED,
+ 'package: "AcmeWidgets")',
+ 'package: "AcmeWidgetsPackage")',
+ ),
+ ),
+ ).toEqual([violation('package-names', AUTOLINKED)]);
+ });
+
+ it('reports a product that its path package does not declare', () => {
+ expect(
+ check(
+ edit(
+ AUTOLINKED,
+ '.product(name: "AcmeWidgets", package: "AcmeWidgets")',
+ '.product(name: "AcmeWidgetsKit", package: "AcmeWidgets")',
+ ),
+ ),
+ ).toEqual([violation('package-names', AUTOLINKED)]);
+ });
+
+ it('resolves a scaffolded manifest path package from its libs/ alias', () => {
+ expect(
+ check(
+ edit(
+ SCAFFOLDED,
+ '.product(name: "ReactNativeHeaders", package: "ReactNative")',
+ '.product(name: "ReactNativeDependenciesHeaders", package: "ReactNative")',
+ ),
+ ),
+ ).toEqual([violation('package-names', SCAFFOLDED)]);
+ });
+
+ it('reports a path package that has no Package.swift', () => {
+ expect(
+ check(
+ edit(
+ AUTOLINKED,
+ '.package(path: "../ios"),',
+ '.package(name: "ios", path: "../nowhere"),',
+ ),
+ ),
+ ).toEqual([violation('package-names', AUTOLINKED)]);
+ });
+
+ it('reports a product that lists a target the package does not declare', () => {
+ expect(
+ check(
+ edit(
+ XCFRAMEWORKS,
+ 'targets: ["ReactNativeHeaders"]',
+ 'targets: ["ReactNativeHeaders", "ReactNativeMissing"]',
+ ),
+ ),
+ ).toEqual([violation('package-names', XCFRAMEWORKS)]);
+ });
+
+ it('resolves a package by the last component of its url', () => {
+ expect(
+ check(
+ edit(
+ SCAFFOLDED,
+ '.package(name: "ReactNative", path: "../../../../xcframeworks"),',
+ '.package(url: "https://github.com/acme/ReactNative.git", exact: "0.90.0"),',
+ ),
+ ),
+ ).toEqual([]);
+ });
+
+ it('reports a missing generated manifest', () => {
+ expect(check({[XCFRAMEWORKS]: null})).toEqual([
+ violation('package-names', XCFRAMEWORKS),
+ violation('package-names', SCAFFOLDED),
+ ]);
+ });
+ });
+
+ describe('R4: generated manifests declare the app deployment target', () => {
+ it('reports a generated manifest with a different floor', () => {
+ expect(check(edit(AUTOLINKED, '.iOS("16.4")', '.iOS("15.1")'))).toEqual([
+ violation('deployment-target', AUTOLINKED),
+ ]);
+ });
+
+ it('reports a scaffolded manifest with a different floor', () => {
+ expect(check(edit(SCAFFOLDED, '.iOS("16.4")', '.iOS(.v16)'))).toEqual([
+ violation('deployment-target', SCAFFOLDED),
+ ]);
+ });
+
+ it('reports a required manifest it did not check', () => {
+ const unmarked = edit(
+ SCAFFOLDED,
+ '// AUTO-SCAFFOLDED by react-native spm scaffold — safe to edit & commit via patch-package.\n',
+ '',
+ );
+ expect(check(unmarked, {requiredManifests: [SCAFFOLDED]})).toEqual([
+ violation('deployment-target', SCAFFOLDED),
+ ]);
+ });
+
+ it('reports a required manifest that declares no floor', () => {
+ expect(
+ check(edit(SCAFFOLDED, ' platforms: [.iOS("16.4")],\n', ''), {
+ requiredManifests: [SCAFFOLDED],
+ }),
+ ).toEqual([violation('deployment-target', SCAFFOLDED)]);
+ });
+
+ it('treats a trailing .0 as the same version', () => {
+ expect(check({}, {expectedDeploymentTarget: '16.4.0'})).toEqual([]);
+ });
+
+ it('reports an autolinked manifest that declares no floor', () => {
+ expect(
+ check(edit(AUTOLINKED, ' platforms: [.iOS("16.4")],\n', '')),
+ ).toEqual([violation('deployment-target', AUTOLINKED)]);
+ });
+
+ it('reports a missing autolinked manifest', () => {
+ expect(check({[AUTOLINKED]: null})).toEqual(
+ expect.arrayContaining([violation('deployment-target', AUTOLINKED)]),
+ );
+ });
+
+ it('reports a codegen manifest whose floor is above the app', () => {
+ expect(check(edit(CODEGEN, '.iOS(.v15)', '.iOS(.v17)'))).toEqual([
+ violation('deployment-target', CODEGEN),
+ ]);
+ });
+
+ it('accepts a codegen manifest whose floor is below the app', () => {
+ expect(check()).toEqual([]);
+ });
+
+ it('accepts a codegen manifest whose floor equals the app', () => {
+ expect(check(edit(CODEGEN, '.iOS(.v15)', '.iOS(.v16_4)'))).toEqual([]);
+ });
+
+ it('compares codegen floors by component, not as text', () => {
+ expect(check(edit(CODEGEN, '.iOS(.v15)', '.iOS("16.10")'))).toEqual([
+ violation('deployment-target', CODEGEN),
+ ]);
+ });
+
+ it('reports a codegen manifest that declares no floor', () => {
+ expect(
+ check(edit(CODEGEN, ' platforms: [.iOS(.v15)],\n', '')),
+ ).toEqual([violation('deployment-target', CODEGEN)]);
+ });
+
+ it('reports a codegen manifest whose floor does not parse', () => {
+ expect(check(edit(CODEGEN, '.iOS(.v15)', '.iOS(someFloor)'))).toEqual([
+ violation('deployment-target', CODEGEN),
+ ]);
+ });
+ });
+
+ describe('R5: the pbxproj is well-formed and agrees with the marker', () => {
+ it('reports unbalanced delimiters', () => {
+ expect(
+ check(edit(PBXPROJ, '\trootObject', '\t};\n\trootObject')),
+ ).toEqual([violation('pbxproj-integrity', PBXPROJ)]);
+ });
+
+ it('reports a reference to an object that is not defined', () => {
+ expect(
+ check(
+ edit(
+ PBXPROJ,
+ 'AA0000000000000000000005 /* Embed React Native Flavored Frameworks */,',
+ 'AA0000000000000000000005 /* Embed React Native Flavored Frameworks */,\n\t\t\t\tAA00000000000000000000FF /* Sync SPM Autolinking */,',
+ ),
+ ),
+ ).toEqual([violation('pbxproj-integrity', PBXPROJ)]);
+ });
+
+ it('ignores object ids that only appear inside strings and comments', () => {
+ expect(
+ check(
+ edit(
+ PBXPROJ,
+ 'name = MyApp;',
+ 'name = MyApp; /* BB0000000000000000000001 */\n\t\t\tcomment = "BB0000000000000000000002";',
+ ),
+ ),
+ ).toEqual([]);
+ });
+
+ it('reports a framework the marker records but the pbxproj does not embed', () => {
+ expect(
+ check(
+ edit(
+ MARKER,
+ '{\n "id": "react",\n "frameworkName": "React"\n }',
+ '{\n "id": "react",\n "frameworkName": "React"\n },\n {\n "id": "AcmeHostKit",\n "frameworkName": "AcmeHostKit"\n }',
+ ),
+ ),
+ ).toEqual([violation('pbxproj-integrity', MARKER)]);
+ });
+
+ it('reports a framework the pbxproj embeds under an id the marker does not record', () => {
+ expect(
+ check(edit(MARKER, '"id": "react"', '"id": "react-core"')),
+ ).toEqual([violation('pbxproj-integrity', MARKER)]);
+ });
+
+ it('reports a missing marker', () => {
+ expect(check({[MARKER]: null})).toEqual(
+ expect.arrayContaining([violation('pbxproj-integrity', MARKER)]),
+ );
+ });
+ });
+});
diff --git a/packages/react-native/scripts/spm/__tests__/spm-invariants.js b/packages/react-native/scripts/spm/__tests__/spm-invariants.js
new file mode 100644
index 00000000000..bbf6e5089ae
--- /dev/null
+++ b/packages/react-native/scripts/spm/__tests__/spm-invariants.js
@@ -0,0 +1,673 @@
+/**
+ * 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.
+ *
+ * @format
+ * @noflow
+ */
+
+'use strict';
+
+// Invariants every file `react-native spm` writes must hold, checked from the
+// files alone. Each rule names a class of bug that has shipped before:
+//
+// machine-path committed outputs name a path on this machine (#58292)
+// scheme-script the scheme pre-action loses its line breaks (#58826)
+// package-names Package.swift names collide or do not resolve
+// deployment-target a manifest floor differs from the app's, or for the
+// fixed codegen template exceeds it (#58379)
+// pbxproj-integrity the pbxproj is malformed or disagrees with the marker
+
+const {SCAFFOLDER_MARKER} = require('../spm-utils');
+const {isBalanced} = require('./pbxproj-oracles');
+const {DOMParser} = require('@xmldom/xmldom');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const MARKER_FILE = '.spm-injected.json';
+const SYNC_PRE_ACTION_TITLE = 'Sync SPM Autolinking';
+const AUTOLINKED_MANIFEST = 'build/generated/autolinking/Package.swift';
+const GENERATED_MANIFESTS = [
+ AUTOLINKED_MANIFEST,
+ 'build/xcframeworks/Package.swift',
+ 'build/generated/ios/Package.swift',
+];
+// Copied verbatim from react-native/scripts/codegen/templates, so its floor
+// (.v15) is fixed rather than derived from the app. A floor is a minimum: it
+// may sit below the app's, but one above it would not build.
+const FIXED_FLOOR_MANIFESTS = new Set(['build/generated/ios/Package.swift']);
+
+const ALLOWED_ABSOLUTE_PATHS = {
+ '/bin/bash':
+ 'the interpreter Xcode runs build phases and scheme actions under',
+ '/usr/bin/rsync':
+ 'pinned so no PATH entry can shadow the tool that embeds frameworks',
+ '/usr/bin/codesign':
+ 'pinned so no PATH entry can shadow the tool that signs frameworks',
+ '/dev/null': 'a redirection target in the sync script',
+};
+
+const C99_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
+// A `/` that starts a path rather than continuing one (`$(SRCROOT)/x`,
+// `build/x`) or belonging to a URL.
+const ABSOLUTE_PATH_TOKEN =
+ /(?|&\\]*/g;
+const PBXPROJ_STRING = /"(?:[^"\\]|\\.)*"/g;
+const PBXPROJ_STRING_OR_COMMENT =
+ /"(?:[^"\\]|\\.)*"|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
+const PBXPROJ_OBJECT_ID = /\b[0-9A-Fa-f]{24}\b/g;
+
+/*::
+type Violation = {rule: string, file: string, detail: string};
+*/
+
+function readIfExists(filePath) {
+ try {
+ return fs.readFileSync(filePath, 'utf8');
+ } catch {
+ return null;
+ }
+}
+
+function unquotePbxproj(value) {
+ if (!value.startsWith('"')) {
+ return value;
+ }
+ return value
+ .slice(1, -1)
+ .replace(/\\(.)/g, (_, c) => ({n: '\n', t: '\t'})[c] ?? c);
+}
+
+function braceBodyRange(text, openBraceIndex) {
+ let depth = 0;
+ for (let i = openBraceIndex; i < text.length; i++) {
+ const c = text[i];
+ if (c === '"') {
+ for (i++; i < text.length && text[i] !== '"'; i++) {
+ if (text[i] === '\\') i++;
+ }
+ } else if (c === '{') {
+ depth++;
+ } else if (c === '}' && --depth === 0) {
+ return {start: openBraceIndex + 1, end: i};
+ }
+ }
+ return {start: openBraceIndex + 1, end: text.length};
+}
+
+function buildSettingsBlocks(pbxproj) {
+ return Array.from(pbxproj.matchAll(/\bbuildSettings = \{/g), m =>
+ braceBodyRange(pbxproj, m.index + m[0].length - 1),
+ );
+}
+
+function buildSettings(pbxproj) {
+ const settings = [];
+ for (const {start, end} of buildSettingsBlocks(pbxproj)) {
+ const body = pbxproj.slice(start, end);
+ const entry =
+ /("(?:[^"\\]|\\.)*"|[^\s="]+)\s*=\s*(\((?:"(?:[^"\\]|\\.)*"|[^()"])*\)|"(?:[^"\\]|\\.)*"|[^;]*);/g;
+ for (const [, key, value] of body.matchAll(entry)) {
+ const values = value.startsWith('(')
+ ? Array.from(value.matchAll(/"(?:[^"\\]|\\.)*"|[^\s,()]+/g), v =>
+ unquotePbxproj(v[0]),
+ )
+ : [unquotePbxproj(value.trim())];
+ for (const v of values) {
+ settings.push({key: unquotePbxproj(key), value: v});
+ }
+ }
+ }
+ return settings;
+}
+
+// pbxproj text outside every buildSettings block, strings decoded, so script
+// text reads as the shell will run it.
+function pbxprojScriptText(pbxproj) {
+ let text = '';
+ let at = 0;
+ for (const {start, end} of buildSettingsBlocks(pbxproj)) {
+ text += pbxproj.slice(at, start);
+ at = end;
+ }
+ text += pbxproj.slice(at);
+ return text.replace(PBXPROJ_STRING, unquotePbxproj);
+}
+
+function jsonStrings(value) {
+ if (typeof value === 'string') {
+ return [value];
+ }
+ if (value != null && typeof value === 'object') {
+ return Object.values(value).flatMap(jsonStrings);
+ }
+ return [];
+}
+
+function parseXml(text) {
+ const errors = [];
+ const record = message => errors.push(message);
+ let doc = null;
+ try {
+ doc = new DOMParser({
+ errorHandler: {warning: () => {}, error: record, fatalError: record},
+ }).parseFromString(text, 'text/xml');
+ } catch (e) {
+ record(e.message);
+ }
+ return {doc, errors};
+}
+
+function xmlAttributeValues(doc) {
+ if (doc == null) {
+ return [];
+ }
+ const elements = doc.getElementsByTagName('*');
+ const values = [];
+ for (let i = 0; i < elements.length; i++) {
+ const attributes = elements[i].attributes;
+ for (let j = 0; j < attributes.length; j++) {
+ values.push(attributes[j].value);
+ }
+ }
+ return values;
+}
+
+function schemeFiles(xcodeprojPath) {
+ const dir = path.join(xcodeprojPath, 'xcshareddata', 'xcschemes');
+ try {
+ return fs
+ .readdirSync(dir)
+ .filter(name => name.endsWith('.xcscheme'))
+ .map(name => path.join(dir, name));
+ } catch {
+ return [];
+ }
+}
+
+function xcconfigFiles(appRoot, xcodeprojPath) {
+ return [appRoot, xcodeprojPath].flatMap(dir => {
+ try {
+ return fs
+ .readdirSync(dir)
+ .filter(name => name.endsWith('.xcconfig'))
+ .map(name => path.join(dir, name));
+ } catch {
+ return [];
+ }
+ });
+}
+
+function findPackageSwiftFiles(dir) {
+ let entries;
+ try {
+ entries = fs.readdirSync(dir, {withFileTypes: true});
+ } catch {
+ return [];
+ }
+ return entries.flatMap(entry => {
+ const entryPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ return findPackageSwiftFiles(entryPath);
+ }
+ return entry.isFile() && entry.name === 'Package.swift' ? [entryPath] : [];
+ });
+}
+
+// Scaffolded manifests live in node_modules; the autolinker links every
+// self-managed library root into libs/, which is where SwiftPM reads them, so
+// their relative package paths resolve from the alias, not the real directory.
+function scaffoldedManifests(appRoot) {
+ const libsDir = path.join(appRoot, 'build/generated/autolinking/libs');
+ let names;
+ try {
+ names = fs.readdirSync(libsDir);
+ } catch {
+ return [];
+ }
+ return names.flatMap(name => {
+ const alias = path.join(libsDir, name);
+ try {
+ const manifest = path.join(fs.realpathSync(alias), 'Package.swift');
+ return readIfExists(manifest)?.includes(SCAFFOLDER_MARKER) === true
+ ? [[manifest, alias]]
+ : [];
+ } catch {
+ return [];
+ }
+ });
+}
+
+function parseManifest(text) {
+ const code = text
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/^\s*\/\/.*$/gm, '');
+ const all = (re, group = 1) => Array.from(code.matchAll(re), m => m[group]);
+ const label = (args, name) =>
+ new RegExp(`\\b${name}:\\s*"([^"]*)"`).exec(args)?.[1];
+ return {
+ targets: all(
+ /\.(?:target|binaryTarget|testTarget|executableTarget|systemLibrary|macro)\(\s*name:\s*"([^"]*)"\s*,/g,
+ ),
+ products: Array.from(
+ code.matchAll(
+ /\.(?:library|executable)\(\s*name:\s*"([^"]*)"[^)]*?targets:\s*\[([^\]]*)\]/g,
+ ),
+ ([, name, targets]) => ({
+ name,
+ targets: Array.from(targets.matchAll(/"([^"]*)"/g), m => m[1]),
+ }),
+ ),
+ packages: new Map(
+ Array.from(code.matchAll(/\.package\(([^)]*)\)/g), ([, args]) => {
+ const packagePath = label(args, 'path');
+ const location = packagePath ?? label(args, 'url') ?? '';
+ return [
+ label(args, 'name') ?? path.basename(location, '.git'),
+ packagePath,
+ ];
+ }),
+ ),
+ targetDependencies: all(/\.target\(\s*name:\s*"([^"]*)"\s*\)/g),
+ productDependencies: Array.from(
+ code.matchAll(
+ /\.product\(\s*name:\s*"([^"]*)"\s*,\s*package:\s*"([^"]*)"/g,
+ ),
+ ([, name, pkg]) => ({name, package: pkg}),
+ ),
+ };
+}
+
+function nameViolations(kind, names) {
+ const problems = [];
+ const byKey = new Map();
+ for (const name of names) {
+ if (!C99_IDENTIFIER.test(name)) {
+ problems.push(`${kind} name "${name}" is not a C99 identifier`);
+ }
+ const key = name.toLowerCase();
+ byKey.set(key, [...(byKey.get(key) ?? []), name]);
+ }
+ for (const group of byKey.values()) {
+ if (group.length < 2) {
+ continue;
+ }
+ problems.push(
+ new Set(group).size === 1
+ ? `declares ${kind} "${group[0]}" ${group.length} times`
+ : `declares ${kind}s ${group.map(n => `"${n}"`).join(', ')}, which differ only in case`,
+ );
+ }
+ return problems;
+}
+
+function parseFloor(argument) {
+ const literal = /^"(\d+(?:\.\d+)*)"$/.exec(argument);
+ const member = /^\.v(\d+(?:_\d+)*)$/.exec(argument);
+ const version = literal?.[1] ?? member?.[1].replace(/_/g, '.');
+ return version == null ? null : normalizeVersion(version);
+}
+
+function normalizeVersion(version) {
+ return version
+ .split('.')
+ .map(Number)
+ .join('.')
+ .replace(/(\.0)+$/, '');
+}
+
+function compareVersions(a, b) {
+ const left = a.split('.').map(Number);
+ const right = b.split('.').map(Number);
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
+ const difference = (left[i] ?? 0) - (right[i] ?? 0);
+ if (difference !== 0) {
+ return difference;
+ }
+ }
+ return 0;
+}
+
+function iosFloorArguments(text) {
+ return [...text.matchAll(/\.iOS\(\s*([^)]*?)\s*\)/g)].map(
+ ([, argument]) => argument,
+ );
+}
+
+function checkMachinePaths(report, file, text, opts) {
+ for (const forbidden of opts.forbiddenPaths) {
+ if (text.includes(forbidden)) {
+ report(file, `contains ${forbidden}, a path on this machine`);
+ }
+ }
+ for (const {key, value} of opts.settings ?? []) {
+ if (value.startsWith('/')) {
+ report(file, `build setting ${key} has the absolute value ${value}`);
+ }
+ }
+ for (const value of opts.recordedValues ?? []) {
+ if (value.startsWith('/')) {
+ report(file, `records the absolute path ${value}`);
+ }
+ }
+ for (const script of opts.scripts ?? []) {
+ for (const [token] of script.matchAll(ABSOLUTE_PATH_TOKEN)) {
+ const alreadyReported = opts.forbiddenPaths.some(p => token.includes(p));
+ if (!Object.hasOwn(ALLOWED_ABSOLUTE_PATHS, token) && !alreadyReported) {
+ report(
+ file,
+ `names ${token}, which is not an allowlisted system tool (${Object.keys(ALLOWED_ABSOLUTE_PATHS).join(', ')})`,
+ );
+ }
+ }
+ }
+}
+
+function checkScheme(report, file, text) {
+ const {doc, errors} = parseXml(text);
+ if (errors.length > 0) {
+ report(file, `is not well-formed XML: ${errors[0]}`);
+ return;
+ }
+ const actions = doc.getElementsByTagName('ActionContent');
+ for (let i = 0; i < actions.length; i++) {
+ if (actions[i].getAttribute('title') !== SYNC_PRE_ACTION_TITLE) {
+ continue;
+ }
+ const lines = (actions[i].getAttribute('scriptText') ?? '').split('\n');
+ if (lines.length < 2) {
+ report(
+ file,
+ `the "${SYNC_PRE_ACTION_TITLE}" scriptText reads as one line, so everything after its first "#" is a comment`,
+ );
+ } else if (lines.slice(1).every(line => /^\s*(#.*)?$/.test(line))) {
+ report(
+ file,
+ `every line of the "${SYNC_PRE_ACTION_TITLE}" scriptText after the first is a comment, so the script body never runs`,
+ );
+ }
+ }
+}
+
+function localPackageProductNames(baseDir, packagePath) {
+ const text = readIfExists(
+ path.resolve(baseDir, packagePath, 'Package.swift'),
+ );
+ return text == null
+ ? null
+ : new Set(parseManifest(text).products.map(p => p.name));
+}
+
+function productDependencyProblem(manifest, baseDir, dep) {
+ if (!manifest.packages.has(dep.package)) {
+ return `depends on product "${dep.name}" of package "${dep.package}", which it does not depend on`;
+ }
+ const packagePath = manifest.packages.get(dep.package);
+ if (packagePath == null) {
+ return null;
+ }
+ const products = localPackageProductNames(baseDir, packagePath);
+ if (products == null) {
+ return `depends on package "${dep.package}" at ${packagePath}, which has no Package.swift`;
+ }
+ return products.has(dep.name)
+ ? null
+ : `depends on product "${dep.name}", which package "${dep.package}" at ${packagePath} does not declare`;
+}
+
+function checkManifestNames(report, file, text, baseDir) {
+ const manifest = parseManifest(text);
+ const targets = new Set(manifest.targets);
+ const problems = [
+ ...nameViolations('target', manifest.targets),
+ ...nameViolations(
+ 'product',
+ manifest.products.map(p => p.name),
+ ),
+ ...manifest.products.flatMap(product =>
+ product.targets
+ .filter(name => !targets.has(name))
+ .map(
+ name =>
+ `product "${product.name}" lists target "${name}", which it does not declare`,
+ ),
+ ),
+ ...manifest.targetDependencies
+ .filter(name => !targets.has(name))
+ .map(name => `depends on target "${name}", which it does not declare`),
+ ...manifest.productDependencies
+ .map(dep => productDependencyProblem(manifest, baseDir, dep))
+ .filter(Boolean),
+ ];
+ for (const problem of problems) {
+ report(file, problem);
+ }
+}
+
+function checkDeploymentTarget(report, file, text, expected) {
+ for (const argument of iosFloorArguments(text)) {
+ const floor = parseFloor(argument);
+ if (floor !== normalizeVersion(expected)) {
+ report(
+ file,
+ `declares .iOS(${argument}), but the app deploys to iOS ${expected}`,
+ );
+ }
+ }
+}
+
+function checkFixedFloor(report, file, text, expected) {
+ const floorArguments = iosFloorArguments(text);
+ if (floorArguments.length === 0) {
+ report(file, 'declares no iOS deployment target');
+ }
+ for (const argument of floorArguments) {
+ const floor = parseFloor(argument);
+ if (floor == null) {
+ report(file, `declares .iOS(${argument}), which is not a version`);
+ } else if (compareVersions(floor, expected) > 0) {
+ report(
+ file,
+ `declares .iOS(${argument}), above the iOS ${expected} the app deploys to`,
+ );
+ }
+ }
+}
+
+function checkPbxprojIntegrity(report, file, pbxproj) {
+ if (!isBalanced(pbxproj)) {
+ report(file, 'has unbalanced braces or parentheses');
+ }
+ const defined = new Set(
+ Array.from(
+ pbxproj.matchAll(/^\s*([0-9A-Fa-f]{24})\b(?:\s*\/\*.*?\*\/)?\s*=\s*\{/gm),
+ m => m[1],
+ ),
+ );
+ const references = new Set(
+ pbxproj.replace(PBXPROJ_STRING_OR_COMMENT, ' ').match(PBXPROJ_OBJECT_ID),
+ );
+ for (const id of references) {
+ if (!defined.has(id)) {
+ report(file, `references object ${id}, which it does not define`);
+ }
+ }
+}
+
+function frameworkSettingPrefix(id) {
+ return `RN_SPM_${id.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}`;
+}
+
+// The embed phase lists each framework's setting in inputPaths and its
+// destination in outputPaths, in the same order.
+function embeddedFrameworks(pbxproj) {
+ const prefixes = Array.from(
+ pbxproj.matchAll(/"\$\((RN_SPM_\w+?)_FRAMEWORK\)"/g),
+ m => m[1],
+ );
+ const names = Array.from(
+ pbxproj.matchAll(/\$\(FRAMEWORKS_FOLDER_PATH\)\/([^"/]+)\.framework/g),
+ m => m[1],
+ );
+ return names.map((name, i) => `${prefixes[i] ?? '?'} ${name}`).sort();
+}
+
+function checkLinkedFrameworks(report, file, marker, pbxproj) {
+ const recorded = (marker.linkedFrameworks ?? [])
+ .map(
+ ({id, frameworkName}) => `${frameworkSettingPrefix(id)} ${frameworkName}`,
+ )
+ .sort();
+ const embedded = embeddedFrameworks(pbxproj);
+ if (JSON.stringify(recorded) !== JSON.stringify(embedded)) {
+ report(
+ file,
+ `linkedFrameworks [${recorded.join(', ')}] differ from the frameworks the pbxproj embeds [${embedded.join(', ')}]`,
+ );
+ }
+}
+
+/**
+ * Checks the files an `spm add`/`update`/`sync` leaves in an app and returns
+ * every violation found ([] when all hold). A file a rule needs that is
+ * missing is itself a violation, so a broken layout never passes vacuously.
+ * `requiredManifests` (relative to `appRoot`) names manifests the caller
+ * expects to be checked, such as scaffolded ones, which are otherwise only
+ * found by their marker. `file` in each violation is relative to `appRoot`.
+ */
+function checkSpmInvariants(
+ options /*: {
+ appRoot: string,
+ xcodeprojPath: string,
+ expectedDeploymentTarget: string,
+ forbiddenPaths: ReadonlyArray,
+ requiredManifests?: ReadonlyArray,
+ } */,
+) /*: Array */ {
+ const {
+ appRoot,
+ xcodeprojPath,
+ expectedDeploymentTarget,
+ forbiddenPaths,
+ requiredManifests = [],
+ } = options;
+ const violations = [];
+ const relative = filePath => path.relative(appRoot, filePath);
+ const reporter = rule => (filePath, detail) =>
+ violations.push({rule, file: relative(filePath), detail});
+ const machinePath = reporter('machine-path');
+ const schemeScript = reporter('scheme-script');
+ const packageNames = reporter('package-names');
+ const deploymentTarget = reporter('deployment-target');
+ const pbxprojIntegrity = reporter('pbxproj-integrity');
+
+ const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
+ const pbxproj = readIfExists(pbxprojPath);
+ if (pbxproj == null) {
+ machinePath(pbxprojPath, 'is missing');
+ pbxprojIntegrity(pbxprojPath, 'is missing');
+ } else {
+ checkMachinePaths(machinePath, pbxprojPath, pbxproj, {
+ forbiddenPaths,
+ settings: buildSettings(pbxproj),
+ scripts: [pbxprojScriptText(pbxproj)],
+ });
+ checkPbxprojIntegrity(pbxprojIntegrity, pbxprojPath, pbxproj);
+ }
+
+ const markerPath = path.join(xcodeprojPath, MARKER_FILE);
+ const markerText = readIfExists(markerPath);
+ let marker = null;
+ try {
+ marker = markerText != null ? JSON.parse(markerText) : null;
+ } catch (e) {
+ pbxprojIntegrity(markerPath, `is not valid JSON: ${e.message}`);
+ }
+ if (markerText == null) {
+ machinePath(markerPath, 'is missing');
+ pbxprojIntegrity(markerPath, 'is missing');
+ } else {
+ checkMachinePaths(machinePath, markerPath, markerText, {
+ forbiddenPaths,
+ recordedValues: jsonStrings(marker),
+ });
+ if (marker != null && pbxproj != null) {
+ checkLinkedFrameworks(pbxprojIntegrity, markerPath, marker, pbxproj);
+ }
+ }
+
+ let schemesWithPreAction = 0;
+ for (const schemePath of schemeFiles(xcodeprojPath)) {
+ const text = fs.readFileSync(schemePath, 'utf8');
+ if (text.includes(`title = "${SYNC_PRE_ACTION_TITLE}"`)) {
+ schemesWithPreAction++;
+ }
+ checkMachinePaths(machinePath, schemePath, text, {
+ forbiddenPaths,
+ scripts: xmlAttributeValues(parseXml(text).doc),
+ });
+ checkScheme(schemeScript, schemePath, text);
+ }
+ if (schemesWithPreAction === 0) {
+ schemeScript(
+ path.join(xcodeprojPath, 'xcshareddata', 'xcschemes'),
+ `no scheme carries the "${SYNC_PRE_ACTION_TITLE}" pre-action`,
+ );
+ }
+
+ for (const xcconfigPath of xcconfigFiles(appRoot, xcodeprojPath)) {
+ const text = fs.readFileSync(xcconfigPath, 'utf8');
+ checkMachinePaths(machinePath, xcconfigPath, text, {
+ forbiddenPaths,
+ scripts: [text],
+ });
+ }
+
+ const manifests = new Map([
+ ...findPackageSwiftFiles(path.join(appRoot, 'build')).map(manifest => [
+ manifest,
+ path.dirname(manifest),
+ ]),
+ ...scaffoldedManifests(appRoot),
+ ]);
+ for (const required of GENERATED_MANIFESTS) {
+ if (!manifests.has(path.join(appRoot, required))) {
+ packageNames(path.join(appRoot, required), 'is missing');
+ }
+ }
+ const autolinkedPath = path.join(appRoot, AUTOLINKED_MANIFEST);
+ if (!manifests.has(autolinkedPath)) {
+ deploymentTarget(autolinkedPath, 'is missing');
+ }
+ const requiredPaths = requiredManifests.map(p => path.resolve(appRoot, p));
+ for (const requiredPath of requiredPaths) {
+ if (!manifests.has(requiredPath)) {
+ deploymentTarget(
+ requiredPath,
+ 'is required but was not among the manifests checked',
+ );
+ }
+ }
+ for (const [manifestPath, baseDir] of manifests) {
+ const text = fs.readFileSync(manifestPath, 'utf8');
+ checkManifestNames(packageNames, manifestPath, text, baseDir);
+ const checkFloor = FIXED_FLOOR_MANIFESTS.has(relative(manifestPath))
+ ? checkFixedFloor
+ : checkDeploymentTarget;
+ checkFloor(deploymentTarget, manifestPath, text, expectedDeploymentTarget);
+ }
+ for (const flooredPath of new Set([autolinkedPath, ...requiredPaths])) {
+ if (
+ manifests.has(flooredPath) &&
+ !/\.iOS\(/.test(fs.readFileSync(flooredPath, 'utf8'))
+ ) {
+ deploymentTarget(flooredPath, 'declares no iOS deployment target');
+ }
+ }
+
+ return violations;
+}
+
+module.exports = {checkSpmInvariants};
diff --git a/packages/react-native/scripts/spm/__tests__/spm-lifecycle-test.js b/packages/react-native/scripts/spm/__tests__/spm-lifecycle-test.js
new file mode 100644
index 00000000000..ca855e4117a
--- /dev/null
+++ b/packages/react-native/scripts/spm/__tests__/spm-lifecycle-test.js
@@ -0,0 +1,449 @@
+/**
+ * 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.
+ *
+ * @format
+ * @noflow
+ */
+
+'use strict';
+
+// Drives `setup-apple-spm.js` through scaffold → add → sync → sync → update →
+// deinit on a fixture app, the way an adopter's project sees it. The Apple
+// tools it shells out to are faked below; everything else is the real code.
+
+jest.mock('node:child_process', () => {
+ const actual = jest.requireActual('node:child_process');
+ return {
+ ...actual,
+ execFileSync(command, args, options) {
+ if (command === 'plutil') {
+ const fs = jest.requireActual('node:fs');
+ const plist = jest.requireActual('plist');
+ return Buffer.from(
+ JSON.stringify(plist.parse(fs.readFileSync(args.at(-1), 'utf8'))),
+ );
+ }
+ if (command === 'otool') {
+ return 'cmd LC_ID_DYLIB\n';
+ }
+ return actual.execFileSync(command, args, options);
+ },
+ execSync(command, options) {
+ // Codegen has its own tests; here it would need a real app's JS specs.
+ // Its output directory is what the tooling keys on, so that is faked.
+ if (String(command).includes('generate-codegen-artifacts.js')) {
+ const [, outputRoot] = /-o "([^"]+)"/.exec(String(command)) ?? [];
+ if (outputRoot == null) {
+ throw new Error(
+ `Cannot find the -o "" output directory in: ${String(command)}`,
+ );
+ }
+ jest
+ .requireActual('node:fs')
+ .mkdirSync(`${outputRoot}/build/generated/ios`, {recursive: true});
+ return Buffer.from('');
+ }
+ return actual.execSync(command, options);
+ },
+ spawnSync(command, args, options) {
+ if (command === 'pod') {
+ return {
+ status: null,
+ stdout: '',
+ stderr: '',
+ error: new Error('ENOENT'),
+ };
+ }
+ return actual.spawnSync(command, args, options);
+ },
+ };
+});
+
+const {main} = require('../../setup-apple-spm');
+const {
+ APP_DEPLOYMENT_TARGET,
+ ARTIFACT_VERSIONS,
+ PLUGIN_FRAMEWORKS,
+ REACT_NATIVE_ROOT,
+ createSpmFixtureApp,
+} = require('./spm-fixture-app');
+const {checkSpmInvariants} = require('./spm-invariants');
+const {DOMParser} = require('@xmldom/xmldom');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const {spawnSync} = jest.requireActual('node:child_process');
+
+const SPM_ENVIRONMENT = [
+ 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND',
+ 'RN_SPM_REMOTE_URL',
+ 'RN_SPM_REMOTE_VERSION',
+ 'RN_CORE_TARBALL_PATH',
+ 'RN_DEPS_TARBALL_PATH',
+ 'RN_HEADERS_TARBALL_PATH',
+ 'RN_DEPS_HEADERS_TARBALL_PATH',
+ 'RN_DEP_VERSION',
+ 'HERMES_VERSION',
+];
+// Rewritten with the time of every sync, by design.
+const SYNC_STAMP = 'build/generated/autolinking/.spm-sync-stamp';
+const LIBS_DIR = 'build/generated/autolinking/libs';
+const [CURRENT_VERSION, NEXT_VERSION] = ARTIFACT_VERSIONS;
+
+let app;
+let output;
+let savedCwd;
+let savedEnvironment;
+let fetchSpy;
+
+function artifactArgs(version) {
+ return [
+ '--version',
+ version,
+ '--artifacts',
+ app.artifactsDirs[version],
+ '--download',
+ 'skip',
+ ];
+}
+
+async function spm(argv, expectedExitCode = 0) {
+ output = [];
+ process.exitCode = undefined;
+ let exitCode;
+ try {
+ await main(argv);
+ } finally {
+ exitCode = process.exitCode ?? 0;
+ process.exitCode = undefined;
+ }
+ if (exitCode !== expectedExitCode) {
+ throw new Error(
+ `spm ${argv.join(' ')} exited ${exitCode}, expected ${expectedExitCode}:\n${output.join('\n')}`,
+ );
+ }
+}
+
+function committedOutputs() {
+ return {
+ pbxproj: fs.readFileSync(app.pbxprojPath, 'utf8'),
+ scheme: fs.readFileSync(app.schemePath, 'utf8'),
+ marker: fs.readFileSync(app.markerPath, 'utf8'),
+ };
+}
+
+function buildTree(dir = path.join(app.appRoot, 'build'), tree = {}) {
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
+ const entryPath = path.join(dir, entry.name);
+ const key = path.relative(app.appRoot, entryPath);
+ if (key === SYNC_STAMP) {
+ continue;
+ }
+ if (entry.isSymbolicLink()) {
+ tree[key] = `-> ${fs.readlinkSync(entryPath)}`;
+ } else if (entry.isDirectory()) {
+ buildTree(entryPath, tree);
+ } else {
+ tree[key] = fs.readFileSync(entryPath, 'utf8');
+ }
+ }
+ return tree;
+}
+
+function libInodes() {
+ const libsDir = path.join(app.appRoot, LIBS_DIR);
+ return Object.fromEntries(
+ fs
+ .readdirSync(libsDir)
+ .map(name => [name, fs.lstatSync(path.join(libsDir, name)).ino]),
+ );
+}
+
+function expectInvariants() {
+ expect(
+ checkSpmInvariants({
+ appRoot: app.appRoot,
+ xcodeprojPath: app.xcodeprojPath,
+ expectedDeploymentTarget: APP_DEPLOYMENT_TARGET,
+ forbiddenPaths: app.forbiddenPaths,
+ requiredManifests: [
+ path.relative(
+ app.appRoot,
+ path.join(app.libs.podspecOnly, 'Package.swift'),
+ ),
+ ],
+ }),
+ ).toEqual([]);
+ // An empty list would make the marker-vs-pbxproj framework check vacuous.
+ expect(
+ JSON.parse(fs.readFileSync(app.markerPath, 'utf8')).linkedFrameworks
+ ?.length,
+ ).toBeGreaterThan(0);
+}
+
+// #58292 baked hermes-compiler's absolute path into the pbxproj; when the
+// package does not resolve, nothing is written and that check passes vacuously.
+function assertHermesCompilerResolves() {
+ try {
+ require.resolve('hermes-compiler/package.json', {
+ paths: [REACT_NATIVE_ROOT],
+ });
+ } catch {
+ throw new Error(
+ `hermes-compiler does not resolve from ${REACT_NATIVE_ROOT}; run yarn install.`,
+ );
+ }
+}
+
+function syncPreActionScript() {
+ const scheme = new DOMParser().parseFromString(
+ fs.readFileSync(app.schemePath, 'utf8'),
+ 'text/xml',
+ );
+ const actions = Array.from(scheme.getElementsByTagName('ActionContent'));
+ const preAction = actions.find(
+ action => action.getAttribute('title') === 'Sync SPM Autolinking',
+ );
+ if (preAction == null) {
+ throw new Error('The scheme has no "Sync SPM Autolinking" pre-action');
+ }
+ return preAction.getAttribute('scriptText');
+}
+
+// Runs the scheme pre-action as Xcode would and reports whether its stale
+// check dispatched a sync (the fake `node` on PATH records the dispatch).
+function preActionDispatchesSync() {
+ fs.rmSync(app.dispatchLog, {force: true});
+ const result = spawnSync('bash', ['-c', syncPreActionScript()], {
+ cwd: app.appRoot,
+ encoding: 'utf8',
+ env: {
+ HOME: process.env.HOME,
+ PATH: `${app.toolsDir}${path.delimiter}${process.env.PATH ?? ''}`,
+ SRCROOT: app.appRoot,
+ PROJECT_DIR: app.appRoot,
+ PROJECT_FILE_PATH: app.xcodeprojPath,
+ },
+ });
+ if (result.status !== 0) {
+ throw new Error(
+ `The pre-action exited ${String(result.status)}:\n${result.stdout}${result.stderr}`,
+ );
+ }
+ const dispatched = fs.existsSync(app.dispatchLog)
+ ? fs.readFileSync(app.dispatchLog, 'utf8')
+ : '';
+ return /setup-apple-spm\.js sync\b/.test(dispatched);
+}
+
+// Bash 3.2, which Xcode runs, compares `-nt` in whole seconds, so a change
+// made in the same second as the sync would read as fresh.
+function afterLastSync() {
+ const stamp = fs.statSync(path.join(app.appRoot, SYNC_STAMP));
+ return stamp.mtimeMs / 1000 + 10;
+}
+
+function withMtime(file, seconds, run) {
+ const {atime, mtime} = fs.statSync(file);
+ fs.utimesSync(file, seconds, seconds);
+ try {
+ return run();
+ } finally {
+ fs.utimesSync(file, atime, mtime);
+ }
+}
+
+describe('spm lifecycle on a fixture app', () => {
+ let original;
+ let afterAdd;
+
+ beforeAll(() => {
+ jest.useRealTimers();
+ assertHermesCompilerResolves();
+ fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async url => {
+ throw new Error(`The lifecycle test must not fetch ${String(url)}`);
+ });
+ savedEnvironment = Object.fromEntries(
+ SPM_ENVIRONMENT.map(name => [name, process.env[name]]),
+ );
+ for (const name of SPM_ENVIRONMENT) {
+ delete process.env[name];
+ }
+ for (const method of ['log', 'warn', 'error']) {
+ jest
+ .spyOn(console, method)
+ .mockImplementation((...args) =>
+ output.push(`[${method}] ${args.join(' ')}`),
+ );
+ }
+ app = createSpmFixtureApp();
+ savedCwd = process.cwd();
+ process.chdir(app.projectRoot);
+ original = {
+ pbxproj: fs.readFileSync(app.pbxprojPath, 'utf8'),
+ scheme: fs.readFileSync(app.schemePath, 'utf8'),
+ };
+ });
+
+ afterEach(() => {
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ afterAll(() => {
+ try {
+ app?.cleanup();
+ } finally {
+ if (savedCwd != null) {
+ process.chdir(savedCwd);
+ }
+ for (const [name, value] of Object.entries(savedEnvironment ?? {})) {
+ if (value === undefined) {
+ delete process.env[name];
+ } else {
+ process.env[name] = value;
+ }
+ }
+ jest.restoreAllMocks();
+ }
+ });
+
+ it('scaffolds a manifest for the podspec-only library', async () => {
+ await spm([
+ 'scaffold',
+ ...artifactArgs(CURRENT_VERSION),
+ '--config-command',
+ JSON.stringify(app.configCommand),
+ ]);
+
+ expect(
+ fs.existsSync(path.join(app.libs.podspecOnly, 'Package.swift')),
+ ).toBe(true);
+ expectInvariants();
+ });
+
+ // `scaffold` injects too and has no flag to skip that, so `add` never sees a
+ // pristine project here; deinit's byte comparison covers the pristine side.
+ it('adds SwiftPM to the app', async () => {
+ await spm(['add', ...artifactArgs(CURRENT_VERSION)]);
+
+ afterAdd = {
+ outputs: committedOutputs(),
+ inodes: libInodes(),
+ };
+ expect(Object.keys(afterAdd.inodes).sort()).toEqual([
+ 'AcmeNative',
+ 'AcmeWidgets',
+ 'ReactNativeScoped',
+ ]);
+ expectInvariants();
+ });
+
+ it('syncs twice without changing any output or replacing a libs/ entry', async () => {
+ await spm(['sync']);
+ const afterFirstSync = {
+ outputs: committedOutputs(),
+ build: buildTree(),
+ inodes: libInodes(),
+ };
+ expectInvariants();
+
+ await spm(['sync']);
+
+ expectInvariants();
+ expect(afterFirstSync.outputs).toEqual(afterAdd.outputs);
+ expect(committedOutputs()).toEqual(afterAdd.outputs);
+ expect(buildTree()).toEqual(afterFirstSync.build);
+ expect(afterFirstSync.inodes).toEqual(afterAdd.inodes);
+ expect(libInodes()).toEqual(afterAdd.inodes);
+ });
+
+ describe('the scheme pre-action stale check', () => {
+ beforeEach(async () => {
+ await spm(['sync']);
+ expectInvariants();
+ expect(preActionDispatchesSync()).toBe(false);
+ });
+
+ it('ignores the state Xcode writes into a library .swiftpm folder', () => {
+ const userData = path.join(
+ app.libs.selfManaged,
+ '.swiftpm',
+ 'xcuserdata',
+ );
+ const file = path.join(userData, 'x');
+ const later = afterLastSync();
+ const {atime, mtime} = fs.statSync(userData);
+ fs.writeFileSync(file, '');
+ fs.utimesSync(file, later, later);
+ fs.utimesSync(userData, later, later);
+ try {
+ expect(preActionDispatchesSync()).toBe(false);
+ } finally {
+ fs.rmSync(file);
+ fs.utimesSync(userData, atime, mtime);
+ }
+ });
+
+ it('re-syncs when a watched path that was absent appears', () => {
+ // The parent keeps its mtime, so the new path itself is the only change
+ // the check can react to.
+ const parent = path.dirname(app.absentPluginWatchPath);
+ const {atime, mtime} = fs.statSync(parent);
+ fs.mkdirSync(app.absentPluginWatchPath);
+ fs.utimesSync(parent, atime, mtime);
+ try {
+ expect(preActionDispatchesSync()).toBe(true);
+ } finally {
+ fs.rmSync(app.absentPluginWatchPath, {recursive: true});
+ fs.utimesSync(parent, atime, mtime);
+ }
+ });
+
+ it('re-syncs when the marker changes', () => {
+ withMtime(app.markerPath, afterLastSync(), () => {
+ expect(preActionDispatchesSync()).toBe(true);
+ });
+ });
+ });
+
+ it('fails sync with exit 2 when a plugin adds a framework the project does not link', async () => {
+ app.setPluginFrameworks(PLUGIN_FRAMEWORKS);
+ try {
+ await spm(['sync'], 2);
+ expect(output.join('\n')).toContain(
+ 'AcmeHostExtras (AcmeHostExtras) is a precompiled framework from an autolinking plugin, but the Xcode project does not link it',
+ );
+ expectInvariants();
+ } finally {
+ app.setPluginFrameworks(PLUGIN_FRAMEWORKS.slice(0, 1));
+ }
+ await spm(['sync']);
+ expectInvariants();
+ });
+
+ it('updates to the same version without changing the project or scheme', async () => {
+ await spm(['update', ...artifactArgs(CURRENT_VERSION)]);
+
+ expectInvariants();
+ const {pbxproj, scheme} = committedOutputs();
+ expect(pbxproj).toBe(afterAdd.outputs.pbxproj);
+ expect(scheme).toBe(afterAdd.outputs.scheme);
+ });
+
+ it('updates to a new version', async () => {
+ await spm(['update', ...artifactArgs(NEXT_VERSION)]);
+
+ expectInvariants();
+ });
+
+ it('deinit restores the project and scheme the app started with', async () => {
+ await spm(['deinit']);
+
+ expect(fs.readFileSync(app.pbxprojPath, 'utf8')).toBe(original.pbxproj);
+ expect(fs.readFileSync(app.schemePath, 'utf8')).toBe(original.scheme);
+ expect(fs.existsSync(app.markerPath)).toBe(false);
+ });
+});