diff --git a/script/command-executor.ts b/script/command-executor.ts index d670878..50dc466 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -34,11 +34,11 @@ import { getBundleSourceMapOutput, getMinifyParams, getReactNativePackagePath, - isHermesEnabled, isValidVersion, runHermesEmitBinaryCommand, takeHermesBaseBytecode, } from "./react-native-utils"; +import { isHermesEnabled } from "./utils/hermes-utils"; import { fileDoesNotExistOrIsDirectory, fileExists, isBinaryOrZip, extractArchive } from "./utils/file-utils"; import { getAndroidVersionInfo } from "./utils/gradle-utils"; import { @@ -1139,7 +1139,8 @@ export const runExpoExportEmbedCommand = async ( outputFolder: string, sourcemapOutputFolder: string, platform: string, - extraBundlerOptions: string[] + extraBundlerOptions: string[], + isHermes: boolean ) => { const expoBundleArgs: string[] = []; const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; @@ -1169,8 +1170,7 @@ export const runExpoExportEmbedCommand = async ( if (sourcemapOutputFolder) { let bundleSourceMapOutput = sourcemapOutputFolder; if (!sourcemapOutputFolder.endsWith(".map")) { - // user defined directory, нужно вычислить полный путь - bundleSourceMapOutput = await getBundleSourceMapOutput(command, bundleName, sourcemapOutputFolder); + bundleSourceMapOutput = getBundleSourceMapOutput(platform, bundleName, sourcemapOutputFolder, isHermes); } expoBundleArgs.push("--sourcemap-output", bundleSourceMapOutput); @@ -1359,6 +1359,7 @@ export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => }) .then(() => deleteFolder(`${os.tmpdir()}/react-*`)) .then(async () => { + const isHermes = await isHermesEnabled(command, platform); await runExpoExportEmbedCommand( command, bundleName, @@ -1367,11 +1368,9 @@ export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => outputFolder, sourcemapOutputFolder, platform, - command.extraBundlerOptions + command.extraBundlerOptions, + isHermes ); - }) - .then(async () => { - const isHermes = await isHermesEnabled(command, platform); if (isHermes) { await createEmptyTempReleaseFolder(baseReleaseTmpFolder); @@ -1472,6 +1471,7 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = // https://github.com/facebook/react-native/issues/4289 .then(() => deleteFolder(`${os.tmpdir()}/react-*`)) .then(async () => { + const isHermes = await isHermesEnabled(command, platform); await runReactNativeBundleCommand( command, bundleName, @@ -1480,11 +1480,9 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = outputFolder, sourcemapOutputFolder, platform, - command.extraBundlerOptions + command.extraBundlerOptions, + isHermes ); - }) - .then(async () => { - const isHermes = await isHermesEnabled(command, platform); if (isHermes) { await createEmptyTempReleaseFolder(baseReleaseTmpFolder); @@ -1822,7 +1820,8 @@ export const runReactNativeBundleCommand = async ( outputFolder: string, sourcemapOutputFolder: string, platform: string, - extraBundlerOptions: string[] + extraBundlerOptions: string[], + isHermes: boolean ) => { const reactNativeBundleArgs: string[] = []; const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; @@ -1854,14 +1853,13 @@ export const runReactNativeBundleCommand = async ( if (sourcemapOutputFolder) { let bundleSourceMapOutput = sourcemapOutputFolder; if (!sourcemapOutputFolder.endsWith(".map")) { - // user defined full path to source map. let's use that instead - bundleSourceMapOutput = await getBundleSourceMapOutput(command, bundleName, sourcemapOutputFolder); + bundleSourceMapOutput = getBundleSourceMapOutput(platform, bundleName, sourcemapOutputFolder, isHermes); } reactNativeBundleArgs.push("--sourcemap-output", bundleSourceMapOutput); } - const minifyValue = await getMinifyParams(command); + const minifyValue = getMinifyParams(platform, isHermes); Array.prototype.push.apply(reactNativeBundleArgs, minifyValue); if (extraBundlerOptions.length > 0) { diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index 6a963c1..eca3e43 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -2,50 +2,36 @@ import * as fs from "fs"; import * as chalk from "chalk"; import * as path from "path"; import * as childProcess from "child_process"; -import { coerce, compare, gte, valid } from "semver"; -import { downloadBlob, extractArchive, fileDoesNotExistOrIsDirectory } from "./utils/file-utils"; +import { valid } from "semver"; +import { downloadBlob, extractArchive } from "./utils/file-utils"; +import { findHermesc, isBaseBytecodeCompatible } from "./utils/hermes-utils"; import * as dotenv from "dotenv"; -import { DotenvParseOutput } from "dotenv"; -import * as cli from "../script/types/cli"; +import * as cli from "./types/cli"; import { log, sdk } from "./command-executor"; -const g2js = require("gradle-to-js/lib/parser"); - export function isValidVersion(version: string): boolean { return !!valid(version) || /^\d+\.\d+$/.test(version); } -export async function getBundleSourceMapOutput(command: cli.IReleaseReactCommand, bundleName: string, sourcemapOutputFolder: string) { - let bundleSourceMapOutput: string | undefined; - switch (command.platform) { +export function getBundleSourceMapOutput( + platform: string, + bundleName: string, + sourcemapOutputFolder: string, + isHermes: boolean +): string { + switch (platform) { case "android": { - // see BundleHermesCTask -> resolvePackagerSourceMapFile - // for Hermes targeted bundles there are 2 source maps: "packager" (metro) and "compiler" (Hermes) - // Metro bundles use .packager.map notation - const isHermes = await isHermesEnabled(command, command.platform); - if (isHermes) { - bundleSourceMapOutput = path.join(sourcemapOutputFolder, bundleName + ".packager.map"); - } else { - bundleSourceMapOutput = path.join(sourcemapOutputFolder, bundleName + ".map"); - } - - break; + // With Hermes, Metro's map is the "packager" one (BundleHermesCTask) + return path.join(sourcemapOutputFolder, bundleName + (isHermes ? ".packager.map" : ".map")); } case "ios": { - // see react-native-xcode.sh - // to match js bundle generated by Xcode and by Revopush cli we must respect SOURCEMAP_FILE value - // because it appears as //# sourceMappingURL value in a js bundle - const xcodeDotEnvValue = getXcodeDotEnvValue("SOURCEMAP_FILE"); - const sourceMapFilename = xcodeDotEnvValue ? path.basename(xcodeDotEnvValue) : bundleName + ".map"; - - bundleSourceMapOutput = path.join(sourcemapOutputFolder, sourceMapFilename); - - break; + // Same name as react-native-xcode.sh, since it ends up in the bundle's sourceMappingURL + const sourceMapFile = getXcodeDotEnvValue("SOURCEMAP_FILE"); + return path.join(sourcemapOutputFolder, sourceMapFile ? path.basename(sourceMapFile) : bundleName + ".map"); } default: throw new Error('Platform must be either "android" or "ios".'); } - return bundleSourceMapOutput; } export async function takeHermesBaseBytecode( @@ -54,7 +40,12 @@ export async function takeHermesBaseBytecode( outputFolder: string, bundleName: string ): Promise { - const { bundleBlobUrl } = await sdk.getBaseRelease(command.appName, command.deploymentName, command.appStoreVersion, command.buildNumber); + const { bundleBlobUrl } = await sdk.getBaseRelease( + command.appName, + command.deploymentName, + command.appStoreVersion, + command.buildNumber + ); if (!bundleBlobUrl) { return null; } @@ -71,48 +62,6 @@ export async function takeHermesBaseBytecode( return baseReleaseBundle; } -// Hermes BytecodeFileHeader: uint64 magic, uint32 version (little-endian), stable since Hermes v0.1 -const HERMES_BYTECODE_MAGIC = BigInt("0x1F1903C103BC1FC6"); - -function getHermesBytecodeVersion(bytecode: Buffer): number | null { - if (bytecode.length < 12 || bytecode.readBigUInt64LE(0) !== HERMES_BYTECODE_MAGIC) { - return null; - } - return bytecode.readUInt32LE(8); -} - -// Compile an empty input to stdout and read the version from the emitted header -export function getHermesCompilerBytecodeVersion(hermesCommand: string): number | null { - const result = childProcess.spawnSync(hermesCommand, ["-emit-binary", "-"], { input: "", timeout: 30000 }); - return result.status === 0 ? getHermesBytecodeVersion(result.stdout) : null; -} - -// hermesc -base-bytecode aborts on a base of another bytecode version; the base is only a diff optimization -export function resolveHermesBaseBytecode(baseBytecode: string, compilerBytecodeVersion: number | null): string | null { - const baseBytecodeVersion = getHermesBytecodeVersion(fs.readFileSync(baseBytecode)); - if (baseBytecodeVersion === null) { - log(chalk.yellow("\nBase release bundle is not Hermes bytecode, skipping -base-bytecode.\n")); - return null; - } - - if (compilerBytecodeVersion === null) { - log(chalk.yellow("\nCould not detect the Hermes compiler bytecode version, skipping -base-bytecode.\n")); - return null; - } - - if (baseBytecodeVersion !== compilerBytecodeVersion) { - log( - chalk.yellow( - `\nWarning: base release is Hermes bytecode v${baseBytecodeVersion}, local compiler produces v${compilerBytecodeVersion}. ` + - `Skipping -base-bytecode. Binaries on v${baseBytecodeVersion} can't run this update.\n` - ) - ); - return null; - } - - return baseBytecode; -} - export async function runHermesEmitBinaryCommand( command: cli.IReleaseReactCommand, bundleName: string, @@ -122,173 +71,92 @@ export async function runHermesEmitBinaryCommand( gradleFile: string, baseBytecode?: string ): Promise { - const hermesArgs: string[] = []; - const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; + const bundle = path.join(outputFolder, bundleName); + const bytecode = bundle + ".hbc"; + const hermesc = await findHermesc(command.platform, gradleFile); - if (typeof envNodeArgs !== "undefined") { - Array.prototype.push.apply(hermesArgs, envNodeArgs.trim().split(/\s+/)); - } - - Array.prototype.push.apply(hermesArgs, [ + const hermesArgs = [ + ...(process.env.CODE_PUSH_NODE_ARGS?.trim().split(/\s+/).filter(Boolean) ?? []), "-emit-binary", "-O", "-out", - path.join(outputFolder, bundleName + ".hbc"), - path.join(outputFolder, bundleName), + bytecode, + bundle, "-w", "-max-diagnostic-width=80", ...extraHermesFlags, - ]); - + ]; if (sourcemapOutputFolder) { hermesArgs.push("-output-source-map"); } - - const hermesCommand = await getHermesCommand(gradleFile); - - const resolvedBaseBytecode = - baseBytecode && resolveHermesBaseBytecode(baseBytecode, getHermesCompilerBytecodeVersion(hermesCommand)); - if (resolvedBaseBytecode) { - hermesArgs.push("-base-bytecode", resolvedBaseBytecode); + if (baseBytecode && isBaseBytecodeCompatible(baseBytecode, hermesc)) { + hermesArgs.push("-base-bytecode", baseBytecode); } console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); - const hermesProcess = childProcess.spawn(hermesCommand, hermesArgs); - console.log(`${hermesCommand} ${hermesArgs.join(" ")}`); + await runCommand("hermes", hermesc, hermesArgs); + await fs.promises.rename(bytecode, bundle); - return new Promise((resolve, reject) => { - hermesProcess.on("error", reject); - - hermesProcess.stdout.on("data", (data: Buffer) => { - console.log(data.toString().trim()); - }); + if (sourcemapOutputFolder) { + await composeHermesSourceMaps(command, bundleName, outputFolder, sourcemapOutputFolder); + } +} - hermesProcess.stderr.on("data", (data: Buffer) => { - console.error(data.toString().trim()); - }); +// Packager + Hermes source maps = final source map, as RN's build does +async function composeHermesSourceMaps( + command: cli.IReleaseReactCommand, + bundleName: string, + outputFolder: string, + sourcemapOutputFolder: string +): Promise { + const composeSourceMaps = path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); + if (!fs.existsSync(composeSourceMaps)) { + throw new Error("react-native compose-source-maps.js script is not found"); + } - hermesProcess.on("close", (exitCode: number, signal: string) => { - if (exitCode !== 0) { - reject(new Error(`"hermes" command failed (exitCode=${exitCode}, signal=${signal}).`)); - return; - } - // Copy HBC bundle to overwrite JS bundle - const source = path.join(outputFolder, bundleName + ".hbc"); - const destination = path.join(outputFolder, bundleName); - fs.copyFile(source, destination, (err) => { - if (err) { - console.error(err); - reject(new Error(`Copying file ${source} to ${destination} failed. "hermes" previously exited with code ${exitCode}.`)); - return; - } - fs.unlink(source, (err) => { - if (err) { - console.error(err); - reject(err); - return; - } - resolve(null as void); - }); - }); - }); - }).then(async () => { - if (!sourcemapOutputFolder) { - // skip source map compose if source map is not enabled - return; - } + const hermesSourceMap = path.join(outputFolder, bundleName + ".hbc.map"); + if (!fs.existsSync(hermesSourceMap)) { + throw new Error(`sourcemap file ${hermesSourceMap} is not found`); + } - const composeSourceMapsPath = getComposeSourceMapsPath(); - if (!composeSourceMapsPath) { - throw new Error("react-native compose-source-maps.js scripts is not found"); - } + let packagerSourceMap = sourcemapOutputFolder; + let combinedSourceMap = sourcemapOutputFolder; + if (!sourcemapOutputFolder.endsWith(".map")) { + packagerSourceMap = getBundleSourceMapOutput(command.platform, bundleName, sourcemapOutputFolder, true); + combinedSourceMap = command.platform === "android" ? path.join(sourcemapOutputFolder, bundleName + ".map") : packagerSourceMap; + } - const jsCompilerSourceMapFile = path.join(outputFolder, bundleName + ".hbc" + ".map"); - if (!fs.existsSync(jsCompilerSourceMapFile)) { - throw new Error(`sourcemap file ${jsCompilerSourceMapFile} is not found`); - } + await runCommand("compose-source-maps", "node", [composeSourceMaps, packagerSourceMap, hermesSourceMap, "-o", combinedSourceMap]); + // Not part of the release + await fs.promises.unlink(hermesSourceMap); +} - const platformSourceMapOutput = await getBundleSourceMapOutput(command, bundleName, sourcemapOutputFolder); - return new Promise((resolve, reject) => { - let bundleSourceMapOutput = sourcemapOutputFolder; - let combinedSourceMapOutput = sourcemapOutputFolder; +function runCommand(name: string, command: string, args: string[]): Promise { + console.log(`${command} ${args.join(" ")}`); + const child = childProcess.spawn(command, args); + child.stdout.on("data", (data: Buffer) => console.log(data.toString().trim())); + child.stderr.on("data", (data: Buffer) => console.error(data.toString().trim())); - if (!sourcemapOutputFolder.endsWith(".map")) { - bundleSourceMapOutput = platformSourceMapOutput; - switch (command.platform) { - case "android": { - combinedSourceMapOutput = path.join(sourcemapOutputFolder, bundleName + ".map"); - break; - } - case "ios": { - combinedSourceMapOutput = bundleSourceMapOutput; - break; - } - default: - throw new Error('Platform must be either "android" or "ios".'); - } + return new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (exitCode: number, signal: string) => { + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`"${name}" command failed (exitCode=${exitCode}, signal=${signal}).`)); } - const composeSourceMapsArgs = [ - composeSourceMapsPath, - bundleSourceMapOutput, - jsCompilerSourceMapFile, - "-o", - combinedSourceMapOutput, - ]; - - // https://github.com/facebook/react-native/blob/master/react.gradle#L211 - // https://github.com/facebook/react-native/blob/master/scripts/react-native-xcode.sh#L178 - // packager.sourcemap.map + hbc.sourcemap.map = sourcemap.map - const composeSourceMapsProcess = childProcess.spawn("node", composeSourceMapsArgs); - console.log(`${composeSourceMapsPath} ${composeSourceMapsArgs.join(" ")}`); - - composeSourceMapsProcess.stdout.on("data", (data: Buffer) => { - console.log(data.toString().trim()); - }); - - composeSourceMapsProcess.stderr.on("data", (data: Buffer) => { - console.error(data.toString().trim()); - }); - - composeSourceMapsProcess.on("close", (exitCode: number, signal: string) => { - if (exitCode !== 0) { - reject(new Error(`"compose-source-maps" command failed (exitCode=${exitCode}, signal=${signal}).`)); - return; - } - - // Delete the HBC sourceMap, otherwise it will be included in 'code-push' bundle as well - fs.unlink(jsCompilerSourceMapFile, (err) => { - if (err) { - console.error(err); - reject(err); - return; - } - - resolve(null); - }); - }); }); }); } -export function getXcodeDotEnvValue(key: string): string | undefined { - const xcodeEnvs = loadEnvAsMap([path.join("ios", ".xcode.env.local"), path.join("ios", ".xcode.env.local")]); - return xcodeEnvs.get(key); -} - -export async function getMinifyParams(command: cli.IReleaseReactCommand) { - const isHermes = await isHermesEnabled(command); - - switch (command.platform) { +export function getMinifyParams(platform: string, isHermes: boolean): (string | boolean)[] { + switch (platform) { case "android": { - // android always explicitly pass --minify true/false - // TaskConfiguration it.minifyEnabled.set(!isHermesEnabledInThisVariant) + // The RN Gradle plugin minifies unless Hermes is enabled return ["--minify", !isHermes]; } case "ios": { - //if [[ $USE_HERMES != false && $DEV == false ]]; then - // EXTRA_ARGS+=("--minify" "false") - // fi - // ios does pass --minify false only if Hermes enables and does pass anything otherwise + // react-native-xcode.sh passes "--minify false" only with Hermes return isHermes ? ["--minify", false] : []; } default: @@ -296,259 +164,24 @@ export async function getMinifyParams(command: cli.IReleaseReactCommand) { } } -export async function isHermesEnabled(command: cli.IReleaseReactCommand, platform: string = command.platform.toLowerCase()) { - if (command.useHermes) return true; - if (platform === "android") return getAndroidHermesEnabled(command.gradleFile); - if (platform === "ios") return getiOSHermesEnabled(command.podFile); - return false; -} - -function parseBuildGradleFile(gradleFile: string) { - let buildGradlePath: string = path.join("android", "app"); - if (gradleFile) { - buildGradlePath = gradleFile; - } - - try { - if (fs.lstatSync(buildGradlePath).isDirectory()) { - buildGradlePath = path.join(buildGradlePath, "build.gradle"); - fs.accessSync(buildGradlePath); - } - } catch { - throw new Error(`Unable to find gradle file "${buildGradlePath}".`); - } - - return g2js.parseFile(buildGradlePath).catch(() => { - throw new Error(`Unable to parse the "${buildGradlePath}" file. Please ensure it is a well-formed Gradle file.`); - }); -} - -function parseGradlePropertiesFile(gradleFile: string): Record { - let gradlePropsPath: string = path.join("android", "gradle.properties"); - - try { - if (gradleFile) { - const base = gradleFile; - const stat = fs.lstatSync(base); - - if (stat.isDirectory()) { - if (path.basename(base) === "app") { - gradlePropsPath = path.join(base, "..", "gradle.properties"); - } else { - gradlePropsPath = path.join(base, "gradle.properties"); - } - } else { - gradlePropsPath = path.join(path.dirname(base), "..", "gradle.properties"); - } - } - } catch {} - - gradlePropsPath = path.normalize(gradlePropsPath); - - if (fileDoesNotExistOrIsDirectory(gradlePropsPath)) { - throw new Error(`Unable to find gradle.properties file "${gradlePropsPath}".`); - } - - const text = fs.readFileSync(gradlePropsPath, "utf8"); - const props: Record = {}; - for (const rawLine of text.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - const m = line.match(/^([^=\s]+)\s*=\s*(.*)$/); - if (m) { - const key = m[1].trim(); - const val = m[2].trim(); - props[key] = val; - } - } - return props; -} - -async function getHermesCommandFromGradle(gradleFile: string): Promise { - const buildGradle: any = await parseBuildGradleFile(gradleFile); - const hermesCommandProperty: any = Array.from(buildGradle["project.ext.react"] || []).find((prop: string) => - prop.trim().startsWith("hermesCommand:") - ); - if (hermesCommandProperty) { - return hermesCommandProperty.replace("hermesCommand:", "").trim().slice(1, -1); - } else { - return ""; - } -} - -async function getAndroidHermesEnabled(gradleFile: string): Promise { - try { - const props = parseGradlePropertiesFile(gradleFile); - if (typeof props.hermesEnabled !== "undefined") { - const v = String(props.hermesEnabled).trim().toLowerCase(); - if (v === "true") return true; - if (v === "false") return false; - } - } catch {} - - try { - const buildGradle: any = await parseBuildGradleFile(gradleFile); - const lines: string[] = Array.from(buildGradle["project.ext.react"] || []); - if (lines.some((l) => /\benableHermes\s*:\s*true\b/.test(l))) return true; - if (lines.some((l) => /\benableHermes\s*:\s*false\b/.test(l))) return false; - } catch {} - - const rnVersion = coerce(getReactNativeVersion())?.version; - return rnVersion && compare(rnVersion, "0.70.0") >= 0; -} - -function getiOSHermesEnabled(podFile: string): boolean { - const podPath = podFile || path.join("ios", "Podfile"); - if (podFile && fileDoesNotExistOrIsDirectory(podPath)) { - throw new Error(`Unable to find Podfile file "${podPath}".`); - } else if (!podFile && fileDoesNotExistOrIsDirectory(podPath)) { - // No Podfile at default path (e.g. Android-only project); fall back to RN version heuristic - const rnVersion = coerce(getReactNativeVersion())?.version; - return !!(rnVersion && compare(rnVersion, "0.70.0") >= 0); - } - - try { - const podFileContents = fs.readFileSync(podPath).toString(); - - const hasTrue = /([^#\n]*:?hermes_enabled(\s+|\n+)?(=>|:)(\s+|\n+)?true)/.test(podFileContents); - if (hasTrue) return true; - - const hasFalse = /([^#\n]*:?hermes_enabled(\s+|\n+)?(=>|:)(\s+|\n+)?false)/.test(podFileContents); - if (hasFalse) return false; - - const rnVersion = coerce(getReactNativeVersion())?.version; - return rnVersion && compare(rnVersion, "0.70.0") >= 0; - } catch (error) { - throw error; - } -} - -function loadEnvAsMap(envPaths = []): Map { - const merged: DotenvParseOutput = {}; - - for (const envPath of envPaths) { - if (fs.existsSync(envPath)) { - Object.assign(merged, dotenv.parse(fs.readFileSync(envPath))); // later files override earlier ones - } - } - - // fallback to process.env for anything missing - return new Map([...Object.entries(process.env), ...Object.entries(merged)]); -} - -function getHermesOSBin(): string { - switch (process.platform) { - case "win32": - return "win64-bin"; - case "darwin": - return "osx-bin"; - case "freebsd": - case "linux": - case "sunos": - default: - return "linux64-bin"; - } -} - -function getHermesOSExe(): string { - const react63orAbove = compare(coerce(getReactNativeVersion())?.version, "0.63.0") !== -1; - const hermesExecutableName = react63orAbove ? "hermesc" : "hermes"; - switch (process.platform) { - case "win32": - return hermesExecutableName + ".exe"; - default: - return hermesExecutableName; - } -} - -async function getHermesCommand(gradleFile: string): Promise { - const fileExists = (file: string): boolean => { - try { - return fs.statSync(file).isFile(); - } catch (e) { - return false; - } - }; - // Hermes is bundled with react-native since 0.69 - const reactNativePath = getReactNativePackagePath(); - const bundledHermesEngine = path.join(reactNativePath, "sdks", "hermesc", getHermesOSBin(), getHermesOSExe()); - if (fileExists(bundledHermesEngine)) { - return bundledHermesEngine; - } - - let gradleHermesCommand = ""; - try { - gradleHermesCommand = await getHermesCommandFromGradle(gradleFile); - } catch { - // Gradle files not present (e.g. iOS-only project); skip to node_modules fallback - } - if (gradleHermesCommand) { - return path.join("android", "app", gradleHermesCommand.replace("%OS-BIN%", getHermesOSBin())); - } else { - const nodeModulesPath = getNodeModulesPath(reactNativePath); - - // assume if hermes-engine exists it should be used instead of hermesvm - const hermesEngine = path.join(nodeModulesPath, "hermes-engine", getHermesOSBin(), getHermesOSExe()); - if (fileExists(hermesEngine)) { - return hermesEngine; - } - - // RN 0.83 hermes-compiler - const hermesCompiler = path.join(nodeModulesPath, "hermes-compiler", "hermesc", getHermesOSBin(), getHermesOSExe()); - if (fileExists(hermesCompiler)) { - return hermesCompiler; - } - - return path.join(nodeModulesPath, "hermesvm", getHermesOSBin(), "hermes"); - } -} - -function getComposeSourceMapsPath(): string { - // detect if compose-source-maps.js script exists - const composeSourceMaps = path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); - if (fs.existsSync(composeSourceMaps)) { - return composeSourceMaps; - } - return null; -} - -function getNodeModulesPath(reactNativePath: string): string { - const nodeModulesPath = path.dirname(reactNativePath); - if (directoryExistsSync(nodeModulesPath)) { - return nodeModulesPath; - } - - return path.join("node_modules"); -} - export function getReactNativePackagePath(): string { const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); - const packagePath = path.dirname(result.stdout.toString()); - if (result.status === 0 && directoryExistsSync(packagePath)) { - return packagePath; - } - - return path.join("node_modules", "react-native"); + return result.status === 0 ? path.dirname(result.stdout.toString().trim()) : path.join("node_modules", "react-native"); } -export function directoryExistsSync(dirname: string): boolean { - try { - return fs.statSync(dirname).isDirectory(); - } catch (err) { - if (err.code !== "ENOENT") { - throw err; - } - } - return false; +export function getReactNativeVersion(): string | undefined { + const packageJson = path.join(getReactNativePackagePath(), "package.json"); + return fs.existsSync(packageJson) ? JSON.parse(fs.readFileSync(packageJson, "utf8")).version : undefined; } -export function getReactNativeVersion(): string { - try { - const result = childProcess.spawnSync("node", ["--print", "require('react-native/package.json').version"]); - return result.stdout.toString().trim(); - } catch (error) { - throw new Error( - 'Unable to resolve "react-native". Please make sure it is installed in your project (e.g. "npm install react-native").' - ); +/** As react-native-xcode.sh sees it: ios/.xcode.env.local, then ios/.xcode.env, then the environment */ +export function getXcodeDotEnvValue(key: string): string | undefined { + for (const envFile of [".xcode.env.local", ".xcode.env"]) { + const envPath = path.join("ios", envFile); + const value = fs.existsSync(envPath) ? dotenv.parse(fs.readFileSync(envPath))[key] : undefined; + if (value !== undefined) { + return value; + } } + return process.env[key]; } diff --git a/script/utils/gradle-utils.ts b/script/utils/gradle-utils.ts index bab4690..cdc53d4 100644 --- a/script/utils/gradle-utils.ts +++ b/script/utils/gradle-utils.ts @@ -1,7 +1,5 @@ -// Detects the Android versionName/versionCode a CodePush release should target. -// Groovy DSL (build.gradle) is parsed statically; Kotlin DSL (build.gradle.kts) may -// compute values from arbitrary expressions, so it is evaluated by running the -// project's Gradle wrapper with an injected task that prints the resolved values. +// Reads the Android app's version and Hermes settings. Groovy DSL is parsed statically; Kotlin DSL versions are +// evaluated by running the Gradle wrapper with an injected task that prints them. import * as childProcess from "child_process"; import * as fs from "fs"; @@ -29,6 +27,7 @@ interface GradleVersionFields { versionCode: string | number | null; } +const DEFAULT_APP_DIR = path.join("android", "app"); const FALLBACK_HINT = "Pass the version explicitly with --targetBinaryVersion to skip Gradle detection."; // "revopush"-prefixed to avoid collisions with project tasks; written in Groovy, @@ -55,15 +54,19 @@ allprojects { } `.trim(); -/** @param gradleFile build script path or its directory; defaults to "android/app". */ +// Every `gradleFile` parameter below is the app's build script or its directory; defaults to "android/app". + export async function getAndroidVersionInfo(gradleFile?: string | null): Promise { - const buildFile = resolveGradleBuildFile(gradleFile ?? path.join("android", "app")); + const buildFile = findGradleBuildFile(gradleFile); + if (!buildFile) { + throw new Error(`Unable to find gradle file "${gradleFile ?? DEFAULT_APP_DIR}".`); + } const { versionName, versionCode } = buildFile.endsWith(".kts") ? await evaluateKotlinDslBuildFile(buildFile) - : await parseGroovyDslBuildFile(buildFile); + : await parseGroovyDslVersion(buildFile); const appVersion = resolveAppVersion(versionName, buildFile); - const buildNumber = resolveBuildNumber(versionCode); + const buildNumber = resolveBuildNumber(versionCode, buildFile); if (!buildNumber) { console.log(chalk.yellow( `Warning: Unable to read "android.defaultConfig.versionCode" from "${buildFile}". ` + @@ -73,20 +76,68 @@ export async function getAndroidVersionInfo(gradleFile?: string | null): Promise return { appVersion, buildNumber }; } -/** Locates the build script: the given file itself, or inside the given directory (Kotlin DSL preferred). */ -function resolveGradleBuildFile(gradleFile: string): string { - const candidates = [gradleFile, path.join(gradleFile, "build.gradle.kts"), path.join(gradleFile, "build.gradle")]; - const buildFile = candidates.find(fileExists); - if (!buildFile) { - throw new Error(`Unable to find gradle file "${gradleFile}".`); +/** `hermesEnabled` / `react.hermesEnabled`, or RN < 0.71's `enableHermes`; undefined if unset */ +export async function getGradleHermesEnabled(gradleFile?: string | null): Promise { + let enabled = readGradleProperty("hermesEnabled", gradleFile) ?? readGradleProperty("react.hermesEnabled", gradleFile); + const buildFile = findGradleBuildFile(gradleFile); + if (enabled === undefined && buildFile && !buildFile.endsWith(".kts")) { + const parsed = await parseGroovyDslBuildFile(buildFile).catch((): any => ({})); + enabled = getLegacyReactProperty(parsed, "enableHermes"); } - return buildFile; + return enabled === undefined ? undefined : enabled.toLowerCase() === "true"; } -async function parseGroovyDslBuildFile(buildFile: string): Promise { - const parsed: any = await g2js.parseFile(buildFile).catch(() => { +/** A literal `hermesCommand`, resolved from the project root like the RN Gradle plugin; expressions are ignored */ +export function getGradleHermesCommand(gradleFile?: string | null): string | undefined { + const buildFile = findGradleBuildFile(gradleFile); + const buildScript = buildFile ? fs.readFileSync(buildFile, "utf8") : ""; + const hermesCommand = /^\s*hermesCommand\s*(?:=|\.set\()\s*["']([^"']+)["']\s*\)?\s*$/m.exec(buildScript)?.[1]; + if (!hermesCommand) return undefined; + + const androidDir = path.resolve(path.dirname(buildFile), ".."); + const expanded = hermesCommand.replace(/^\$\{?rootDir\}?/, androidDir); + return expanded.includes("$") ? undefined : path.resolve(androidDir, "..", expanded); +} + +/** A property from the app module's or the Android project's gradle.properties. */ +export function readGradleProperty(key: string, gradleFile?: string | null): string | undefined { + const appDir = getAppDir(gradleFile); + for (const file of [path.join(appDir, "gradle.properties"), path.join(appDir, "..", "gradle.properties")]) { + if (!fileExists(file)) continue; + // The properties parser type-converts values (e.g. "2" becomes a number) — normalize back to string. + const value = properties.parse(fs.readFileSync(file, "utf8"))?.[key]?.toString(); + if (value) return value; + } +} + +function getAppDir(gradleFile?: string | null): string { + const location = gradleFile || DEFAULT_APP_DIR; + return fileExists(location) ? path.dirname(location) : location; +} + +/** The build script: the given file itself, or inside the app directory (Kotlin DSL preferred). */ +function findGradleBuildFile(gradleFile?: string | null): string | undefined { + const appDir = getAppDir(gradleFile); + return [gradleFile, path.join(appDir, "build.gradle.kts"), path.join(appDir, "build.gradle")].find( + (file) => file && fileExists(file) + ); +} + +function parseGroovyDslBuildFile(buildFile: string): Promise { + return g2js.parseFile(buildFile).catch(() => { throw new Error(`Unable to parse the "${buildFile}" file. Please ensure it is a well-formed Gradle file.`); }); +} + +// RN < 0.71's `project.ext.react = [key: value]`. g2js prepends a trailing `// comment` to the next entry, so strip it +function getLegacyReactProperty(parsed: any, key: string): string | undefined { + const entries: string[] = parsed["project.ext.react"] || []; + const entry = entries.map((prop) => prop.replace(/\/\/.*$/gm, "").trim()).find((prop) => prop.startsWith(`${key}:`)); + return entry?.slice(key.length + 1).trim().replace(/^["']|["']$/g, ""); +} + +async function parseGroovyDslVersion(buildFile: string): Promise { + const parsed = await parseGroovyDslBuildFile(buildFile); // g2js yields an array when the file contains multiple 'android' blocks. const androidBlocks: any[] = Array.isArray(parsed.android) ? parsed.android : [parsed.android]; const defaultConfig = androidBlocks.find((block) => block?.defaultConfig)?.defaultConfig; @@ -135,7 +186,7 @@ function resolveAppVersion(versionName: string | null, buildFile: string): strin // A value that isn't valid semver and doesn't start with a digit is a property reference. const isPropertyRef = !isValidVersion(versionName) && !/^\d/.test(versionName); - const appVersion = isPropertyRef ? lookupGradleProperty(versionName.replace("project.", "")) : versionName; + const appVersion = isPropertyRef ? readGradleProperty(versionName.replace("project.", ""), buildFile) : versionName; if (!appVersion || !isValidVersion(appVersion)) { throw new Error( @@ -146,19 +197,9 @@ function resolveAppVersion(versionName: string | null, buildFile: string): strin return appVersion; } -function resolveBuildNumber(versionCode: string | number | null): string | undefined { +function resolveBuildNumber(versionCode: string | number | null, buildFile: string): string | undefined { const text = versionCode?.toString(); if (!text) return undefined; // A non-numeric value is a reference to a Gradle property (e.g. "project.versionCode"). - return (/^\d+$/.test(text) ? text : lookupGradleProperty(text.replace("project.", ""))) || undefined; -} - -function lookupGradleProperty(key: string): string | undefined { - const files = [path.join("android", "app", "gradle.properties"), path.join("android", "gradle.properties")]; - for (const file of files) { - if (!fileExists(file)) continue; - // The properties parser type-converts values (e.g. "2" becomes a number) — normalize back to string. - const value = properties.parse(fs.readFileSync(file, "utf8"))?.[key]?.toString(); - if (value) return value; - } + return (/^\d+$/.test(text) ? text : readGradleProperty(text.replace("project.", ""), buildFile)) || undefined; } diff --git a/script/utils/hermes-utils.ts b/script/utils/hermes-utils.ts new file mode 100644 index 0000000..4bf1e57 --- /dev/null +++ b/script/utils/hermes-utils.ts @@ -0,0 +1,111 @@ +// Hermes detection, hermesc lookup and bytecode version checks + +import * as childProcess from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as chalk from "chalk"; +import { coerce, gte } from "semver"; + +import * as cli from "../types/cli"; +import { log } from "../command-executor"; +import { fileExists } from "./file-utils"; +import { getGradleHermesCommand, getGradleHermesEnabled, readGradleProperty } from "./gradle-utils"; +import { getReactNativePackagePath, getReactNativeVersion, getXcodeDotEnvValue } from "../react-native-utils"; + +const OS_BIN = ({ win32: "win64-bin", darwin: "osx-bin" } as Record)[process.platform] ?? "linux64-bin"; + +function exe(name: string): string { + return process.platform === "win32" ? `${name}.exe` : name; +} + +export async function isHermesEnabled(command: cli.IReleaseReactCommand, platform = command.platform.toLowerCase()): Promise { + if (command.useHermes) return true; + const enabled = platform === "android" ? await getGradleHermesEnabled(command.gradleFile) : getIosHermesEnabled(command.podFile); + // Hermes is the default engine since React Native 0.70 + return enabled ?? gte(coerce(getReactNativeVersion()) ?? "0.0.0", "0.70.0"); +} + +// pod install writes USE_HERMES into the Xcode project since RN 0.75; older apps set :hermes_enabled in the Podfile +function getIosHermesEnabled(podFile = path.join("ios", "Podfile")): boolean | undefined { + const iosDir = path.dirname(podFile); + const xcodeProjects = fs.existsSync(iosDir) ? fs.readdirSync(iosDir).filter((file) => file.endsWith(".xcodeproj")) : []; + const pbxprojs = xcodeProjects.map((project) => path.join(iosDir, project, "project.pbxproj")); + const value = + matchInFiles(pbxprojs, /\bUSE_HERMES = "?(true|false)"?;/) ?? + matchInFiles([podFile], /^[^#\n]*:?hermes_enabled\s*(?:=>|:)\s*(true|false)/m); + return value === undefined ? undefined : value === "true"; +} + +function matchInFiles(files: string[], pattern: RegExp): string | undefined { + for (const file of files) { + const match = fileExists(file) && pattern.exec(fs.readFileSync(file, "utf8")); + if (match) return match[1]; + } + return undefined; +} + +/** The hermesc RN's build uses: Gradle plugin PathUtils.detectOSAwareHermesCommand, iOS react-native-xcode.sh */ +export async function findHermesc(platform: string, gradleFile?: string): Promise { + const configured = + platform === "ios" ? getXcodeDotEnvValue("HERMES_CLI_PATH") : getGradleHermesCommand(gradleFile)?.replace("%OS-BIN%", OS_BIN); + // Values referencing Xcode variables like $PODS_ROOT can't be resolved here + if (configured && !configured.includes("$")) return configured; + + // RN <= 0.82 ships sdks/hermesc, 0.83+ hermes-compiler. On 0.82, hermes-compiler is an empty placeholder unless + // the app opts into Hermes V1 (Android also needs hermesV1Enabled) + const reactNativePath = getReactNativePackagePath(); + const bundledHermesc = path.join(reactNativePath, "sdks", "hermesc", OS_BIN, exe("hermesc")); + const hermesCompiler = resolvePackageFile("hermes-compiler", reactNativePath, "hermesc", OS_BIN, exe("hermesc")); + const preferBundled = platform === "android" && readGradleProperty("hermesV1Enabled", gradleFile) !== "true"; + + const candidates = [ + ...(preferBundled ? [bundledHermesc, hermesCompiler] : [hermesCompiler, bundledHermesc]), + // RN < 0.69: hermes-engine (binary named "hermes" before 0.64) or hermesvm + resolvePackageFile("hermes-engine", reactNativePath, OS_BIN, exe("hermesc")), + resolvePackageFile("hermes-engine", reactNativePath, OS_BIN, exe("hermes")), + resolvePackageFile("hermesvm", reactNativePath, OS_BIN, exe("hermes")), + ]; + const hermesc = candidates.find((candidate) => candidate && fileExists(candidate)); + if (hermesc) return hermesc; + + const setting = platform === "ios" ? "HERMES_CLI_PATH" : "react.hermesCommand"; + throw new Error(`Unable to find hermesc. Set ${setting} to the path of the hermesc binary.`); +} + +// Resolved from react-native, since they may not be hoisted +function resolvePackageFile(packageName: string, reactNativePath: string, ...segments: string[]): string | undefined { + try { + const packageJson = require.resolve(`${packageName}/package.json`, { paths: [reactNativePath] }); + return path.join(path.dirname(packageJson), ...segments); + } catch { + return undefined; + } +} + +// Hermes bytecode header (BytecodeFileFormat.h): uint64 magic, uint32 version, little-endian +const BYTECODE_MAGIC = BigInt("0x1F1903C103BC1FC6"); + +function readBytecodeVersion(bytecode: Buffer): number | undefined { + return bytecode.length >= 12 && bytecode.readBigUInt64LE(0) === BYTECODE_MAGIC ? bytecode.readUInt32LE(8) : undefined; +} + +/** Compiles empty input and reads the version from the emitted header */ +export function getHermescBytecodeVersion(hermesc: string): number | undefined { + const result = childProcess.spawnSync(hermesc, ["-emit-binary", "-"], { input: "", timeout: 30000 }); + return result.status === 0 ? readBytecodeVersion(result.stdout) : undefined; +} + +/** -base-bytecode fails on another bytecode version; the base only shrinks diffs, so it's safe to skip */ +export function isBaseBytecodeCompatible(baseBytecode: string, hermesc: string): boolean { + const baseVersion = readBytecodeVersion(fs.readFileSync(baseBytecode)); + const hermescVersion = getHermescBytecodeVersion(hermesc); + if (baseVersion !== undefined && baseVersion === hermescVersion) return true; + + const warning = + baseVersion === undefined || hermescVersion === undefined + ? "Unable to compare Hermes bytecode versions, skipping -base-bytecode." + : `Base release is Hermes bytecode v${baseVersion}, local compiler produces v${hermescVersion}. ` + + `Skipping -base-bytecode. Binaries on v${baseVersion} can't run this update.`; + log(chalk.yellow(`\nWarning: ${warning}\n`)); + return false; +} diff --git a/test/hermes-bytecode.ts b/test/hermes-bytecode.ts index 6d89758..d0c83d2 100644 --- a/test/hermes-bytecode.ts +++ b/test/hermes-bytecode.ts @@ -4,7 +4,7 @@ import * as os from "os"; import * as path from "path"; import * as sinon from "sinon"; import * as cmdexec from "../script/command-executor"; -import { getHermesCompilerBytecodeVersion, resolveHermesBaseBytecode } from "../script/react-native-utils"; +import { getHermescBytecodeVersion, isBaseBytecodeCompatible } from "../script/utils/hermes-utils"; function hermesHeader(version: number): Buffer { const header = Buffer.alloc(128); @@ -24,12 +24,17 @@ describe("Hermes base bytecode", () => { return bundle; } + // A stand-in hermesc: a node script whose stdout is what `hermesc -emit-binary -` would print function createCompiler(script: string): string { const compiler = path.join(testDirectory, "hermesc"); fs.writeFileSync(compiler, `#!/usr/bin/env node\n${script}\n`, { mode: 0o755 }); return compiler; } + function compilerEmitting(version: number): string { + return createCompiler(`process.stdout.write(Buffer.from("${hermesHeader(version).toString("hex")}", "hex"));`); + } + beforeEach(() => { testDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "revopush-hermes-")); sandbox = sinon.createSandbox(); @@ -42,34 +47,32 @@ describe("Hermes base bytecode", () => { }); it("reads the compiler bytecode version from the bytecode it emits", () => { - const compiler = createCompiler(`process.stdout.write(Buffer.from("${hermesHeader(98).toString("hex")}", "hex"));`); - assert.strictEqual(getHermesCompilerBytecodeVersion(compiler), 98); + assert.strictEqual(getHermescBytecodeVersion(compilerEmitting(98)), 98); }); - it("returns null when the compiler fails or emits something other than Hermes bytecode", () => { - assert.strictEqual(getHermesCompilerBytecodeVersion(createCompiler("process.exit(1);")), null); - assert.strictEqual(getHermesCompilerBytecodeVersion(createCompiler(`process.stdout.write("var a;");`)), null); - assert.strictEqual(getHermesCompilerBytecodeVersion(path.join(testDirectory, "missing-hermesc")), null); + it("returns undefined when the compiler fails or emits something other than Hermes bytecode", () => { + assert.strictEqual(getHermescBytecodeVersion(createCompiler("process.exit(1);")), undefined); + assert.strictEqual(getHermescBytecodeVersion(createCompiler(`process.stdout.write("var a;");`)), undefined); + assert.strictEqual(getHermescBytecodeVersion(path.join(testDirectory, "missing-hermesc")), undefined); }); - it("keeps the base bytecode when versions match", () => { - const bundle = createBundle(hermesHeader(96)); - assert.strictEqual(resolveHermesBaseBytecode(bundle, 96), bundle); + it("uses the base bytecode when versions match", () => { + assert.strictEqual(isBaseBytecodeCompatible(createBundle(hermesHeader(96)), compilerEmitting(96)), true); sinon.assert.notCalled(log); }); it("skips the base bytecode and warns when versions differ", () => { - const bundle = createBundle(hermesHeader(96)); - assert.strictEqual(resolveHermesBaseBytecode(bundle, 98), null); + assert.strictEqual(isBaseBytecodeCompatible(createBundle(hermesHeader(96)), compilerEmitting(98)), false); sinon.assert.calledWithMatch(log, sinon.match(/v96.*v98/s)); }); it("skips the base bytecode when the compiler version is unknown", () => { - assert.strictEqual(resolveHermesBaseBytecode(createBundle(hermesHeader(96)), null), null); + assert.strictEqual(isBaseBytecodeCompatible(createBundle(hermesHeader(96)), createCompiler("process.exit(1);")), false); }); it("skips the base bytecode when the base is not Hermes bytecode", () => { - assert.strictEqual(resolveHermesBaseBytecode(createBundle("__d(function(){});"), 96), null); - assert.strictEqual(resolveHermesBaseBytecode(createBundle(Buffer.from([0xc6, 0x1f])), 96), null); + const compiler = compilerEmitting(96); + assert.strictEqual(isBaseBytecodeCompatible(createBundle("__d(function(){});"), compiler), false); + assert.strictEqual(isBaseBytecodeCompatible(createBundle(Buffer.from([0xc6, 0x1f])), compiler), false); }); }); diff --git a/test/hermes-command.ts b/test/hermes-command.ts new file mode 100644 index 0000000..103d054 --- /dev/null +++ b/test/hermes-command.ts @@ -0,0 +1,206 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as sinon from "sinon"; +import * as cmdexec from "../script/command-executor"; +import { findHermesc, isHermesEnabled } from "../script/utils/hermes-utils"; +import { getAndroidVersionInfo } from "../script/utils/gradle-utils"; + +const OS_BIN = process.platform === "darwin" ? "osx-bin" : process.platform === "win32" ? "win64-bin" : "linux64-bin"; +const HERMESC = process.platform === "win32" ? "hermesc.exe" : "hermesc"; + +describe("Hermes command resolution", () => { + let originalCwd: string; + let originalEnv: NodeJS.ProcessEnv; + let projectDirectory: string; + + function createFile(relativePath: string, contents = ""): string { + const file = path.join(projectDirectory, relativePath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + return file; + } + + const bundledHermesc = () => createFile(`node_modules/react-native/sdks/hermesc/${OS_BIN}/${HERMESC}`); + const hermesCompiler = (root = "node_modules") => { + createFile(`${root}/hermes-compiler/package.json`, JSON.stringify({ name: "hermes-compiler", version: "1.0.0" })); + return createFile(`${root}/hermes-compiler/hermesc/${OS_BIN}/${HERMESC}`); + }; + + beforeEach(() => { + originalCwd = process.cwd(); + originalEnv = { ...process.env }; + delete process.env.HERMES_CLI_PATH; + projectDirectory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "revopush-hermesc-"))); + createFile("node_modules/react-native/package.json", JSON.stringify({ name: "react-native", version: "0.83.0" })); + createFile("android/app/build.gradle", "react {\n}\n"); + createFile("android/gradle.properties", "hermesEnabled=true\n"); + process.chdir(projectDirectory); + }); + + afterEach(() => { + process.chdir(originalCwd); + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); + fs.rmSync(projectDirectory, { recursive: true, force: true }); + }); + + it("resolves a literal hermesCommand from the react block against the project root", async () => { + bundledHermesc(); + createFile("android/app/build.gradle", 'react {\n hermesCommand = "custom/%OS-BIN%/hermesc"\n}\n'); + assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "custom", OS_BIN, "hermesc")); + }); + + it("expands $rootDir in hermesCommand", async () => { + bundledHermesc(); + createFile("android/app/build.gradle", 'react {\n hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"\n}\n'); + assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "android", "my-custom-hermesc", "bin", "hermesc")); + }); + + it("reads a literal hermesCommand from a Kotlin DSL build script", async () => { + bundledHermesc(); + fs.rmSync(path.join(projectDirectory, "android", "app", "build.gradle")); + createFile("android/app/build.gradle.kts", 'react {\n hermesCommand.set("custom/%OS-BIN%/hermesc")\n}\n'); + assert.strictEqual(await findHermesc("android"), path.join(projectDirectory, "custom", OS_BIN, "hermesc")); + }); + + it("ignores a commented-out hermesCommand", async () => { + const bundled = bundledHermesc(); + createFile("android/app/build.gradle", 'react {\n // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"\n}\n'); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), bundled); + }); + + it("ignores a computed hermesCommand expression such as Expo's", async () => { + const expected = bundledHermesc(); + createFile( + "android/app/build.gradle", + 'react {\n hermesCommand = new File(["node", "--print", "require.resolve(\'react-native/package.json\')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"\n}\n' + ); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), expected); + }); + + it("ignores Expo SDK 55's hermesCommand expression without a warning", async () => { + const expected = hermesCompiler(); + createFile( + "android/app/build.gradle", + 'react {\n hermesCommand = new File(["node", "--print", "require.resolve(\'hermes-compiler/package.json\', { paths: [require.resolve(\'react-native/package.json\')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"\n}\n' + ); + const log = sinon.stub(cmdexec, "log"); + try { + assert.strictEqual(fs.realpathSync(await findHermesc("android")), expected); + sinon.assert.notCalled(log); + } finally { + log.restore(); + } + }); + + it("prefers the bundled hermesc unless Hermes V1 is enabled", async () => { + const bundled = bundledHermesc(); + const compiler = hermesCompiler(); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), bundled); + + createFile("android/gradle.properties", "hermesEnabled=true\nhermesV1Enabled=true\n"); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), compiler); + }); + + it("resolves hermes-compiler through react-native when it is not hoisted", async () => { + const compiler = hermesCompiler("node_modules/react-native/node_modules"); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), compiler); + }); + + it("uses HERMES_CLI_PATH on iOS", async () => { + hermesCompiler(); + process.env.HERMES_CLI_PATH = "/custom/hermesc"; + assert.strictEqual(await findHermesc("ios"), "/custom/hermesc"); + }); + + it("reads HERMES_CLI_PATH from ios/.xcode.env.local", async () => { + createFile("ios/.xcode.env", "export HERMES_CLI_PATH=/env/hermesc\n"); + createFile("ios/.xcode.env.local", "export HERMES_CLI_PATH=/xcode/hermesc\n"); + assert.strictEqual(await findHermesc("ios"), "/xcode/hermesc"); + }); + + it("ignores a HERMES_CLI_PATH that references Xcode variables", async () => { + const compiler = hermesCompiler(); + createFile("ios/.xcode.env", 'export HERMES_CLI_PATH="$PODS_ROOT/custom/hermesc"\n'); + assert.strictEqual(fs.realpathSync(await findHermesc("ios")), compiler); + }); + + it("prefers hermes-compiler on iOS, skipping RN 0.82's empty placeholder package", async () => { + const bundled = bundledHermesc(); + createFile("node_modules/hermes-compiler/package.json", JSON.stringify({ name: "hermes-compiler", version: "0.0.0" })); + assert.strictEqual(fs.realpathSync(await findHermesc("ios")), bundled); + + const compiler = hermesCompiler(); + assert.strictEqual(fs.realpathSync(await findHermesc("ios")), compiler); + }); + + it("finds the hermes binary of RN < 0.64's hermes-engine package", async () => { + createFile("node_modules/hermes-engine/package.json", JSON.stringify({ name: "hermes-engine", version: "0.4.0" })); + const hermes = createFile(`node_modules/hermes-engine/${OS_BIN}/${process.platform === "win32" ? "hermes.exe" : "hermes"}`); + assert.strictEqual(fs.realpathSync(await findHermesc("android")), hermes); + }); + + it("fails with the setting to use when no hermesc is found", async () => { + await assert.rejects(findHermesc("android"), /Set react\.hermesCommand/); + await assert.rejects(findHermesc("ios"), /Set HERMES_CLI_PATH/); + }); + + describe("Hermes enabled detection", () => { + const command = (platform: string): any => ({ platform }); + + it("reads hermesEnabled from gradle.properties", async () => { + createFile("android/gradle.properties", "hermesEnabled=false\n"); + assert.strictEqual(await isHermesEnabled(command("android")), false); + }); + + it("reads enableHermes after a commented project.ext.react entry", async () => { + createFile("android/gradle.properties", ""); + createFile("node_modules/react-native/package.json", JSON.stringify({ name: "react-native", version: "0.68.0" })); + createFile( + "android/app/build.gradle", + 'project.ext.react = [\n entryFile: "index.js", // entry\n enableHermes: true\n]\n' + ); + assert.strictEqual(await isHermesEnabled(command("android")), true); + }); + + it("reads enableHermes from RN < 0.71's project.ext.react", async () => { + createFile("android/gradle.properties", ""); + createFile("android/app/build.gradle", "project.ext.react = [\n enableHermes: false\n]\n"); + assert.strictEqual(await isHermesEnabled(command("android")), false); + }); + + it("reads hermes_enabled from the Podfile, ignoring comments", async () => { + createFile("ios/Podfile", "# :hermes_enabled => true\nuse_react_native!(\n :hermes_enabled => false\n)\n"); + assert.strictEqual(await isHermesEnabled(command("ios")), false); + }); + + it("reads the scoped react.hermesEnabled property", async () => { + createFile("android/gradle.properties", "react.hermesEnabled=false\n"); + assert.strictEqual(await isHermesEnabled(command("android")), false); + }); + + it("reads the USE_HERMES build setting pod install writes into the Xcode project", async () => { + createFile("ios/Podfile", "use_react_native!(\n :hermes_enabled => true\n)\n"); + createFile("ios/App.xcodeproj/project.pbxproj", "buildSettings = {\n\t\t\t\tUSE_HERMES = false;\n};\n"); + assert.strictEqual(await isHermesEnabled(command("ios")), false); + }); + + it("defaults to Hermes on RN 0.70+", async () => { + createFile("android/gradle.properties", ""); + assert.strictEqual(await isHermesEnabled(command("android")), true); + }); + }); + + it("resolves a versionName that references a Gradle property", async () => { + createFile("android/gradle.properties", "appVersion=1.2.3\n"); + createFile( + "android/app/build.gradle", + "android {\n defaultConfig {\n versionName project.appVersion\n versionCode 7\n }\n}\n" + ); + assert.deepStrictEqual(await getAndroidVersionInfo(undefined), { appVersion: "1.2.3", buildNumber: "7" }); + }); +});