UNPKG

react-native-test-app

Version:

react-native-test-app provides a test app for all supported platforms as a package

749 lines (690 loc) 22.3 kB
#!/usr/bin/env node // @ts-check /** * @import { * Configuration, * ConfigureParams, * FileCopy, * Manifest, * Platform, * PlatformConfiguration, * PlatformPackage, * } from "./types.js"; */ import * as nodefs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; import { URL, fileURLToPath } from "node:url"; import semverCoerce from "semver/functions/coerce.js"; import semverSatisfies from "semver/functions/satisfies.js"; import { getPackageVersion, isMain, memo, readJSONFile, readTextFile, toVersionNumber, v, } from "./helpers.js"; import { appManifest, buildGradle, bundleConfig, gradleProperties, podfile, serialize, settingsGradle, } from "./template.mjs"; import * as colors from "./utils/colors.mjs"; import { downloadPackage } from "./utils/npm.mjs"; import { parseArgs } from "./utils/parseargs.mjs"; /** * @param {...string} paths * @returns {{ source: string; }} */ function copyFrom(...paths) { return { source: path.join(...paths) }; } /** * Merges two objects. * @param {unknown} lhs * @param {Record<string, unknown>} rhs * @returns {Record<string, unknown>} */ function mergeObjects(lhs, rhs) { return typeof lhs === "object" ? sortByKeys({ ...lhs, ...rhs }) : sortByKeys(rhs); } /** @type {() => Required<Manifest>} */ const readManifest = memo(() => readJSONFile(new URL("../package.json", import.meta.url)) ); /** * Prints an error message to the console. * @param {string} message */ export function error(message) { console.error(colors.red(`[!] ${message}`)); } /** * @param {string} targetVersion * @returns {Promise<string | undefined>} */ async function findTemplateDir(targetVersion) { if (toVersionNumber(targetVersion) < v(0, 75, 0)) { // Let `getConfig` try to find the template inside `react-native` return undefined; } const [major, minor = 0] = targetVersion.split("."); const output = await downloadPackage( "@react-native-community/template", `${major}.${minor}`, true ); return path.join(output, "template"); } /** * Merges specified configurations. * @param {Configuration} lhs * @param {Configuration} rhs * @returns {Configuration} */ export function mergeConfig(lhs, rhs) { return { files: { ...lhs.files, ...rhs.files, }, oldFiles: [...lhs.oldFiles, ...rhs.oldFiles], scripts: { ...lhs.scripts, ...rhs.scripts, }, dependencies: { ...lhs.dependencies, ...rhs.dependencies, }, }; } /** * Sort the keys in specified object. * @param {Record<string, unknown>} obj */ export function sortByKeys(obj) { return Object.keys(obj) .sort() .reduce((sorted, key) => { sorted[key] = obj[key]; return sorted; }, /** @type {Record<string, unknown>} */ ({})); } /** * @param {string | string[]} input * @returns {Platform[]} */ export function validatePlatforms(input) { const platforms = Array.isArray(input) ? input : [input]; let includesApplePlatforms = false; let includesIOS = false; for (const p of platforms) { switch (p) { case "ios": includesIOS = true; break; case "macos": case "visionos": includesApplePlatforms = true; break; case "android": case "windows": break; default: throw new Error(`Unknown platform: ${p}`); } } // Autolinking currently assumes that `ios` is always present: // https://github.com/facebook/react-native/blob/0.76-stable/packages/react-native/scripts/cocoapods/autolinking.rb#L41 // We need to include iOS if we want to target other Apple platforms. if (includesApplePlatforms && !includesIOS) { platforms.push("ios"); } return /** @type {Platform[]} */ (platforms); } /** * Prints a warning message to the console. * @param {string} message * @param {string=} tag */ export function warn(message, tag = "[!]") { console.warn(colors.yellow(`${tag} ${message}`)); } /** * Returns the default npm package name for the specified platform. * @param {Platform} platform * @returns {PlatformPackage} */ export function getDefaultPlatformPackageName(platform) { if (platform === "common") { return "react-native"; } const { defaultPlatformPackages } = readManifest(); const pkgName = defaultPlatformPackages[platform]; if (!pkgName) { throw new Error(`Unsupported platform: ${platform}`); } return pkgName; } /** * Returns platform package at target version if it satisfies version range. * @param {Platform} platform * @param {string} targetVersion * @returns {Record<string, string> | undefined} */ export function getPlatformPackage(platform, targetVersion) { const packageName = getDefaultPlatformPackageName(platform); if (packageName === "react-native") { return {}; } const v = semverCoerce(targetVersion); if (!v) { throw new Error(`Invalid ${packageName} version: ${targetVersion}`); } const { peerDependencies } = readManifest(); const versionRange = peerDependencies[packageName]; if (!semverSatisfies(v.version, versionRange)) { warn( `${packageName}@${v.major}.${v.minor} cannot be added because it does not exist or is unsupported` ); return undefined; } return { [packageName]: `^${v.major}.${v.minor}.0` }; } /** * Returns the appropriate `react-native.config.js` for specified parameters. * @param {ConfigureParams} params * @returns {string | FileCopy} */ export function reactNativeConfig({ name, testAppPath }, fs = nodefs) { const config = path.join(testAppPath, "example", "react-native.config.js"); return readTextFile(config, fs).replaceAll("Example", name); } /** * Returns a {@link Configuration} object for specified platform. * * A {@link Configuration} object consists of four main parts: * * - `files`: A filename/content map of files to create. The content of a file * is either generated from {@link ConfigureParams}, or is copied from * somewhere. If the file is copied, the content is a {@link FileCopy} object * instead of a string. * - `oldFiles`: A list of files that will be deleted if found. * - `scripts`: Scripts that will be added to `package.json`. * * There is a {@link Configuration} object for each supported platform. * Additionally, there is a common {@link Configuration} object that is always * included by {@link gatherConfig} during {@link configure}. */ export const getConfig = (() => { /** @type {PlatformConfiguration} */ let configuration; return ( /** @type {ConfigureParams} */ params, /** @type {Platform} */ platform, disableCache = false, fs = nodefs ) => { if (disableCache || typeof configuration === "undefined") { const { name, templatePath, testAppPath, targetVersion, init } = params; // `.gitignore` files are only renamed when published. const gitignore = ["_gitignore", ".gitignore"].find((filename) => { return fs.existsSync(path.join(testAppPath, "example", filename)); }); if (!gitignore) { throw new Error("Failed to find `.gitignore`"); } const require = createRequire(import.meta.url); const templateDir = templatePath || path.relative( process.cwd(), path.dirname(require.resolve("react-native/template/package.json")) ); const targetVersionNum = toVersionNumber(targetVersion); configuration = { common: { files: { ".gitignore": copyFrom(testAppPath, "example", gitignore), ".watchmanconfig": copyFrom(templateDir, "_watchmanconfig"), "babel.config.js": copyFrom(templateDir, "babel.config.js"), "metro.config.js": copyFrom( testAppPath, "example", "metro.config.js" ), "react-native.config.js": reactNativeConfig(params), ...(!init ? undefined : { // TODO: We will no longer need to consider `App.js` when we // drop support for 0.70 ...(fs.existsSync(path.join(templateDir, "App.tsx")) ? { "App.tsx": copyFrom(templateDir, "App.tsx"), "tsconfig.json": copyFrom(templateDir, "tsconfig.json"), } : { "App.js": copyFrom(templateDir, "App.js"), }), ".bundle/config": bundleConfig(), Gemfile: copyFrom(templateDir, "Gemfile"), "app.json": appManifest(name), "index.js": copyFrom(templateDir, "index.js"), "package.json": readTextFile( path.join(templateDir, "package.json"), fs ).replaceAll("HelloWorld", name), }), }, oldFiles: [], scripts: { // TODO: Remove this script when we drop support for 0.72 // https://github.com/react-native-community/cli/commit/48d4c29bba4e8b16cbc8307bd1b4c5349f3651d8 mkdist: `node -e "require('node:fs').mkdirSync('dist', { recursive: true, mode: 0o755 })"`, start: "react-native start", }, dependencies: {}, }, android: { files: { "build.gradle": buildGradle(), "gradle/wrapper/gradle-wrapper.jar": copyFrom( testAppPath, "example", "android", "gradle", "wrapper", "gradle-wrapper.jar" ), "gradle/wrapper/gradle-wrapper.properties": (() => { const gradleWrapperProperties = path.join( testAppPath, "example", "android", "gradle", "wrapper", "gradle-wrapper.properties" ); const props = readTextFile(gradleWrapperProperties); if (targetVersionNum < v(0, 73, 0)) { return props.replace( /gradle-[.0-9]*-bin\.zip/, "gradle-7.6.4-bin.zip" ); } return props; })(), "gradle.properties": gradleProperties(targetVersionNum), gradlew: copyFrom(testAppPath, "example", "android", "gradlew"), "gradlew.bat": copyFrom( testAppPath, "example", "android", "gradlew.bat" ), "settings.gradle": settingsGradle(name), }, oldFiles: [], scripts: { android: "react-native run-android", "build:android": "npm run mkdist && react-native bundle --entry-file index.js --platform android --dev true --bundle-output dist/main.android.jsbundle --assets-dest dist/res", }, dependencies: {}, }, ios: { files: { Podfile: podfile(name, "", targetVersionNum), }, oldFiles: [ "Podfile.lock", "Pods", `${name}.xcodeproj`, `${name}.xcworkspace`, ], scripts: { "build:ios": "npm run mkdist && react-native bundle --entry-file index.js --platform ios --dev true --bundle-output dist/main.ios.jsbundle --assets-dest dist", ios: "react-native run-ios", }, dependencies: {}, }, macos: { files: { Podfile: podfile(name, "macos/", targetVersionNum), }, oldFiles: [ "Podfile.lock", "Pods", `${name}.xcodeproj`, `${name}.xcworkspace`, ], scripts: { "build:macos": "npm run mkdist && react-native bundle --entry-file index.js --platform macos --dev true --bundle-output dist/main.macos.jsbundle --assets-dest dist", macos: `react-native run-macos --scheme ${name}`, }, dependencies: {}, }, visionos: { files: { Podfile: podfile(name, "visionos/", targetVersionNum), }, oldFiles: [ "Podfile.lock", "Pods", `${name}.xcodeproj`, `${name}.xcworkspace`, ], scripts: { "build:visionos": "npm run mkdist && react-native bundle --entry-file index.js --platform ios --dev true --bundle-output dist/main.visionos.jsbundle --assets-dest dist", visionos: "react-native run-visionos", }, dependencies: {}, }, windows: { files: { ".gitignore": copyFrom( testAppPath, "example", "windows", gitignore ), }, oldFiles: [ `${name}.sln`, `${name}.vcxproj`, path.join(name, `${name}.vcxproj`), ], scripts: { "build:windows": "npm run mkdist && react-native bundle --entry-file index.js --platform windows --dev true --bundle-output dist/main.windows.bundle --assets-dest dist", windows: "react-native run-windows", }, dependencies: {}, }, }; } return configuration[platform]; }; })(); /** * Collects and returns configuration for all specified platforms. * @param {ConfigureParams} params * @returns Configuration */ export function gatherConfig(params, disableCache = false) { const { platforms, targetVersion } = params; const config = (() => { return platforms.reduce( (config, platform) => { const platformConfig = getConfig(params, platform, disableCache); const dependencies = getPlatformPackage(platform, targetVersion); if (!dependencies) { /* node:coverage ignore next */ return config; } return mergeConfig(config, { ...platformConfig, dependencies, files: Object.fromEntries( // Map each file into its platform specific folder, e.g. // `Podfile` -> `ios/Podfile` Object.entries(platformConfig.files).map(([filename, content]) => [ path.join(platform, filename), content, ]) ), oldFiles: platformConfig.oldFiles.map((file) => { return path.join(platform, file); }), }); }, /** @type {Configuration} */ ({ scripts: {}, dependencies: {}, files: {}, oldFiles: [], }) ); })(); if ( Object.keys(config.scripts).length === 0 && Object.keys(config.dependencies).length === 0 && Object.keys(config.files).length === 0 && config.oldFiles.length === 0 ) { return config; } return mergeConfig(getConfig(params, "common", disableCache), config); } /** * Retrieves app name from the app manifest. * @param {string} packagePath * @returns {string} */ export function getAppName(packagePath, fs = nodefs) { try { const { name } = readJSONFile(path.join(packagePath, "app.json"), fs); if (typeof name === "string" && name) { return name; } } catch (_) { // No name? Use fallback. } warn("Could not determine app name; using 'ReactTestApp'"); return "ReactTestApp"; } /** * Returns whether destructive operations will be required. * @param {string} packagePath * @param {Configuration} config * @returns {boolean} */ export function isDestructive(packagePath, { files, oldFiles }, fs = nodefs) { const modified = Object.keys(files).reduce((result, file) => { const targetPath = path.join(packagePath, file); if (fs.existsSync(targetPath)) { result.push(targetPath); } return result; }, /** @type {string[]} */ ([])); const removed = oldFiles.reduce((result, file) => { const targetPath = path.join(packagePath, file); if (fs.existsSync(targetPath)) { result.push(targetPath); } return result; }, /** @type {string[]} */ ([])); if (modified.length > 0 || removed.length > 0) { if (modified.length > 0) { const reset = colors.bold("reset"); warn(`The following files will be ${reset} to their original state:`); modified.sort().forEach((file) => warn(file, " ")); } if (removed.length > 0) { warn("The following files will be removed:"); removed.sort().forEach((file) => warn(file, " ")); } return true; } return false; } /** * Removes all specified files on disk. * @param {Configuration["oldFiles"]} files * @param {string} destination * @returns {Promise<void[]>} */ export function removeAllFiles(files, destination, fs = nodefs.promises) { const options = { force: true, maxRetries: 3, recursive: true }; return Promise.all( files.map((filename) => fs.rm(path.join(destination, filename), options)) ); } /** * Returns the package manifest with additions for react-native-test-app. * @param {import("node:fs").PathLike} path * @param {Configuration} config * @returns {Record<string, unknown>} */ export function updatePackageManifest( path, { dependencies, scripts }, fs = nodefs ) { const manifest = readJSONFile(path, fs); manifest["scripts"] = mergeObjects(manifest["scripts"], scripts); manifest["dependencies"] = mergeObjects( manifest["dependencies"], dependencies ); const { name: rntaName, version: rntaVersion } = readManifest(); manifest["devDependencies"] = mergeObjects(manifest["devDependencies"], { "@rnx-kit/metro-config": "^2.1.0", [rntaName]: `^${rntaVersion}`, }); return manifest; } /** * Writes all specified files to disk. * @param {Configuration["files"]} files * @param {string} destination * @returns {Promise<void[]>} */ export function writeAllFiles(files, destination, fs = nodefs.promises) { const options = { recursive: true, mode: 0o755 }; return Promise.all( Object.keys(files).map(async (filename) => { const content = files[filename]; if (!content) { return; } const file = path.join(destination, filename); await fs.mkdir(path.dirname(file), options); if (typeof content === "string") { await fs.writeFile(file, content); } else { try { await fs.copyFile(content.source, file); } catch (e) { if (path.basename(content.source) !== ".gitignore") { throw e; } // This is a special case for `.gitignore` files. On CI, and only // during testing, there is some sort of race condition that causes // the files to be renamed _after_ `getConfig()` is called, even // though the renaming should've happened during `npm pack`, long // before this script is ever called. const sourceDir = path.dirname(content.source); await fs.copyFile(path.join(sourceDir, "_gitignore"), file); } } }) ); } /** * Configure specified package to use react-native-test-app. * @param {ConfigureParams} params * @returns {number} */ export function configure(params, fs = nodefs) { const { force, packagePath } = params; const config = gatherConfig(params); if (!force && isDestructive(packagePath, config)) { error( "Some files will be reset and/or removed: You may have to manually restore your own or your template's customizations to get the app working again (for more details, see https://github.com/microsoft/react-native-test-app/wiki/Updating#reconfiguringresetting-rnta)" ); const forceFlag = colors.bold("--force"); console.log(`Re-run with ${forceFlag} if you're fine with this.`); return 1; } const { files, oldFiles } = config; writeAllFiles(files, packagePath).then(() => { const packageManifest = path.join(packagePath, "package.json"); if (!fs.existsSync(packageManifest)) { // We cannot assume that the app itself is an npm package. Some libraries // have an 'example' folder inside the package. warn( `skipped modifying 'package.json' because it was not found in path '${packagePath}'` ); return; } const newPackageManifest = updatePackageManifest(packageManifest, config); fs.writeFile( packageManifest, serialize(newPackageManifest), (/** @type {Error | null} */ error) => { if (error) { throw error; } } ); }); removeAllFiles(oldFiles, packagePath); return 0; } if (isMain(import.meta.url)) { /** @type {Platform[]} */ const platformChoices = ["android", "ios", "macos", "windows"]; const defaultPlatforms = platformChoices.join(", "); parseArgs( "Configures React Test App in an existing package", { force: { description: "Allow destructive operations", type: "boolean", short: "f", default: false, }, init: { description: "Initialize a new project", type: "boolean", default: false, }, package: { description: "Path of the package to modify (defaults to current directory)", type: "string", default: ".", }, platforms: { description: `Platforms to configure (defaults to [${defaultPlatforms}])`, type: "string", multiple: true, short: "p", default: platformChoices, }, }, async ({ _: { [0]: name }, force, init, package: packagePath, platforms, }) => { const targetVersion = getPackageVersion("react-native"); process.exitCode = configure({ name: typeof name === "string" && name ? name : getAppName(packagePath), packagePath, templatePath: await findTemplateDir(targetVersion), testAppPath: fileURLToPath(new URL("..", import.meta.url)), targetVersion, platforms: validatePlatforms(platforms), force, init, }); } ); }