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); + }); +});