react-native-test-app
Version:
react-native-test-app provides a test app for all supported platforms as a package
433 lines (393 loc) • 12.4 kB
JavaScript
// @ts-check
import { resolveCommunityCLI } from "@rnx-kit/tools-react-native/context";
import { XMLParser } from "fast-xml-parser";
import * as nodefs from "node:fs";
import * as path from "node:path";
import { URL, fileURLToPath } from "node:url";
import { v5 as uuidv5 } from "uuid";
import {
findNearest,
getPackageVersion,
memo,
readJSONFile,
readTextFile,
toVersionNumber,
v,
} from "../scripts/helpers.js";
import * as colors from "../scripts/utils/colors.mjs";
/**
* @import { Config } from "@react-native-community/cli-types"
* @import {
* AppManifest,
* AppxBundle,
* AssetItems,
* Assets,
* MSBuildProjectOptions,
* ProjectInfo,
* } from "../scripts/types.js";
*/
const uniqueFilterIdentifier = "e48dc53e-40b1-40cb-970a-f89935452892";
/**
* Returns whether specified object is Error-like.
* @param {unknown} e
* @returns {e is Error}
*/
function isErrorLike(e) {
return typeof e === "object" && e !== null && "name" in e && "message" in e;
}
/**
* Normalizes specified path.
* @param {string} p
* @returns {string}
*/
function normalizePath(p) {
return p.replace(/[/\\]+/g, "\\");
}
/**
* Returns path to the specified asset relative to the project path.
* @param {string} projectPath
* @param {string} assetPath
* @returns {string}
*/
function projectRelativePath(projectPath, assetPath) {
return normalizePath(
path.isAbsolute(assetPath)
? path.relative(projectPath, assetPath)
: assetPath
);
}
/**
* @param {Required<AppManifest>["windows"]} certificate
* @param {string} projectPath
* @returns {string}
*/
function generateCertificateItems(
{ certificateKeyFile, certificateThumbprint, certificatePassword },
projectPath
) {
const items = [];
if (typeof certificateKeyFile === "string") {
items.push(
"<AppxPackageSigningEnabled>true</AppxPackageSigningEnabled>",
`<PackageCertificateKeyFile>$(ProjectRootDir)\\${projectRelativePath(
projectPath,
certificateKeyFile
)}</PackageCertificateKeyFile>`
);
}
if (typeof certificateThumbprint === "string") {
items.push(
`<PackageCertificateThumbprint>${certificateThumbprint}</PackageCertificateThumbprint>`
);
}
if (typeof certificatePassword === "string") {
items.push(
`<PackageCertificatePassword>${certificatePassword}</PackageCertificatePassword>`
);
}
return items.join("\n ");
}
/**
* Equivalent to invoking `react-native config`.
* @type {(rnWindowsPath: string) => Promise<Config>}
*/
export const loadReactNativeConfig = memo((rnWindowsPath) => {
const rncli = "file://" + resolveCommunityCLI(rnWindowsPath);
return import(rncli).then(async (cli) => {
const { loadConfig, loadConfigAsync } = cli?.default ?? cli;
if (!loadConfigAsync) {
// The signature of `loadConfig` changed in 14.0.0:
// https://github.com/react-native-community/cli/commit/b787c89edb781bb788576cd615d2974fc81402fc
return loadConfig.length === 1 ? loadConfig({}) : loadConfig();
}
return await loadConfigAsync({});
});
});
/**
* @param {string} message
*/
function warn(message) {
const tag = colors.yellow(colors.bold("warn"));
console.warn(tag, message);
}
/**
* @param {string[]} resources
* @param {string} projectPath
* @param {AssetItems} assets
* @param {string} currentFilter
* @param {string} source
* @returns {AssetItems}
*/
function generateContentItems(
resources,
projectPath,
assets = { assetFilters: [], assetItemFilters: [], assetItems: [] },
currentFilter = "Assets",
source = "",
fs = nodefs
) {
const { assetFilters, assetItemFilters, assetItems } = assets;
for (const resource of resources) {
const resourcePath = path.isAbsolute(resource)
? path.relative(projectPath, resource)
: resource;
if (!fs.existsSync(resourcePath)) {
warn(`Resource not found: ${resource}`);
continue;
}
if (fs.statSync(resourcePath).isDirectory()) {
const filter =
"Assets\\" +
normalizePath(
source ? path.relative(source, resource) : path.basename(resource)
);
const id = uuidv5(filter, uniqueFilterIdentifier);
assetFilters.push(
`<Filter Include="${filter}">`,
` <UniqueIdentifier>{${id}}</UniqueIdentifier>`,
`</Filter>`
);
const files = fs
.readdirSync(resourcePath)
.map((file) => path.join(resource, file));
generateContentItems(
files,
projectPath,
assets,
filter,
source || path.dirname(resource),
fs
);
} else {
const assetPath = normalizePath(path.relative(projectPath, resourcePath));
/**
* When a resources folder is included in the manifest, the directory
* structure within the folder must be maintained. For example, given
* `dist/assets`, we must output:
*
* `<DestinationFolders>$(OutDir)\\Bundle\\assets\\...</DestinationFolders>`
* `<DestinationFolders>$(OutDir)\\Bundle\\assets\\node_modules\\...</DestinationFolders>`
* ...
*
* Resource paths are always prefixed with `$(OutDir)\\Bundle`.
*/
const destination =
source &&
`\\${normalizePath(path.relative(source, path.dirname(resource)))}`;
assetItems.push(
`<CopyFileToFolders Include="$(ProjectRootDir)\\${assetPath}">`,
` <DestinationFolders>$(OutDir)\\Bundle${destination}</DestinationFolders>`,
"</CopyFileToFolders>"
);
assetItemFilters.push(
`<CopyFileToFolders Include="$(ProjectRootDir)\\${assetPath}">`,
` <Filter>${currentFilter}</Filter>`,
"</CopyFileToFolders>"
);
}
}
return assets;
}
/**
* Finds NuGet dependencies.
*
* Visual Studio (?) currently does not download transitive dependencies. This
* is a workaround until `react-native-windows` autolinking adds support.
*
* @see {@link https://github.com/microsoft/react-native-windows/issues/9578}
* @param {string} rnWindowsPath
* @returns {Promise<[string, string][]>}
*/
async function getNuGetDependencies(rnWindowsPath, fs = nodefs) {
const pkgJson = findNearest("package.json", undefined, fs);
if (!pkgJson) {
return [];
}
const config = await loadReactNativeConfig(rnWindowsPath);
const dependencies = Object.values(config.dependencies);
const xml = new XMLParser({
ignoreAttributes: false,
transformTagName: (tag) => tag.toLowerCase(),
});
const lowerCase = (/** @type{Record<string, string>} */ refs) => {
for (const key of Object.keys(refs)) {
refs[key.toLowerCase()] = refs[key];
}
return refs;
};
/** @type {Record<string, [string, string]>} */
const packageRefs = {};
for (const { root, platforms } of dependencies) {
/** @type {{ projects?: Record<string, string>[]; sourceDir?: string; }?} */
const windows = platforms?.["windows"];
if (!windows || !Array.isArray(windows.projects)) {
continue;
}
const projects = windows.projects.map(({ projectFile }) =>
path.join(root, windows.sourceDir || ".", projectFile)
);
if (!Array.isArray(projects)) {
continue;
}
// Look for `PackageReference` entries:
//
// <Project>
// <ImportGroup>
// <PackageReference ... />
// <PackageReference ... />
// </ImportGroup>
// </Project>
//
for (const vcxproj of projects) {
const proj = xml.parse(readTextFile(vcxproj, fs));
const itemGroup = proj.project?.itemgroup;
if (!itemGroup) {
continue;
}
const itemGroups = Array.isArray(itemGroup) ? itemGroup : [itemGroup];
for (const group of itemGroups) {
const pkgRef = group["packagereference"];
if (!pkgRef) {
continue;
}
const refs = Array.isArray(pkgRef) ? pkgRef : [pkgRef];
for (const ref of refs) {
// Attributes are not case-sensitive
lowerCase(ref);
const id = ref["@_include"];
const version = ref["@_version"];
if (!id || !version) {
continue;
}
// Package ids are not case-sensitive
packageRefs[id.toLowerCase()] = [id, version];
}
}
}
}
// Remove dependencies managed by us
const vcxprojUrl = new URL("UWP/ReactTestApp.vcxproj", import.meta.url);
const vcxproj = readTextFile(fileURLToPath(vcxprojUrl), fs);
const matches = vcxproj.matchAll(/PackageReference Include="(.+?)"/g);
for (const m of matches) {
const id = m[1].toLowerCase();
delete packageRefs[id];
}
return Object.values(packageRefs);
}
/**
* Maps NuGet dependencies to `<Import>` elements.
* @param {[string, string][]} refs
* @returns {string}
*/
export function importTargets(refs) {
return refs
.map(
([id, version]) =>
`<Import Project="$(SolutionDir)packages\\${id}.${version}\\build\\native\\${id}.targets" Condition="Exists('$(SolutionDir)packages\\${id}.${version}\\build\\native\\${id}.targets')" />`
)
.join("\n ");
}
/**
* @param {string[] | { windows?: string[] } | undefined} resources
* @param {string} projectPath
* @returns {Assets}
*/
export function parseResources(resources, projectPath, fs = nodefs) {
if (!Array.isArray(resources)) {
if (resources?.windows) {
return parseResources(resources.windows, projectPath, fs);
}
return { assetItems: "", assetItemFilters: "", assetFilters: "" };
}
const { assetItems, assetItemFilters, assetFilters } = generateContentItems(
resources,
projectPath,
/* assets */ undefined,
/* currentFilter */ undefined,
/* source */ undefined,
fs
);
return {
assetItems: assetItems.join("\n "),
assetItemFilters: assetItemFilters.join("\n "),
assetFilters: assetFilters.join("\n "),
};
}
/**
* Reads manifest file and and resolves paths to bundle resources.
* @param {string | null} manifestFilePath Path to the closest manifest file.
* @returns {AppxBundle} Application name, and paths to directories and files to include.
*/
export function getBundleResources(manifestFilePath, fs = nodefs) {
// 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 {
/** @type {AppManifest} */
const manifest = readJSONFile(manifestFilePath, fs);
const { name, singleApp, resources, windows } = manifest;
const projectPath = path.dirname(manifestFilePath);
return {
appName: name || defaultName,
singleApp,
appxManifest: projectRelativePath(
projectPath,
windows?.appxManifest || defaultAppxManifest
),
packageCertificate: generateCertificateItems(
windows || {},
projectPath
),
...parseResources(resources, projectPath, fs),
};
} catch (e) {
if (isErrorLike(e)) {
warn(`Could not parse 'app.json':\n${e.message}`);
} else {
throw e;
}
}
} else {
warn("Could not find 'app.json' file.");
}
return {
appName: defaultName,
appxManifest: defaultAppxManifest,
assetItems: "",
assetItemFilters: "",
assetFilters: "",
packageCertificate: "",
};
}
/**
* @param {MSBuildProjectOptions} options
* @param {string} rnWindowsPath
* @param {string} destPath
* @returns {Promise<ProjectInfo>}
*/
export async function projectInfo(
{ useFabric, useNuGet },
rnWindowsPath,
destPath,
fs = nodefs
) {
const version = getPackageVersion("react-native-windows", rnWindowsPath, fs);
const versionNumber = toVersionNumber(version);
const newArch =
Boolean(useFabric) && (versionNumber === 0 || versionNumber >= v(0, 74, 0));
if (useFabric && !newArch) {
warn("New Architecture requires `react-native-windows` 0.74+");
}
return {
version,
versionNumber,
bundle: getBundleResources(findNearest("app.json", destPath, fs), fs),
nugetDependencies: await getNuGetDependencies(rnWindowsPath),
useExperimentalNuGet: newArch || useNuGet,
useFabric: newArch,
};
}