react-native-test-app
Version:
react-native-test-app provides a test app for all supported platforms as a package
395 lines (368 loc) • 13.1 kB
JavaScript
// @ts-check
import * as nodefs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { URL, fileURLToPath } from "node:url";
import {
findNearest,
isMain,
readTextFile,
requireTransitive,
v,
writeTextFile,
} from "../scripts/helpers.js";
import * as colors from "../scripts/utils/colors.mjs";
import { cp_r, mkdir_p } from "../scripts/utils/filesystem.mjs";
import { parseArgs } from "../scripts/utils/parseargs.mjs";
import { loadReactNativeConfig, projectInfo } from "./project.mjs";
import { configureForUWP } from "./uwp.mjs";
import { configureForWin32 } from "./win32.mjs";
/** @import { MSBuildProjectOptions } from "../scripts/types.js"; */
const templateView = {
packageGuidUpper: "{B44CEAD7-FBFF-4A17-95EB-FF5434BBD79D}", // .wapproj
projectGuidUpper: "{B44CEAD7-FBFF-4A17-95EA-FF5434BBD79D}", // .vcxproj
useExperimentalNuget: false,
};
/**
* 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; }[]}
*/
export function findUserProjects(projectDir, projects = [], fs = nodefs) {
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 = readTextFile(fullPath, fs);
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);
}
/**
* @param {string | undefined} msbuildprops
* @returns {string | undefined} XML elements for additional MSBuild properties.
*/
export function parseMSBuildProperties(msbuildprops) {
if (!msbuildprops) {
return undefined;
}
return msbuildprops
.split(",")
.map((prop) => {
const [name, value] = prop.split("=");
if (!name || !value) {
throw new Error(
`Invalid MSBuild property: "${prop}"; expected format: "Name=Value".`
);
}
return `<${name}>${value}</${name}>`;
})
.join("\n");
}
/**
* 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.
*/
export function replaceContent(content, replacements) {
return Object.keys(replacements).reduce(
(content, regex) =>
content.replace(new RegExp(regex, "g"), replacements[regex]),
content
);
}
/**
* Returns a solution entry for specified project.
* @param {{ path: string; name: string; guid: string; }} project
* @param {string} destPath
*/
export 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 {Record<string, string> | undefined} replacements e.g. {'TextToBeReplaced': 'Replacement'}
* @returns {Promise<void>}
*/
export async function copyAndReplace(
srcPath,
destPath,
replacements,
fs = nodefs.promises
) {
if (!replacements) {
return cp_r(srcPath, destPath, nodefs);
}
// Treat as text file
const data = await fs.readFile(srcPath, { encoding: "utf-8" });
return writeTextFile(destPath, replaceContent(data, replacements), fs);
}
/**
* Generates Visual Studio solution.
* @param {string} destPath Destination path.
* @param {MSBuildProjectOptions} options
* @returns {Promise<string | undefined>} An error message; `undefined` otherwise.
*/
export async function generateSolution(destPath, options, fs = nodefs) {
if (!destPath) {
return "Missing or invalid destination path";
}
const projectManifest = findNearest("package.json", undefined, fs);
if (!projectManifest) {
return "Could not find 'package.json'";
}
const nodeModulesDir = "node_modules";
const rnWindowsPath = findNearest(
path.join(nodeModulesDir, "react-native-windows"),
undefined,
fs
);
if (!rnWindowsPath) {
return "Could not find 'react-native-windows'";
}
const info = await projectInfo(options, rnWindowsPath, destPath, fs);
const { projDir, projectFileName, projectFiles, solutionTemplatePath } =
info.useFabric ? configureForWin32(info) : configureForUWP(info);
const solutionTemplate = path.join(rnWindowsPath, solutionTemplatePath);
if (!fs.existsSync(solutionTemplate)) {
return "Could not find solution template";
}
const projectFilesDestPath = path.join(
path.dirname(projectManifest),
nodeModulesDir,
".generated",
"windows",
projDir
);
mkdir_p(projectFilesDestPath, fs);
mkdir_p(destPath, fs);
/** @type {typeof copyAndReplace} */
const copyAndReplaceAsync = (src, dst, r) =>
copyAndReplace(src, dst, r, fs.promises);
const copyTasks = projectFiles.map(([file, replacements]) =>
copyAndReplaceAsync(
fileURLToPath(new URL(`${projDir}/${file}`, import.meta.url)),
path.join(projectFilesDestPath, file),
replacements
)
);
const additionalProjectEntries = findUserProjects(destPath, undefined, fs)
.map((project) => toProjectEntry(project, destPath))
.join(os.EOL);
/** @type {typeof import("mustache")} */
const mustache = requireTransitive(
["@react-native-windows/cli", "mustache"],
rnWindowsPath
);
const slnPath = path.join(destPath, `${info.bundle.appName}.sln`);
const vcxprojPath = path.join(projectFilesDestPath, projectFileName);
const vcxprojLocalPath = path.relative(destPath, vcxprojPath);
copyTasks.push(
writeTextFile(
slnPath,
mustache
.render(readTextFile(solutionTemplate, fs), {
...templateView,
name: path.basename(projectFileName, path.extname(projectFileName)),
useExperimentalNuget: info.useExperimentalNuGet,
rnwPathFromProjectRoot: path.relative(
path.dirname(projectManifest),
rnWindowsPath
),
})
// 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("ReactApp\\ReactApp.vcxproj", vcxprojLocalPath) // Win32
.replace(
"ReactApp.Package\\ReactApp.Package.wapproj", // Win32
vcxprojLocalPath.replace(
"ReactApp.vcxproj",
"ReactApp.Package.wapproj"
)
)
.replace("ReactTestApp\\ReactTestApp.vcxproj", vcxprojLocalPath) // UWP
.replace(
/EndProject\r?\nGlobal/,
["EndProject", additionalProjectEntries, "Global"].join(os.EOL)
),
fs.promises
)
);
const experimentalFeaturesPropsFilename = "ExperimentalFeatures.props";
const experimentalFeaturesPropsPath = path.join(
destPath,
experimentalFeaturesPropsFilename
);
if (fs.existsSync(experimentalFeaturesPropsPath)) {
const props = path.relative(process.cwd(), experimentalFeaturesPropsPath);
console.log(colors.cyan(colors.bold("info")), `'${props}' already exists`);
} else {
const { msbuildprops, useHermes } = options;
const { useExperimentalNuGet, useFabric, versionNumber } = info;
const url = new URL(experimentalFeaturesPropsFilename, import.meta.url);
await copyAndReplaceAsync(
fileURLToPath(url),
experimentalFeaturesPropsPath,
{
"<RnwNewArch>false</RnwNewArch>": `<RnwNewArch>${useFabric}</RnwNewArch>`,
"<UseFabric>false</UseFabric>": `<UseFabric>${useFabric}</UseFabric>`,
"<UseHermes>true</UseHermes>": `<UseHermes>${useHermes == null ? versionNumber >= v(0, 73, 0) : useHermes}</UseHermes>`,
"<UseWinUI3>false</UseWinUI3>": `<UseWinUI3>${useFabric}</UseWinUI3>`,
"<UseExperimentalNuget>false</UseExperimentalNuget>": `<UseExperimentalNuget>${useExperimentalNuGet}</UseExperimentalNuget>`,
"<!-- AdditionalMSBuildProperties -->": msbuildprops ?? "",
}
);
}
if (info.useExperimentalNuGet) {
const nugetConfigTemplatePath = info.useFabric
? path.join(rnWindowsPath, "templates", "cpp-app", "NuGet_Config")
: path.join(
rnWindowsPath,
"template",
"shared-app",
"proj",
"NuGet_Config"
);
const nugetConfigPath = fs.existsSync(nugetConfigTemplatePath)
? nugetConfigTemplatePath
: null;
if (nugetConfigPath) {
const nugetConfigDest = path.join(destPath, "NuGet.Config");
const nugetConfigCopy = path.join(projectFilesDestPath, "NuGet.Config");
if (fs.existsSync(nugetConfigDest)) {
copyTasks.push(fs.promises.cp(nugetConfigDest, nugetConfigCopy));
} else {
const template = readTextFile(nugetConfigPath, fs);
const nugetConfig = mustache.render(template, {
addReactNativePublicAdoFeed: true,
nuGetADOFeed: info.version.startsWith("0.0.0-"),
});
copyTasks.push(
writeTextFile(nugetConfigDest, nugetConfig, fs.promises),
writeTextFile(nugetConfigCopy, nugetConfig, fs.promises)
);
}
}
}
if (options.autolink) {
const projectRoot = path.resolve(path.dirname(projectManifest));
Promise.all(copyTasks)
.then(async () => {
// `react-native config` is cached by `@react-native-community/cli`. We
// need to manually regenerate the Windows project config and inject it.
const config = await loadReactNativeConfig(rnWindowsPath);
config.project.windows = config.platforms.windows.projectConfig(
projectRoot,
{
sourceDir: path.relative(projectRoot, destPath),
solutionFile: path.relative(destPath, slnPath),
project: {
projectFile: vcxprojLocalPath,
},
}
);
return config;
})
.then((config) => {
const autolink = config.commands.find(
({ name }) => name === "autolink-windows"
);
autolink?.func([], config, { proj: vcxprojPath });
});
}
return undefined;
}
if (isMain(import.meta.url)) {
parseArgs(
"Generate a Visual Studio solution for a React Native app",
{
"project-directory": {
description:
"Directory where solution will be created (default: “windows”)",
type: "string",
short: "p",
default: "windows",
},
autolink: {
description: `Run autolink after generating the solution (this is the default on Windows)`,
type: "boolean",
default: os.platform() === "win32",
},
msbuildprops: {
description: `Comma-separated properties passed to MSBuild e.g., UseWinUI3=true,WindowsTargetPlatformVersion=10.0.26100.0`,
type: "string",
},
"use-fabric": {
description: "Use New Architecture [experimental] (supported on 0.73+)",
type: "boolean",
},
"use-hermes": {
description:
"Use Hermes instead of Chakra as the JS engine (enabled by default on 0.73+)",
type: "boolean",
},
"use-nuget": {
description: "Use NuGet packages [experimental]",
type: "boolean",
default: false,
},
},
async ({
"project-directory": projectDirectory,
autolink,
msbuildprops,
"use-fabric": useFabric,
"use-hermes": useHermes,
"use-nuget": useNuGet,
}) => {
const destPath = path.resolve(projectDirectory);
const error = await generateSolution(destPath, {
autolink,
msbuildprops: parseMSBuildProperties(msbuildprops),
useFabric,
useHermes,
useNuGet,
});
if (error) {
console.error(error);
process.exitCode = 1;
}
}
);
}