UNPKG

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
// @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, }; }