diff --git a/ios/app.mjs b/ios/app.mjs index a7f27eff6..323c0843e 100644 --- a/ios/app.mjs +++ b/ios/app.mjs @@ -4,7 +4,7 @@ import * as path from "node:path"; import { URL, fileURLToPath } from "node:url"; import { loadAppConfig } from "../scripts/appConfig.mjs"; import { findFile, readTextFile, toVersionNumber } from "../scripts/helpers.js"; -import { cp_r, mkdir_p } from "../scripts/utils/filesystem.mjs"; +import { cp_r, mkdir_p, rm_r } from "../scripts/utils/filesystem.mjs"; import { generateAssetsCatalogs } from "./assetsCatalog.mjs"; import { generateEntitlements } from "./entitlements.mjs"; import { generateInfoPlist } from "./infoPlist.mjs"; @@ -34,13 +34,23 @@ import { const SUPPORTED_PLATFORMS = ["ios", "macos", "visionos"]; +/** + * @param {string} platform + * @returns {asserts platform is ApplePlatform} + */ +function assertSupportedPlatform(platform) { + if (!SUPPORTED_PLATFORMS.includes(platform)) { + throw new Error(`Unsupported platform: ${platform}`); + } +} + /** * @param {string} projectRoot * @param {string} destination * @returns {void} */ function exportNodeBinaryPath(projectRoot, destination, fs = nodefs) { - const node = process.argv0; + const node = process.argv[0]; fs.writeFileSync( path.join(projectRoot, ".xcode.env"), `export NODE_BINARY='${node}'\n` @@ -99,7 +109,7 @@ function readPackageVersion(p, fs = nodefs) { /** * @param {string} projectRoot - * @param {ApplePlatform} targetPlatform + * @param {string} targetPlatform * @param {JSONObject} options * @returns {ProjectConfiguration} */ @@ -109,9 +119,7 @@ export function generateProject( options, fs = nodefs ) { - if (!SUPPORTED_PLATFORMS.includes(targetPlatform)) { - throw new Error(`Unsupported platform: ${targetPlatform}`); - } + assertSupportedPlatform(targetPlatform); const appConfig = loadAppConfig(projectRoot, fs); @@ -134,7 +142,11 @@ export function generateProject( // Link source files const srcDirs = ["ReactTestApp", "ReactTestAppTests", "ReactTestAppUITests"]; for (const file of srcDirs) { - fs.linkSync(projectPath(file, targetPlatform), destination); + const symlink = path.join(destination, file); + if (fs.existsSync(symlink)) { + rm_r(symlink, fs); + } + fs.symlinkSync(projectPath(file, targetPlatform), symlink); } // Shared code lives in `ios/ReactTestApp/` @@ -142,7 +154,7 @@ export function generateProject( const shared = path.join(destination, "Shared"); if (!fs.existsSync(shared)) { const source = new URL("ReactTestApp", import.meta.url); - fs.linkSync(fileURLToPath(source), shared); + fs.symlinkSync(fileURLToPath(source), shared); } } @@ -168,8 +180,8 @@ export function generateProject( /** @type {ProjectConfiguration} */ const project = { - xcodeprojPath: xcodeprojDst, - reactNativePath, + xcodeprojPath: path.resolve(xcodeprojDst), + reactNativePath: path.resolve(reactNativePath), reactNativeVersion, useNewArch, useBridgeless, @@ -178,9 +190,7 @@ export function generateProject( uitestsBuildSettings: {}, }; - if (isObject(platformConfig)) { - applyBuildSettings(platformConfig, project, projectRoot, destination, fs); - } + applyBuildSettings(platformConfig, project, projectRoot, destination, fs); const overrides = options["buildSettingOverrides"]; if (isObject(overrides)) { diff --git a/ios/assetsCatalog.mjs b/ios/assetsCatalog.mjs index 7e9b37c9d..abf6405fb 100644 --- a/ios/assetsCatalog.mjs +++ b/ios/assetsCatalog.mjs @@ -92,7 +92,7 @@ export function generateAssetsCatalogs( const xcassets_dst = path.join(destination, path.basename(xcassets_src)); rm_r(xcassets_dst, fs); - cp_r(xcassets_src, xcassets_dst, fs); + cp_r(xcassets_src, destination, fs); const platformConfig = appConfig[targetPlatform]; if (!isObject(platformConfig)) { diff --git a/ios/utils.mjs b/ios/utils.mjs index aa72a6605..5dde7d45c 100644 --- a/ios/utils.mjs +++ b/ios/utils.mjs @@ -68,7 +68,7 @@ export function plistFromJSON(source, filename) { */ export function projectPath(p, targetPlatform) { const packageDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); - return path.join(packageDir, targetPlatform, p); + return path.resolve(packageDir, targetPlatform, p); } /** diff --git a/ios/xcode.mjs b/ios/xcode.mjs index 7e62240bf..6ecd74209 100644 --- a/ios/xcode.mjs +++ b/ios/xcode.mjs @@ -9,6 +9,7 @@ import { isObject, isString } from "./utils.mjs"; * @import { * ApplePlatform, * JSONObject, + * JSONValue, * ProjectConfiguration, * XmlOptions, * } from "../scripts/types.js"; @@ -32,7 +33,7 @@ export const USER_HEADER_SEARCH_PATHS = "USER_HEADER_SEARCH_PATHS"; export const WARNING_CFLAGS = "WARNING_CFLAGS"; /** - * @param {JSONObject} platformConfig + * @param {JSONValue} platformConfig * @param {ProjectConfiguration} project * @param {string} projectRoot * @param {string} destination @@ -45,7 +46,9 @@ export function applyBuildSettings( destination, fs = nodefs ) { - const codeSignEntitlements = platformConfig["codeSignEntitlements"]; + const config = isObject(platformConfig) ? platformConfig : {}; + + const codeSignEntitlements = config["codeSignEntitlements"]; if (isString(codeSignEntitlements)) { const appManifest = findFile("app.json", projectRoot, fs); if (!appManifest) { @@ -58,19 +61,19 @@ export function applyBuildSettings( project.buildSettings[CODE_SIGN_ENTITLEMENTS] = relPath; } - const codeSignIdentity = platformConfig["codeSignIdentity"]; + const codeSignIdentity = config["codeSignIdentity"]; if (isString(codeSignIdentity)) { project.buildSettings[CODE_SIGN_IDENTITY] = codeSignIdentity; } - const developmentTeam = platformConfig["developmentTeam"]; + const developmentTeam = config["developmentTeam"]; if (isString(developmentTeam)) { project.buildSettings[DEVELOPMENT_TEAM] = developmentTeam; project.testsBuildSettings[DEVELOPMENT_TEAM] = developmentTeam; project.uitestsBuildSettings[DEVELOPMENT_TEAM] = developmentTeam; } - const bundleIdentifier = platformConfig["bundleIdentifier"]; + const bundleIdentifier = config["bundleIdentifier"]; if (isString(bundleIdentifier)) { project.buildSettings[PRODUCT_BUNDLE_IDENTIFIER] = bundleIdentifier; project.testsBuildSettings[PRODUCT_BUNDLE_IDENTIFIER] = @@ -79,7 +82,7 @@ export function applyBuildSettings( `${bundleIdentifier}UITests`; } - const buildNumber = platformConfig["buildNumber"]; + const buildNumber = config["buildNumber"]; project.buildSettings[PRODUCT_BUILD_NUMBER] = buildNumber && isString(buildNumber) ? buildNumber : "1"; @@ -161,7 +164,7 @@ export function applyUserHeaderSearchPaths({ buildSettings }, destination) { const existingPaths = buildSettings[USER_HEADER_SEARCH_PATHS]; const searchPaths = Array.isArray(existingPaths) ? existingPaths : []; - searchPaths.push(path.dirname(destination)); + searchPaths.push(path.resolve(path.dirname(destination))); buildSettings[USER_HEADER_SEARCH_PATHS] = searchPaths; } diff --git a/scripts/appConfig.mjs b/scripts/appConfig.mjs index cd7eaa77a..d76510fda 100644 --- a/scripts/appConfig.mjs +++ b/scripts/appConfig.mjs @@ -6,19 +6,17 @@ import { findFile, readJSONFile } from "./helpers.js"; const SOURCE_KEY = Symbol.for("source"); -/** @type {JSONObject} */ -let appConfig; - /** * @param {string} projectRoot * @returns {JSONObject} */ export function loadAppConfig(projectRoot, fs = nodefs) { - if (!appConfig) { - const configFile = findFile("app.json", projectRoot, fs); - appConfig = configFile ? readJSONFile(configFile) : {}; - appConfig[SOURCE_KEY] = configFile || projectRoot; - } + const configFile = findFile("app.json", projectRoot, fs); + + /** @type {JSONObject} */ + const appConfig = configFile ? readJSONFile(configFile, fs) : {}; + appConfig[SOURCE_KEY] = configFile || projectRoot; + return appConfig; } diff --git a/test/fs.mock.ts b/test/fs.mock.ts index 47868a28d..0743c5ed3 100644 --- a/test/fs.mock.ts +++ b/test/fs.mock.ts @@ -1,11 +1,47 @@ /* node:coverage disable */ import type { DirectoryJSON } from "memfs"; import { fs as memfs, vol } from "memfs"; +import * as path from "node:path"; +import { mkdir_p } from "../scripts/utils/filesystem.mjs"; export const fs = memfs as unknown as typeof import("node:fs"); -// Stub `cpSync` until memfs implements it -fs.cpSync = fs.cpSync ?? (() => undefined); +// Add simple `cpSync` implementation until memfs implements it +fs.cpSync = + fs.cpSync ?? + ((src: string, dst: string, options) => { + const srcStat = fs.statSync(src); + const dstStat = fs.statSync(dst); + if (!srcStat.isDirectory()) { + const finalDst = dstStat.isDirectory() + ? path.join(dst, path.basename(src)) + : dst; + return fs.copyFileSync(src, finalDst); + } + + let finalDst: string; + if (!dstStat.isDirectory()) { + fs.rmSync(dst); + finalDst = dst; + } else { + finalDst = path.join(dst, path.basename(src)); + } + + mkdir_p(finalDst, fs); + for (const filename of fs.readdirSync(src)) { + const p = path.join(src, filename); + const pStat = fs.statSync(p); + if (pStat.isDirectory()) { + if (options?.recursive) { + fs.cpSync(p, finalDst, options); + } else { + mkdir_p(path.join(finalDst, filename)); + } + } else { + fs.copyFileSync(p, path.join(finalDst, filename)); + } + } + }); export function setMockFiles(files: DirectoryJSON = {}) { vol.reset(); diff --git a/test/ios/app.test.ts b/test/ios/app.test.ts new file mode 100644 index 000000000..fab38f997 --- /dev/null +++ b/test/ios/app.test.ts @@ -0,0 +1,587 @@ +import { deepEqual, equal, fail, ok, throws } from "node:assert/strict"; +import * as fs from "node:fs"; +import { afterEach, before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { generateProject as generateProjectActual } from "../../ios/app.mjs"; +import { USER_HEADER_SEARCH_PATHS } from "../../ios/xcode.mjs"; +import { readTextFile } from "../../scripts/helpers.js"; +import type { + ApplePlatform, + JSONObject, + ProjectConfiguration, +} from "../../scripts/types.ts"; +import { fs as mockfs, setMockFiles, toJSON } from "../fs.mock.ts"; + +const macosOnly = { skip: process.platform === "win32" }; + +describe("generateProject()", macosOnly, () => { + function generateProject( + projectRoot: string, + platform: ApplePlatform, + options: JSONObject + ): ProjectConfiguration { + return generateProjectActual(projectRoot, platform, options, mockfs); + } + + function makeMockProject(overrides?: Record) { + const manifestURL = new URL("../../package.json", import.meta.url); + const manifest = readTextFile(fileURLToPath(manifestURL)); + const { name, version, defaultPlatformPackages } = JSON.parse(manifest); + return { + "app.json": JSON.stringify({ name: "ContosoApp", ...overrides }), + "ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme": + "", + "ios/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json": "", + "ios/ReactTestApp/Assets.xcassets/Contents.json": "", + "ios/ReactTestApp/Info.plist": "", + "ios/ReactTestAppTests/Info.plist": "", + "ios/ReactTestAppUITests/Info.plist": "", + "macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme": + "", + "macos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json": "", + "macos/ReactTestApp/Assets.xcassets/Contents.json": "", + "macos/ReactTestApp/Info.plist": "", + "macos/ReactTestAppTests/Info.plist": "", + "macos/ReactTestAppUITests/Info.plist": "", + "visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme": + "", + "visionos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json": "", + "visionos/ReactTestApp/Assets.xcassets/Contents.json": "", + "visionos/ReactTestApp/Info.plist": "", + "visionos/ReactTestAppTests/Info.plist": "", + "visionos/ReactTestAppUITests/Info.plist": "", + "package.json": JSON.stringify({ + name, + version, + defaultPlatformPackages, + }), + "node_modules/@callstack/react-native-visionos/package.json": + JSON.stringify({ + name: "@callstack/react-native-visionos", + version: "1000.0.0", + }), + "node_modules/react-native/package.json": JSON.stringify({ + name: "react-native", + version: "1000.0.0", + }), + "node_modules/react-native-macos/package.json": JSON.stringify({ + name: "react-native-macos", + version: "1000.0.0", + }), + }; + } + + function trimPath(path: string, trim: string) { + return path?.replace(trim, "/~"); + } + + function trimPaths( + project: ProjectConfiguration, + trim: string + ): ProjectConfiguration { + const searchPaths = project.buildSettings[USER_HEADER_SEARCH_PATHS]; + if (!Array.isArray(searchPaths)) { + fail("'USER_HEADER_SEARCH_PATHS' should have been set"); + } + + for (let i = 0; i < searchPaths.length; ++i) { + searchPaths[i] = trimPath(searchPaths[i], trim); + } + + project.reactNativePath = trimPath(project.reactNativePath, trim); + project.xcodeprojPath = trimPath(project.xcodeprojPath, trim); + + return project; + } + + before(() => { + setMockFiles(); + }); + + afterEach(() => { + setMockFiles(); + }); + + it("throws on unsupported platforms", () => { + throws( + () => generateProject("", "android" as unknown as "ios", {}), + new Error("Unsupported platform: android") + ); + throws( + () => generateProject("", "windows" as unknown as "ios", {}), + new Error("Unsupported platform: windows") + ); + }); + + it("throws when 'node_modules' cannot be found", () => { + setMockFiles({ "app.json": "{}" }); + + throws( + () => generateProject("", "ios", {}), + new Error("Cannot not find 'node_modules' folder") + ); + }); + + for (const platform of ["ios", "macos", "visionos"] as const) { + it(`[${platform}] generates Xcode project files for old architecture`, () => { + setMockFiles(makeMockProject()); + + const result = generateProject(platform, platform, {}); + + const cwd = process.cwd(); + const cwdLength = cwd.length; + const files = Object.keys(toJSON()).map((path) => + path.substring(cwdLength) + ); + + const expected = PROJECT_FILES.oldArch[platform]; + + deepEqual(files, expected.files); + deepEqual(trimPaths(result, cwd), expected.result); + }); + + it(`[${platform}] generates Xcode project files for new architecture`, () => { + setMockFiles(makeMockProject()); + + const result = generateProject(platform, platform, { + fabricEnabled: true, + }); + + const cwd = process.cwd(); + const cwdLength = cwd.length; + const files = Object.keys(toJSON()).map((path) => + path.substring(cwdLength) + ); + + const expected = PROJECT_FILES.newArch[platform]; + + deepEqual(files, expected.files); + deepEqual(trimPaths(result, cwd), expected.result); + }); + + it(`[${platform}] exports path to Node binary`, () => { + setMockFiles(makeMockProject()); + + generateProject(platform, platform, { fabricEnabled: true }); + const xcode_env = readTextFile(`${platform}/.xcode.env`, mockfs); + const xcode_m = xcode_env.match(/export NODE_BINARY='(.*?)'/); + + ok(xcode_m); + ok(xcode_m[1].startsWith("/")); + ok(fs.existsSync(xcode_m[1])); + + const env = readTextFile( + `node_modules/.generated/${platform}/.env`, + mockfs + ); + const env_m = env.match(/export PATH='(.*?)':\$PATH/); + + ok(env_m); + ok(env_m[1].startsWith("/")); + ok(fs.existsSync(env_m[1])); + }); + + it(`[${platform}] applies build setting overrides`, () => { + setMockFiles(makeMockProject()); + + const result = generateProject(platform, platform, { + buildSettingOverrides: { + ONLY_ACTIVE_ARCH: "NO", + }, + }); + + equal(result.buildSettings.ONLY_ACTIVE_ARCH, "NO"); + }); + + it(`[${platform}] returns single app property`, () => { + setMockFiles(makeMockProject({ singleApp: "Main" })); + + const result = generateProject(platform, platform, {}); + + equal(result.singleApp, "Main"); + }); + } + + it("uses custom React Native path", () => { + setMockFiles( + makeMockProject({ + ios: { reactNativePath: "node_modules/react-native-macos" }, + }) + ); + + const result = generateProject("ios", "ios", { fabricEnabled: true }); + const cwd = process.cwd(); + + deepEqual(trimPaths(result, cwd), PROJECT_FILES.customReactNative); + }); +}); + +const PROJECT_FILES = { + customReactNative: { + buildSettings: { + GCC_PREPROCESSOR_DEFINITIONS: [ + "REACT_NATIVE_VERSION=1000000000", + "FOLLY_NO_CONFIG=1", + "RCT_NEW_ARCH_ENABLED=1", + "USE_FABRIC=1", + "USE_BRIDGELESS=1", + ], + OTHER_SWIFT_FLAGS: ["-DUSE_FABRIC", "-DUSE_BRIDGELESS"], + PRODUCT_BUILD_NUMBER: "1", + PRODUCT_DISPLAY_NAME: "ContosoApp", + PRODUCT_VERSION: "1.0", + USER_HEADER_SEARCH_PATHS: ["/~/node_modules/.generated"], + }, + reactNativePath: "/~/node_modules/react-native-macos", + reactNativeVersion: 1000000000, + testsBuildSettings: {}, + uitestsBuildSettings: {}, + useBridgeless: true, + useNewArch: true, + xcodeprojPath: "/~/node_modules/.generated/ios/ReactTestApp.xcodeproj", + }, + newArch: { + ios: { + result: { + buildSettings: { + GCC_PREPROCESSOR_DEFINITIONS: [ + "REACT_NATIVE_VERSION=1000000000", + "FOLLY_NO_CONFIG=1", + "RCT_NEW_ARCH_ENABLED=1", + "USE_FABRIC=1", + "USE_BRIDGELESS=1", + ], + OTHER_SWIFT_FLAGS: ["-DUSE_FABRIC", "-DUSE_BRIDGELESS"], + PRODUCT_BUILD_NUMBER: "1", + PRODUCT_DISPLAY_NAME: "ContosoApp", + PRODUCT_VERSION: "1.0", + USER_HEADER_SEARCH_PATHS: ["/~/node_modules/.generated"], + }, + reactNativePath: "/~/node_modules/react-native", + reactNativeVersion: 1000000000, + testsBuildSettings: {}, + uitestsBuildSettings: {}, + useBridgeless: true, + useNewArch: true, + xcodeprojPath: "/~/node_modules/.generated/ios/ReactTestApp.xcodeproj", + }, + files: [ + "/app.json", + "/ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/ios/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/ios/ReactTestApp/Assets.xcassets/Contents.json", + "/ios/ReactTestApp/Info.plist", + "/ios/ReactTestAppTests/Info.plist", + "/ios/ReactTestAppUITests/Info.plist", + "/ios/.xcode.env", + "/macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/macos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/macos/ReactTestApp/Assets.xcassets/Contents.json", + "/macos/ReactTestApp/Info.plist", + "/macos/ReactTestAppTests/Info.plist", + "/macos/ReactTestAppUITests/Info.plist", + "/visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/visionos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/visionos/ReactTestApp/Assets.xcassets/Contents.json", + "/visionos/ReactTestApp/Info.plist", + "/visionos/ReactTestAppTests/Info.plist", + "/visionos/ReactTestAppUITests/Info.plist", + "/package.json", + "/node_modules/@callstack/react-native-visionos/package.json", + "/node_modules/react-native/package.json", + "/node_modules/react-native-macos/package.json", + "/node_modules/.generated/ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/node_modules/.generated/ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ContosoApp.xcscheme", + "/node_modules/.generated/ios/Assets.xcassets/AppIcon.iconset/Contents.json", + "/node_modules/.generated/ios/Assets.xcassets/Contents.json", + "/node_modules/.generated/ios/App.entitlements", + "/node_modules/.generated/ios/Info.plist", + "/node_modules/.generated/ios/PrivacyInfo.xcprivacy", + "/node_modules/.generated/ios/.env", + ], + }, + macos: { + result: { + buildSettings: { + GCC_PREPROCESSOR_DEFINITIONS: [ + "REACT_NATIVE_VERSION=1000000000", + "FOLLY_NO_CONFIG=1", + "RCT_NEW_ARCH_ENABLED=1", + "USE_FABRIC=1", + "USE_BRIDGELESS=1", + ], + OTHER_SWIFT_FLAGS: ["-DUSE_FABRIC", "-DUSE_BRIDGELESS"], + PRODUCT_BUILD_NUMBER: "1", + PRODUCT_DISPLAY_NAME: "ContosoApp", + PRODUCT_VERSION: "1.0", + USER_HEADER_SEARCH_PATHS: ["/~/node_modules/.generated"], + }, + reactNativePath: "/~/node_modules/react-native-macos", + reactNativeVersion: 1000000000, + testsBuildSettings: {}, + uitestsBuildSettings: {}, + useBridgeless: true, + useNewArch: true, + xcodeprojPath: + "/~/node_modules/.generated/macos/ReactTestApp.xcodeproj", + }, + files: [ + "/app.json", + "/ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/ios/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/ios/ReactTestApp/Assets.xcassets/Contents.json", + "/ios/ReactTestApp/Info.plist", + "/ios/ReactTestAppTests/Info.plist", + "/ios/ReactTestAppUITests/Info.plist", + "/macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/macos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/macos/ReactTestApp/Assets.xcassets/Contents.json", + "/macos/ReactTestApp/Info.plist", + "/macos/ReactTestAppTests/Info.plist", + "/macos/ReactTestAppUITests/Info.plist", + "/macos/.xcode.env", + "/visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/visionos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/visionos/ReactTestApp/Assets.xcassets/Contents.json", + "/visionos/ReactTestApp/Info.plist", + "/visionos/ReactTestAppTests/Info.plist", + "/visionos/ReactTestAppUITests/Info.plist", + "/package.json", + "/node_modules/@callstack/react-native-visionos/package.json", + "/node_modules/react-native/package.json", + "/node_modules/react-native-macos/package.json", + "/node_modules/.generated/macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/node_modules/.generated/macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ContosoApp.xcscheme", + "/node_modules/.generated/macos/Assets.xcassets/AppIcon.iconset/Contents.json", + "/node_modules/.generated/macos/Assets.xcassets/Contents.json", + "/node_modules/.generated/macos/App.entitlements", + "/node_modules/.generated/macos/Info.plist", + "/node_modules/.generated/macos/PrivacyInfo.xcprivacy", + "/node_modules/.generated/macos/.env", + ], + }, + visionos: { + result: { + buildSettings: { + GCC_PREPROCESSOR_DEFINITIONS: [ + "REACT_NATIVE_VERSION=1000000000", + "FOLLY_NO_CONFIG=1", + "RCT_NEW_ARCH_ENABLED=1", + "USE_FABRIC=1", + "USE_BRIDGELESS=1", + ], + OTHER_SWIFT_FLAGS: ["-DUSE_FABRIC", "-DUSE_BRIDGELESS"], + PRODUCT_BUILD_NUMBER: "1", + PRODUCT_DISPLAY_NAME: "ContosoApp", + PRODUCT_VERSION: "1.0", + USER_HEADER_SEARCH_PATHS: ["/~/node_modules/.generated"], + }, + reactNativePath: "/~/node_modules/@callstack/react-native-visionos", + reactNativeVersion: 1000000000, + testsBuildSettings: {}, + uitestsBuildSettings: {}, + useBridgeless: true, + useNewArch: true, + xcodeprojPath: + "/~/node_modules/.generated/visionos/ReactTestApp.xcodeproj", + }, + files: [ + "/app.json", + "/ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/ios/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/ios/ReactTestApp/Assets.xcassets/Contents.json", + "/ios/ReactTestApp/Info.plist", + "/ios/ReactTestAppTests/Info.plist", + "/ios/ReactTestAppUITests/Info.plist", + "/macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/macos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/macos/ReactTestApp/Assets.xcassets/Contents.json", + "/macos/ReactTestApp/Info.plist", + "/macos/ReactTestAppTests/Info.plist", + "/macos/ReactTestAppUITests/Info.plist", + "/visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/visionos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/visionos/ReactTestApp/Assets.xcassets/Contents.json", + "/visionos/ReactTestApp/Info.plist", + "/visionos/ReactTestAppTests/Info.plist", + "/visionos/ReactTestAppUITests/Info.plist", + "/visionos/.xcode.env", + "/package.json", + "/node_modules/@callstack/react-native-visionos/package.json", + "/node_modules/react-native/package.json", + "/node_modules/react-native-macos/package.json", + "/node_modules/.generated/visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/node_modules/.generated/visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ContosoApp.xcscheme", + "/node_modules/.generated/visionos/Assets.xcassets/AppIcon.iconset/Contents.json", + "/node_modules/.generated/visionos/Assets.xcassets/Contents.json", + "/node_modules/.generated/visionos/App.entitlements", + "/node_modules/.generated/visionos/Info.plist", + "/node_modules/.generated/visionos/PrivacyInfo.xcprivacy", + "/node_modules/.generated/visionos/.env", + ], + }, + }, + oldArch: { + ios: { + result: { + buildSettings: { + GCC_PREPROCESSOR_DEFINITIONS: ["REACT_NATIVE_VERSION=1000000000"], + OTHER_SWIFT_FLAGS: [], + PRODUCT_BUILD_NUMBER: "1", + PRODUCT_DISPLAY_NAME: "ContosoApp", + PRODUCT_VERSION: "1.0", + USER_HEADER_SEARCH_PATHS: ["/~/node_modules/.generated"], + }, + reactNativePath: "/~/node_modules/react-native", + reactNativeVersion: 1000000000, + testsBuildSettings: {}, + uitestsBuildSettings: {}, + useBridgeless: false, + useNewArch: false, + xcodeprojPath: "/~/node_modules/.generated/ios/ReactTestApp.xcodeproj", + }, + files: [ + "/app.json", + "/ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/ios/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/ios/ReactTestApp/Assets.xcassets/Contents.json", + "/ios/ReactTestApp/Info.plist", + "/ios/ReactTestAppTests/Info.plist", + "/ios/ReactTestAppUITests/Info.plist", + "/ios/.xcode.env", + "/macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/macos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/macos/ReactTestApp/Assets.xcassets/Contents.json", + "/macos/ReactTestApp/Info.plist", + "/macos/ReactTestAppTests/Info.plist", + "/macos/ReactTestAppUITests/Info.plist", + "/visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/visionos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/visionos/ReactTestApp/Assets.xcassets/Contents.json", + "/visionos/ReactTestApp/Info.plist", + "/visionos/ReactTestAppTests/Info.plist", + "/visionos/ReactTestAppUITests/Info.plist", + "/package.json", + "/node_modules/@callstack/react-native-visionos/package.json", + "/node_modules/react-native/package.json", + "/node_modules/react-native-macos/package.json", + "/node_modules/.generated/ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/node_modules/.generated/ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ContosoApp.xcscheme", + "/node_modules/.generated/ios/Assets.xcassets/AppIcon.iconset/Contents.json", + "/node_modules/.generated/ios/Assets.xcassets/Contents.json", + "/node_modules/.generated/ios/App.entitlements", + "/node_modules/.generated/ios/Info.plist", + "/node_modules/.generated/ios/PrivacyInfo.xcprivacy", + "/node_modules/.generated/ios/.env", + ], + }, + macos: { + result: { + buildSettings: { + GCC_PREPROCESSOR_DEFINITIONS: ["REACT_NATIVE_VERSION=1000000000"], + OTHER_SWIFT_FLAGS: [], + PRODUCT_BUILD_NUMBER: "1", + PRODUCT_DISPLAY_NAME: "ContosoApp", + PRODUCT_VERSION: "1.0", + USER_HEADER_SEARCH_PATHS: ["/~/node_modules/.generated"], + }, + reactNativePath: "/~/node_modules/react-native-macos", + reactNativeVersion: 1000000000, + testsBuildSettings: {}, + uitestsBuildSettings: {}, + useBridgeless: false, + useNewArch: false, + xcodeprojPath: + "/~/node_modules/.generated/macos/ReactTestApp.xcodeproj", + }, + files: [ + "/app.json", + "/ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/ios/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/ios/ReactTestApp/Assets.xcassets/Contents.json", + "/ios/ReactTestApp/Info.plist", + "/ios/ReactTestAppTests/Info.plist", + "/ios/ReactTestAppUITests/Info.plist", + "/macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/macos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/macos/ReactTestApp/Assets.xcassets/Contents.json", + "/macos/ReactTestApp/Info.plist", + "/macos/ReactTestAppTests/Info.plist", + "/macos/ReactTestAppUITests/Info.plist", + "/macos/.xcode.env", + "/visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/visionos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/visionos/ReactTestApp/Assets.xcassets/Contents.json", + "/visionos/ReactTestApp/Info.plist", + "/visionos/ReactTestAppTests/Info.plist", + "/visionos/ReactTestAppUITests/Info.plist", + "/package.json", + "/node_modules/@callstack/react-native-visionos/package.json", + "/node_modules/react-native/package.json", + "/node_modules/react-native-macos/package.json", + "/node_modules/.generated/macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/node_modules/.generated/macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ContosoApp.xcscheme", + "/node_modules/.generated/macos/Assets.xcassets/AppIcon.iconset/Contents.json", + "/node_modules/.generated/macos/Assets.xcassets/Contents.json", + "/node_modules/.generated/macos/App.entitlements", + "/node_modules/.generated/macos/Info.plist", + "/node_modules/.generated/macos/PrivacyInfo.xcprivacy", + "/node_modules/.generated/macos/.env", + ], + }, + visionos: { + result: { + buildSettings: { + GCC_PREPROCESSOR_DEFINITIONS: ["REACT_NATIVE_VERSION=1000000000"], + OTHER_SWIFT_FLAGS: [], + PRODUCT_BUILD_NUMBER: "1", + PRODUCT_DISPLAY_NAME: "ContosoApp", + PRODUCT_VERSION: "1.0", + USER_HEADER_SEARCH_PATHS: ["/~/node_modules/.generated"], + }, + reactNativePath: "/~/node_modules/@callstack/react-native-visionos", + reactNativeVersion: 1000000000, + testsBuildSettings: {}, + uitestsBuildSettings: {}, + useBridgeless: false, + useNewArch: false, + xcodeprojPath: + "/~/node_modules/.generated/visionos/ReactTestApp.xcodeproj", + }, + files: [ + "/app.json", + "/ios/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/ios/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/ios/ReactTestApp/Assets.xcassets/Contents.json", + "/ios/ReactTestApp/Info.plist", + "/ios/ReactTestAppTests/Info.plist", + "/ios/ReactTestAppUITests/Info.plist", + "/macos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/macos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/macos/ReactTestApp/Assets.xcassets/Contents.json", + "/macos/ReactTestApp/Info.plist", + "/macos/ReactTestAppTests/Info.plist", + "/macos/ReactTestAppUITests/Info.plist", + "/visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/visionos/ReactTestApp/Assets.xcassets/AppIcon.iconset/Contents.json", + "/visionos/ReactTestApp/Assets.xcassets/Contents.json", + "/visionos/ReactTestApp/Info.plist", + "/visionos/ReactTestAppTests/Info.plist", + "/visionos/ReactTestAppUITests/Info.plist", + "/visionos/.xcode.env", + "/package.json", + "/node_modules/@callstack/react-native-visionos/package.json", + "/node_modules/react-native/package.json", + "/node_modules/react-native-macos/package.json", + "/node_modules/.generated/visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ReactTestApp.xcscheme", + "/node_modules/.generated/visionos/ReactTestApp.xcodeproj/xcshareddata/xcschemes/ContosoApp.xcscheme", + "/node_modules/.generated/visionos/Assets.xcassets/AppIcon.iconset/Contents.json", + "/node_modules/.generated/visionos/Assets.xcassets/Contents.json", + "/node_modules/.generated/visionos/App.entitlements", + "/node_modules/.generated/visionos/Info.plist", + "/node_modules/.generated/visionos/PrivacyInfo.xcprivacy", + "/node_modules/.generated/visionos/.env", + ], + }, + }, +}; diff --git a/test/ios/assetsCatalog.test.ts b/test/ios/assetsCatalog.test.ts index 824d4f98f..3822a4229 100644 --- a/test/ios/assetsCatalog.test.ts +++ b/test/ios/assetsCatalog.test.ts @@ -29,7 +29,13 @@ describe("generateAssetsCatalogs()", macosOnly, () => { }); for (const platform of ["ios", "macos"] as const) { - const assetsCatalogTemplatePath = path.join( + const appiconsetCopyPath = path.resolve( + projectRoot, + "Assets.xcassets", + "AppIcon.appiconset", + "Contents.json" + ); + const appiconsetTemplatePath = path.resolve( projectRoot, platform, "ReactTestApp", @@ -37,30 +43,43 @@ describe("generateAssetsCatalogs()", macosOnly, () => { "AppIcon.appiconset", "Contents.json" ); - const assetsCatalogTemplate = readTextFile(assetsCatalogTemplatePath); + const appiconsetTemplate = readTextFile(appiconsetTemplatePath); it(`[${platform}] returns early if no icons are declared`, () => { + setMockFiles({ [appiconsetTemplatePath]: appiconsetTemplate }); + generateAssetsCatalogs(configs.noConfig, platform, projectRoot); - deepEqual(toJSON(), {}); + deepEqual(Object.keys(toJSON()), [ + appiconsetTemplatePath, + appiconsetCopyPath, + ]); generateAssetsCatalogs(configs.noIcons, platform, projectRoot); - deepEqual(toJSON(), {}); + deepEqual(Object.keys(toJSON()), [ + appiconsetTemplatePath, + appiconsetCopyPath, + ]); }); it(`[${platform}] returns early if primary icon is missing`, () => { + setMockFiles({ [appiconsetTemplatePath]: appiconsetTemplate }); + generateAssetsCatalogs( configs.withAlternateIconsOnly, platform, projectRoot ); - deepEqual(toJSON(), {}); + deepEqual(Object.keys(toJSON()), [ + appiconsetTemplatePath, + appiconsetCopyPath, + ]); }); it(`[${platform}] generates asset catalog for primary icon`, () => { - setMockFiles({ [assetsCatalogTemplatePath]: assetsCatalogTemplate }); + setMockFiles({ [appiconsetTemplatePath]: appiconsetTemplate }); generateAssetsCatalogs(configs.withPrimaryIcon, platform, projectRoot); @@ -72,7 +91,7 @@ describe("generateAssetsCatalogs()", macosOnly, () => { }); it(`[${platform}] generates asset catalog for all icons`, () => { - setMockFiles({ [assetsCatalogTemplatePath]: assetsCatalogTemplate }); + setMockFiles({ [appiconsetTemplatePath]: appiconsetTemplate }); generateAssetsCatalogs(configs.withAlternateIcons, platform, projectRoot); diff --git a/test/ios/xcode.test.ts b/test/ios/xcode.test.ts index d9cd426e1..a669f9fc5 100644 --- a/test/ios/xcode.test.ts +++ b/test/ios/xcode.test.ts @@ -1,26 +1,36 @@ -import { deepEqual, equal, match, notEqual, throws } from "node:assert/strict"; +import { + deepEqual, + equal, + match, + notEqual, + ok, + throws, +} from "node:assert/strict"; import * as path from "node:path"; import { afterEach, beforeEach, describe, it } from "node:test"; +import { fileURLToPath, URL } from "node:url"; +import { isObject, jsonFromPlist } from "../../ios/utils.mjs"; import { + applyBuildSettings as applyBuildSettingsActual, + applyPreprocessorDefinitions, + applySwiftFlags, + applyUserHeaderSearchPaths, CODE_SIGN_ENTITLEMENTS, CODE_SIGN_IDENTITY, + configureBuildSchemes as configureBuildSchemesActual, DEVELOPMENT_TEAM, GCC_PREPROCESSOR_DEFINITIONS, OTHER_SWIFT_FLAGS, + overrideBuildSettings, PRODUCT_BUILD_NUMBER, PRODUCT_BUNDLE_IDENTIFIER, USER_HEADER_SEARCH_PATHS, - applyBuildSettings as applyBuildSettingsActual, - applyPreprocessorDefinitions, - applySwiftFlags, - applyUserHeaderSearchPaths, - configureBuildSchemes as configureBuildSchemesActual, - overrideBuildSettings, } from "../../ios/xcode.mjs"; import { readTextFile, v } from "../../scripts/helpers.js"; import type { ApplePlatform, JSONObject, + JSONValue, ProjectConfiguration, } from "../../scripts/types.ts"; import { fs, setMockFiles, toJSON } from "../fs.mock.ts"; @@ -42,7 +52,7 @@ function makeProjectConfiguration(): ProjectConfiguration { describe("applyBuildSettings()", macosOnly, () => { function applyBuildSettings( - config: JSONObject, + config: JSONValue, project: ProjectConfiguration, projectRoot: string, destination: string @@ -60,6 +70,15 @@ describe("applyBuildSettings()", macosOnly, () => { setMockFiles(); }); + it("sets default build settings", () => { + const project = makeProjectConfiguration(); + applyBuildSettings(null, project, ".", "."); + + deepEqual(project.buildSettings, { PRODUCT_BUILD_NUMBER: "1" }); + deepEqual(project.testsBuildSettings, {}); + deepEqual(project.uitestsBuildSettings, {}); + }); + it("sets codesign entitlements", () => { const project = makeProjectConfiguration(); applyBuildSettings({}, project, ".", "."); @@ -319,12 +338,14 @@ describe("applySwiftFlags()", () => { }); describe("applyUserHeaderSearchPaths()", () => { + const cwd = process.cwd(); + it("sets user header search paths", () => { const project = makeProjectConfiguration(); applyUserHeaderSearchPaths(project, "ReactApp"); - deepEqual(project.buildSettings[USER_HEADER_SEARCH_PATHS], ["."]); + deepEqual(project.buildSettings[USER_HEADER_SEARCH_PATHS], [cwd]); }); it("appends user header search paths", () => { @@ -333,7 +354,7 @@ describe("applyUserHeaderSearchPaths()", () => { applyUserHeaderSearchPaths(project, "ReactApp"); - deepEqual(project.buildSettings[USER_HEADER_SEARCH_PATHS], ["Test", "."]); + deepEqual(project.buildSettings[USER_HEADER_SEARCH_PATHS], ["Test", cwd]); }); }); @@ -442,3 +463,52 @@ describe("overrideBuildSettings()", () => { equal(buildSettings["ONLY_ACTIVE_ARCH"], "YES"); }); }); + +describe("macos/ReactTestApp.xcodeproj", macosOnly, () => { + // Xcode expects the development team used for code signing to exist when + // targeting macOS. Unlike when targeting iOS, the warnings are treated as + // errors. + it("does not specify development team", () => { + const xcodeproj = jsonFromPlist( + fileURLToPath( + new URL( + "../../macos/ReactTestApp.xcodeproj/project.pbxproj", + import.meta.url + ) + ) + ); + + const { objects } = xcodeproj; + + ok(isObject(objects)); + ok(typeof xcodeproj.rootObject === "string"); + + const rootObject = objects[xcodeproj.rootObject]; + + ok(isObject(rootObject)); + ok(Array.isArray(rootObject.targets)); + ok(typeof rootObject.targets[0] === "string"); + + const appTarget = objects[rootObject.targets[0]]; + + ok(isObject(appTarget)); + equal(appTarget.name, "ReactTestApp"); + ok(typeof appTarget.buildConfigurationList === "string"); + + const buildConfigurationList = objects[appTarget.buildConfigurationList]; + + ok(isObject(buildConfigurationList)); + ok(Array.isArray(buildConfigurationList.buildConfigurations)); + + for (const config of buildConfigurationList.buildConfigurations) { + ok(typeof config === "string"); + + const buildConfiguration: JSONValue = objects[config]; + + ok(isObject(buildConfiguration)); + ok(isObject(buildConfiguration.buildSettings)); + equal(buildConfiguration.buildSettings[CODE_SIGN_IDENTITY], "-"); + equal(buildConfiguration.buildSettings[DEVELOPMENT_TEAM], undefined); + } + }); +});