UNPKG

react-native-test-app

Version:

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

612 lines (568 loc) 18.6 kB
#!/usr/bin/env node // // Copyright (c) Microsoft Corporation // // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. // // @ts-check const fs = require("fs"); const os = require("os"); const path = require("path"); const templateView = { name: "ReactTestApp", projectGuidUpper: "{B44CEAD7-FBFF-4A17-95EA-FF5434BBD79D}", useExperimentalNuget: false, }; // Binary files in React Native Test App Windows project const binaryExtensions = [".png", ".pfx"]; const textFileReadOptions = { encoding: "utf-8" }; const textFileWriteOptions = { encoding: "utf-8", mode: 0o644 }; /** * Finds nearest relative path to a file or directory from current path. * @param {string} fileOrDirName Path to the file or directory to find. * @param {string=} currentDir The current working directory. Mostly used for unit tests. * @returns {string | null} Nearest path to given file or directory; null if not found */ function findNearest(fileOrDirName, currentDir = path.resolve("")) { const rootDirectory = process.platform === "win32" ? currentDir.split(path.sep)[0] + path.sep : "/"; while (currentDir !== rootDirectory) { const candidatePath = path.join(currentDir, fileOrDirName); if (fs.existsSync(candidatePath)) { return path.relative("", candidatePath); } // Get parent folder currentDir = path.dirname(currentDir); } return null; } /** * Finds all Visual Studio projects in specified directory. * @param {string} projectDir * @param {{ path: string; name: string; guid: string; }[]=} projects * @returns {{ path: string; name: string; guid: string; }[]} */ function findUserProjects(projectDir, projects = []) { return fs.readdirSync(projectDir).reduce((projects, file) => { const fullPath = path.join(projectDir, file); if (fs.lstatSync(fullPath).isDirectory()) { if (!["android", "ios", "macos", "node_modules"].includes(file)) { findUserProjects(fullPath, projects); } } else if (fullPath.endsWith(".vcxproj")) { const vcxproj = fs.readFileSync(fullPath, textFileReadOptions); const guidMatch = vcxproj.match( /<ProjectGuid>({[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}})<\/ProjectGuid>/ ); if (guidMatch) { const projectNameMatch = vcxproj.match( /<ProjectName>(.*?)<\/ProjectName>/ ); projects.push({ path: fullPath, name: projectNameMatch ? projectNameMatch[1] : path.basename(file, ".vcxproj"), guid: guidMatch[1], }); } } return projects; }, projects); } /** * Returns a NuGet package entry for specified package id and version. * @param {string} id NuGet package id * @param {string} version NuGet package version * @returns {string} */ function nuGetPackage(id, version) { return `<package id="${id}" version="${version}" targetFramework="native" />`; } /** * @param {string[] | { windows?: string[] } | undefined} resources * @param {string} projectPath * @param {string} vcxProjectPath * @returns {[string, string]} [bundleDirContent, bundleFileContent] */ function parseResources(resources, projectPath, vcxProjectPath) { if (!Array.isArray(resources)) { if (resources && resources.windows) { return parseResources(resources.windows, projectPath, vcxProjectPath); } return ["", ""]; } let bundleDirContent = ""; let bundleFileContent = ""; for (const resource of resources) { const resourcePath = path.relative(projectPath, resource); if (!fs.existsSync(resourcePath)) { console.warn(`warning: resource with path '${resource}' was not found`); continue; } const relativeResourcePath = path.relative(vcxProjectPath, resourcePath); if (fs.statSync(resourcePath).isDirectory()) { bundleDirContent = bundleDirContent.concat( relativeResourcePath, "\\**\\*;" ); } else { bundleFileContent = bundleFileContent.concat(relativeResourcePath, ";"); } } return [bundleDirContent, bundleFileContent]; } /** * Replaces parts in specified content. * @param {string} content Content to be replaced. * @param {{ [pattern: string]: string }} replacements e.g. {'TextToBeReplaced': 'Replacement'} * @returns {string} The contents of the file with the replacements applied. */ function replaceContent(content, replacements) { return Object.keys(replacements).reduce( (content, regex) => content.replace(new RegExp(regex, "g"), replacements[regex]), content ); } /** * Rethrows specified error. * @param {Error | null} error */ function rethrow(error) { if (error) { throw error; } } /** * Returns a solution entry for specified project. * @param {{ path: string; name: string; guid: string; }} project * @param {string} destPath */ function toProjectEntry(project, destPath) { return [ `Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "${ project.name }", "${path.relative(destPath, project.path)}", "${project.guid}"`, "\tProjectSection(ProjectDependencies) = postProject", `\t\t${templateView.projectGuidUpper} = ${templateView.projectGuidUpper}`, "\tEndProjectSection", "EndProject", ].join(os.EOL); } /** * Copies a file to given destination, replacing parts of its contents. * @param {string} srcPath Path to the file to be copied. * @param {string} destPath Destination path. * @param {{ [pattern: string]: string }=} replacements e.g. {'TextToBeReplaced': 'Replacement'} */ function copyAndReplace(srcPath, destPath, replacements = {}) { /** @type {(e: NodeJS.ErrnoException | null) => void} */ const throwOnError = (e) => { if (e) { throw e; } }; if (binaryExtensions.includes(path.extname(srcPath))) { // Binary file return fs.copyFile(srcPath, destPath, throwOnError); } else { // Text file return fs.writeFile( destPath, replaceContent( fs.readFileSync(srcPath, textFileReadOptions), replacements ), { encoding: "utf-8", mode: fs.statSync(srcPath).mode, }, throwOnError ); } } /** * Reads manifest file and and resolves paths to bundle resources. * @param {string | null} manifestFilePath Path to the closest manifest file. * @param {string} projectFilesDestPath Resolved paths will be relative to this path. * @return {{ * appName: string; * appxManifest: string; * bundleDirContent: string; * bundleFileContent: string; * }} Application name, and paths to directories and files to include. */ function getBundleResources(manifestFilePath, projectFilesDestPath) { // Default value if manifest or 'name' field don't exist. const defaultName = "ReactTestApp"; // Default `Package.appxmanifest` path. The project will automatically use our // fallback if there is no file at this path. const defaultAppxManifest = "windows/Package.appxmanifest"; if (manifestFilePath) { try { const content = fs.readFileSync(manifestFilePath, textFileReadOptions); const { name, resources, windows } = JSON.parse(content); const [bundleDirContent, bundleFileContent] = parseResources( resources, path.dirname(manifestFilePath), projectFilesDestPath ); return { appName: name || defaultName, appxManifest: (windows && windows.appxManifest) || defaultAppxManifest, bundleDirContent, bundleFileContent, }; } catch (e) { console.warn(`Could not parse 'app.json':\n${e.message}`); } } else { console.warn("Could not find 'app.json' file."); } return { appName: defaultName, appxManifest: defaultAppxManifest, bundleDirContent: "", bundleFileContent: "", }; } /** * Returns the version of Hermes that should be installed. * @param {string} rnwPath Path to `react-native-windows`. * @returns {string | null} */ function getHermesVersion(rnwPath) { const jsEnginePropsPath = path.join( rnwPath, "PropertySheets", "JSEngine.props" ); const props = fs.readFileSync(jsEnginePropsPath, textFileReadOptions); const m = props.match(/<HermesVersion.*?>([.\w]+)<\/HermesVersion>/); return m && m[1]; } /** * Returns the version number the package at specified path. * @param {string} packagePath * @returns {string} */ function getPackageVersion(packagePath) { const { version } = JSON.parse( fs.readFileSync(path.join(packagePath, "package.json"), textFileReadOptions) ); return version; } /** * Returns a single number for the specified version, suitable as a value for a * preprocessor definition. * @param {string} version * @returns {number} */ function getVersionNumber(version) { const components = version.split("-")[0].split("."); const lastIndex = components.length - 1; return components.reduce( /** @type {(sum: number, value: string, index: number) => number} */ (sum, value, index) => { return sum + parseInt(value) * Math.pow(100, lastIndex - index); }, 0 ); } /** * Generates Visual Studio solution. * @param {string} destPath Destination path. * @param {{ autolink: boolean; useHermes: boolean | undefined; useNuGet: boolean; }} options * @returns {string | undefined} An error message; `undefined` otherwise. */ function generateSolution(destPath, { autolink, useHermes, useNuGet }) { if (!destPath) { throw "Missing or invalid destination path"; } const nodeModulesDir = "node_modules"; const nodeModulesPath = findNearest(nodeModulesDir); if (!nodeModulesPath) { return "Could not find 'node_modules'"; } const rnWindowsPath = findNearest( path.join(nodeModulesDir, "react-native-windows") ); if (!rnWindowsPath) { return "Could not find 'react-native-windows'"; } const rnTestAppPath = findNearest( path.join(nodeModulesDir, "react-native-test-app") ); if (!rnTestAppPath) { return "Could not find 'react-native-test-app'"; } const projDir = "ReactTestApp"; const projectFilesDestPath = path.join( nodeModulesPath, ".generated", "windows", projDir ); fs.mkdirSync(projectFilesDestPath, { recursive: true }); fs.mkdirSync(destPath, { recursive: true }); const manifestFilePath = findNearest("app.json"); const { appName, appxManifest, bundleDirContent, bundleFileContent } = getBundleResources(manifestFilePath, projectFilesDestPath); const rnWindowsVersion = getPackageVersion(rnWindowsPath); const rnWindowsVersionNumber = getVersionNumber(rnWindowsVersion); const hermesVersion = useHermes && getHermesVersion(rnWindowsPath); const projectFilesReplacements = { ...(hermesVersion ? { '<!-- package id="ReactNative.Hermes.Windows" version="0.0.0" targetFramework="native" / -->': nuGetPackage("ReactNative.Hermes.Windows", hermesVersion), } : undefined), ...(useNuGet ? { '<!-- package id="Microsoft.ReactNative" version="1000.0.0" targetFramework="native" / -->': nuGetPackage("Microsoft.ReactNative", rnWindowsVersion), '<!-- package id="Microsoft.ReactNative.Cxx" version="1000.0.0" targetFramework="native" / -->': nuGetPackage("Microsoft.ReactNative.Cxx", rnWindowsVersion), '<!-- package id="Microsoft.UI.Xaml" version="2.5.0" targetFramework="native" / -->': nuGetPackage("Microsoft.UI.Xaml", "2.5.0"), "<UseExperimentalNuget>false</UseExperimentalNuget>": "<UseExperimentalNuget>true</UseExperimentalNuget>", "<WinUI2xVersionDisabled />": "<WinUI2xVersion>2.5.0</WinUI2xVersion>", } : undefined), "1000\\.0\\.0": rnWindowsVersion, "REACT_NATIVE_VERSION=10000000;": `REACT_NATIVE_VERSION=${rnWindowsVersionNumber};`, "\\$\\(BundleDirContentPaths\\)": bundleDirContent, "\\$\\(BundleFileContentPaths\\)": bundleFileContent, "\\$\\(ReactTestAppPackageManifest\\)": path.normalize( path.relative(destPath, path.resolve(appxManifest)) ), }; const copyTasks = [ "AutolinkedNativeModules.g.cpp", "AutolinkedNativeModules.g.props", "AutolinkedNativeModules.g.targets", "Package.appxmanifest", "PropertySheet.props", "ReactTestApp.vcxproj", "ReactTestApp.vcxproj.filters", "ReactTestApp_TemporaryKey.pfx", "packages.config", ].map((file) => copyAndReplace( path.join(__dirname, projDir, file), path.join(projectFilesDestPath, file), projectFilesReplacements ) ); const additionalProjectEntries = findUserProjects(destPath) .map((project) => toProjectEntry(project, destPath)) .join(os.EOL); // The mustache template was introduced in 0.63 const solutionTemplatePath = findNearest( // In 0.64, the template was moved into `react-native-windows` path.join( nodeModulesDir, "react-native-windows", "template", "cpp-app", "proj", "MyApp.sln" ) ) || findNearest( // In 0.63, the template is in `@react-native-windows/cli` path.join( nodeModulesDir, "@react-native-windows", "cli", "templates", "cpp", "proj", "MyApp.sln" ) ); if (!solutionTemplatePath) { copyAndReplace( path.join(__dirname, "ReactTestApp.sln"), path.join(destPath, `${appName}.sln`), { "\\$\\(ReactNativeModulePath\\)": path.relative( destPath, rnWindowsPath ), "\\$\\(ReactTestAppProjectPath\\)": path.relative( destPath, projectFilesDestPath ), "\\$\\(AdditionalProjects\\)": additionalProjectEntries, } ); } else { const mustache = require("mustache"); const reactTestAppProjectPath = path.join( projectFilesDestPath, "ReactTestApp.vcxproj" ); const solutionTask = fs.writeFile( path.join(destPath, `${appName}.sln`), mustache .render(fs.readFileSync(solutionTemplatePath, textFileReadOptions), { ...templateView, useExperimentalNuget: useNuGet, }) // The current version of this template (v0.63.18) assumes that // `react-native-windows` is always installed in // `..\node_modules\react-native-windows`. .replace( /"\.\.\\node_modules\\react-native-windows\\/g, `"${path.relative(destPath, rnWindowsPath)}\\` ) .replace( "ReactTestApp\\ReactTestApp.vcxproj", path.relative(destPath, reactTestAppProjectPath) ) .replace( /EndProject\r?\nGlobal/, ["EndProject", additionalProjectEntries, "Global"].join(os.EOL) ), textFileWriteOptions, rethrow ); copyAndReplace( path.join(__dirname, "ExperimentalFeatures.props"), path.join(destPath, "ExperimentalFeatures.props"), { ...(hermesVersion ? { "<UseHermes>false</UseHermes>": `<UseHermes>true</UseHermes>` } : undefined), } ); if (useNuGet) { const nugetConfigPath = findNearest( // In 0.64, the template was moved into `react-native-windows` path.join( nodeModulesDir, "react-native-windows", "template", "shared-app", "proj", "NuGet.Config" ) ) || findNearest( // In 0.63, the template is in `@react-native-windows/cli` path.join( nodeModulesDir, "@react-native-windows", "cli", "templates", "shared", "proj", "NuGet.Config" ) ); if (nugetConfigPath) { fs.writeFile( path.join(destPath, "NuGet.Config"), mustache.render( fs.readFileSync(nugetConfigPath, textFileReadOptions), {} ), textFileWriteOptions, rethrow ); } } if (autolink) { Promise.all([...copyTasks, solutionTask]).then(() => { const { spawn } = require("child_process"); spawn( path.join(path.dirname(process.argv0), "npx.cmd"), [ "react-native", "autolink-windows", "--proj", reactTestAppProjectPath, ], { stdio: "inherit" } ).on("close", (code) => { if (code !== 0) { process.exit(code); } }); }); } } return undefined; } if (require.main === module) { // Add the `node_modules` path whence the script was invoked. Without it, this // script will fail to resolve any packages when `react-native-test-app` was // linked using npm or yarn link. const nodeModulesDir = process.argv[1].match(/(.*?[/\\]node_modules)[/\\]/); if (nodeModulesDir) { module.paths.push(nodeModulesDir[1]); } require("yargs").usage( "$0 [options]", "Generate a Visual Studio solution for React Test App", { "project-directory": { alias: "p", type: "string", description: "Directory where solution will be created", default: "windows", }, autolink: { type: "boolean", description: "Run autolink after generating the solution", default: true, }, "use-hermes": { type: "boolean", description: "Use Hermes JavaScript engine (experimental)", }, "use-nuget": { type: "boolean", description: "Use NuGet packages (experimental)", default: false, }, }, ({ "project-directory": projectDirectory, autolink, "use-hermes": useHermes, "use-nuget": useNuGet, }) => { const error = generateSolution(path.resolve(projectDirectory), { autolink, useHermes, useNuGet, }); if (error) { console.error(error); process.exit(1); } } ).argv; } else { exports["copyAndReplace"] = copyAndReplace; exports["findNearest"] = findNearest; exports["findUserProjects"] = findUserProjects; exports["generateSolution"] = generateSolution; exports["getBundleResources"] = getBundleResources; exports["getPackageVersion"] = getPackageVersion; exports["getVersionNumber"] = getVersionNumber; exports["parseResources"] = parseResources; exports["replaceContent"] = replaceContent; exports["toProjectEntry"] = toProjectEntry; }