diff --git a/README.md b/README.md
index 5104397fa..dd3a57417 100644
--- a/README.md
+++ b/README.md
@@ -706,7 +706,7 @@ The following is a list of all the configuration properties the debugger accepts
| `expoHostType` | The connection type to be used on Expo debugging to communicate with a device or an emulator. Possible values:
- `tunnel` - allows to deploy and debug an application by means of Expo cloud services
- `lan` - allows to deploy and install an application via your LAN
- `local` - allows to debug an application on an emulator or an Android device without network connection
| `string` | `lan` |
| `env` | Environment variables passed to the debugger and `react-native run-` command | `object` | `{}` |
| `envFile` | Absolute path to a file containing environment variable definitions | `string` | `${workspaceFolder}/.env` |
-| `variant` | A variant to be passed to `react-native run-android`, e.g. use `devDebug` to specify `--variant=devDebug` | `string` | n/a |
+| `variant` | Android build variant to run, such as `debug` or `devDebug`. The extension uses `--mode` for CLI 12 and newer, and `--variant` for older versions. Non-empty `runArguments` override this setting. | `string` | n/a |
| `scheme` | A scheme name to be passed to `react-native run-ios`, e.g. `devDebug` to specify `--scheme=devDebug` | `string` | n/a |
| `productName` | iOS bundle display name e.g. `AwesomeProject` value means that the extension will search for `AwesomeProject.app` bundle | `string` | n/a |
| `jsDebugTrace` | Enable trace collection for depended extension `vscode-js-debug` | `boolean` | n/a |
diff --git a/package.nls.json b/package.nls.json
index 94fec838c..77a5f9d4a 100644
--- a/package.nls.json
+++ b/package.nls.json
@@ -77,7 +77,7 @@
"reactNative.launch.runArguments.description": "Run arguments to be passed to 'react-native run-' command (override all other configuration params)",
"reactNative.launch.env.description": "Environment variables passed to the debugger and 'react-native run-' command.",
"reactNative.launch.envFile.description": "Absolute path to a file containing environment variable definitions.",
- "reactNative.launch.variant.description": "A variant to be passed to 'react-native run-android', e.g. use 'devDebug' to specify '--variant=devDebug'",
+ "reactNative.launch.variant.description": "Android build variant to run, such as 'debug' or 'devDebug'. The extension selects '--mode' or '--variant' according to the installed CLI version.",
"reactNative.launch.scheme.description": "A scheme name to be passed to 'react-native run-ios', e.g. 'devDebug' to specify '--scheme=devDebug'",
"reactNative.launch.productName.description": "iOS bundle display name e.g. 'AwesomeProject' value means that extension will search for 'AwesomeProject.app' bundle",
"reactNative.launch.skipFiles.description": "An array of file or folder names, or glob patterns, to skip when debugging.",
diff --git a/src/common/customRequire.ts b/src/common/customRequire.ts
index f5344935e..96a2f8d65 100644
--- a/src/common/customRequire.ts
+++ b/src/common/customRequire.ts
@@ -6,6 +6,7 @@
interface CustomRequire extends NodeRequireFunction {
cache: any;
+ resolve: RequireResolve;
}
// Portion of code was taken from https://github.com/sindresorhus/ow/blob/d62a06c192b892d504887f0b97fdc842e8cbf862/source/utils/node/require.ts
diff --git a/src/common/reactNativeProjectHelper.ts b/src/common/reactNativeProjectHelper.ts
index c1bb0545d..9ebb1cc23 100644
--- a/src/common/reactNativeProjectHelper.ts
+++ b/src/common/reactNativeProjectHelper.ts
@@ -46,14 +46,36 @@ export class ReactNativeProjectHelper {
}
public static isAndroidHermesEnabled(projectRoot: string): boolean {
- const buildGradlePath = path.join(projectRoot, "android", "app", "build.gradle");
- if (!projectRoot || !fs.existsSync(buildGradlePath)) {
+ if (!projectRoot) {
return false;
}
+ const gradlePropertiesPath = path.join(projectRoot, "android", "gradle.properties");
+ if (fs.existsSync(gradlePropertiesPath)) {
+ const properties = fs.readFileSync(gradlePropertiesPath, "utf-8");
+ let hermesEnabled: boolean | undefined;
+ for (const line of properties.split(/\r?\n/)) {
+ const match = line.match(/^[\t ]*hermesEnabled[\t ]*[:=][\t ]*(.*)$/);
+ if (match) {
+ hermesEnabled = match[1].trim().toLowerCase() === "true";
+ }
+ }
+ if (hermesEnabled !== undefined) {
+ return hermesEnabled;
+ }
+ }
+
+ const buildGradlePath = path.join(projectRoot, "android", "app", "build.gradle");
+ if (!fs.existsSync(buildGradlePath)) {
+ return false;
+ }
const buildGradleContent = fs.readFileSync(buildGradlePath, "utf-8");
- const hermesEnabled = /enableHermes\s*:\s*true/.test(buildGradleContent);
- return hermesEnabled;
+ // Mask strings as well as comments so examples and comment markers in strings are ignored.
+ const configuration = buildGradleContent.replace(
+ /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\/\/[^\n\r]*|\/\*[\S\s]*?\*\//g,
+ " ",
+ );
+ return /\benableHermes\s*:\s*true\b/.test(configuration);
}
public static isIOSHermesEnabled(projectRoot: string): boolean {
diff --git a/src/extension/android/androidPlatform.ts b/src/extension/android/androidPlatform.ts
index c75a1bf2d..856677d6b 100644
--- a/src/extension/android/androidPlatform.ts
+++ b/src/extension/android/androidPlatform.ts
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for details.
+import * as fs from "fs";
+import * as path from "path";
import * as semver from "semver";
import * as nls from "vscode-nls";
import { MobilePlatformDeps, TargetType } from "../generalPlatform";
@@ -15,6 +17,7 @@ import { notNullOrUndefined } from "../../common/utils";
import { PromiseUtil } from "../../common/node/promise";
import { GeneralMobilePlatform } from "../generalMobilePlatform";
import { ProjectVersionHelper } from "../../common/projectVersionHelper";
+import customRequire from "../../common/customRequire";
import { LogCatMonitorManager } from "./logCatMonitorManager";
import { AndroidTarget, AndroidTargetManager } from "./androidTargetManager";
import { AdbHelper, AndroidAPILevel } from "./adb";
@@ -306,7 +309,7 @@ export class AndroidPlatform extends GeneralMobilePlatform {
runArguments = this.runOptions.runArguments;
} else {
if (this.runOptions.variant) {
- runArguments.push("--variant", this.runOptions.variant);
+ runArguments.push(this.getVariantOptionName(), this.runOptions.variant);
}
if (this.runOptions.target) {
if (
@@ -321,6 +324,69 @@ export class AndroidPlatform extends GeneralMobilePlatform {
return runArguments;
}
+ private getVariantOptionName(): "--mode" | "--variant" {
+ const searchPaths = [
+ this.runOptions.projectRoot,
+ this.runOptions.nodeModulesRoot,
+ path.join(this.runOptions.nodeModulesRoot, "node_modules", "react-native"),
+ path.join(
+ this.runOptions.nodeModulesRoot,
+ "node_modules",
+ "@react-native-community",
+ "cli",
+ ),
+ ];
+
+ try {
+ for (const packageName of [
+ "@react-native-community/cli-platform-android",
+ "@react-native-community/cli",
+ "react-native",
+ ]) {
+ let packageJsonPath: string;
+ try {
+ packageJsonPath = customRequire.resolve(`${packageName}/package.json`, {
+ paths: searchPaths,
+ });
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "MODULE_NOT_FOUND") {
+ continue;
+ }
+ throw error;
+ }
+
+ const packageJson: unknown = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
+ const version =
+ packageJson && typeof packageJson === "object" && "version" in packageJson
+ ? packageJson.version
+ : undefined;
+ if (typeof version !== "string" || !semver.valid(version)) {
+ throw new Error(`Invalid package version in ${packageJsonPath}`);
+ }
+
+ if (packageName === "react-native") {
+ // Older React Native releases bundled the Android CLI.
+ if (semver.lt(version, "0.60.0")) {
+ return "--variant";
+ }
+ break;
+ }
+
+ return semver.gte(version, "12.0.0") ? "--mode" : "--variant";
+ }
+
+ throw new Error("Cannot locate the project's Android CLI package");
+ } catch (error) {
+ throw new Error(
+ localize(
+ "CannotDetermineAndroidVariantOption",
+ "Cannot determine the Android build variant option: {0}. Check the project's CLI installation or specify the supported option explicitly in 'runArguments'.",
+ error instanceof Error ? error.message : String(error),
+ ),
+ );
+ }
+ }
+
public dispose(): void {
if (this.logCatMonitor) {
LogCatMonitorManager.delMonitor(this.logCatMonitor.deviceId);
diff --git a/src/extension/commands/enableHermes.ts b/src/extension/commands/enableHermes.ts
index 9f4d2eb98..3753a6299 100644
--- a/src/extension/commands/enableHermes.ts
+++ b/src/extension/commands/enableHermes.ts
@@ -47,7 +47,7 @@ export class EnableHermes extends Command {
}
const podfileContent = fs.readFileSync(podfilePath, "utf-8");
- const hermesMatches = podfileContent.match(/#?\s*:hermes_enabled\s*=>\s*\w*/);
+ const hermesMatches = podfileContent.match(/#?\s*:hermes_enabled\s*=>\s*(\w*)/);
const regex = /(use_react_native!\s*\([^)]*?)(\n\s*\))/;
const rnMatches = podfileContent.match(regex);
const nodeModulesRoot: string =
@@ -59,6 +59,7 @@ export class EnableHermes extends Command {
);
if (hermesMatches && !hermesMatches[0].startsWith("#")) {
+ if (hermesMatches[1] === isHermesEnabled) return;
const updatedHermes = podfileContent.replace(
/:hermes_enabled\s*=>\s*\w+/,
`:hermes_enabled => ${isHermesEnabled}`,
diff --git a/src/extension/commands/installExpoGoApplication.ts b/src/extension/commands/installExpoGoApplication.ts
index ae90e9b7b..751a638f8 100644
--- a/src/extension/commands/installExpoGoApplication.ts
+++ b/src/extension/commands/installExpoGoApplication.ts
@@ -9,6 +9,7 @@ import * as vscode from "vscode";
import * as nls from "vscode-nls";
import { OutputChannelLogger } from "../log/OutputChannelLogger";
import { ErrorHelper } from "../../common/error/errorHelper";
+import { NestedError } from "../../common/error/internalError";
import { InternalErrorCode } from "../../common/error/internalErrorCode";
import { downloadExpoGo } from "../../common/downloadHelper";
import { installAndroidApplication, installiOSApplication } from "../../common/installHelper";
@@ -71,9 +72,11 @@ export class InstallExpoGoApplication extends Command {
if (!fs.existsSync(fileName)) {
try {
await downloadExpoGo(targetUrl, fileName);
- } catch {
- throw new Error(
+ } catch (error) {
+ throw new NestedError(
+ InternalErrorCode.FailedToInstallExpoGo,
localize("FailedToDownloadExpoGo", "Failed to download Expo Go."),
+ error,
);
}
}
@@ -81,9 +84,11 @@ export class InstallExpoGoApplication extends Command {
if (installItem === "Auto") {
try {
await installAndroidApplication(this.project, fileName);
- } catch {
- throw new Error(
+ } catch (error) {
+ throw new NestedError(
+ InternalErrorCode.FailedToInstallExpoGo,
localize("FailedToInstallExpoGo", "Failed to install Expo Go."),
+ error,
);
}
} else {
@@ -123,9 +128,11 @@ export class InstallExpoGoApplication extends Command {
.getPackager()
.getProjectPath()}/expogo_${iOSClientVersion}.tar.gz`,
);
- } catch {
- throw new Error(
+ } catch (error) {
+ throw new NestedError(
+ InternalErrorCode.FailedToInstallExpoGo,
localize("FailedToDownloadExpoGo", "Failed to download Expo Go."),
+ error,
);
}
}
@@ -133,9 +140,11 @@ export class InstallExpoGoApplication extends Command {
if (installItem === "Auto" && os.platform() === "darwin") {
try {
await installiOSApplication(this.project, tarFile);
- } catch {
- throw new Error(
+ } catch (error) {
+ throw new NestedError(
+ InternalErrorCode.FailedToInstallExpoGo,
localize("FailedToInstallExpoGo", "Failed to install Expo Go."),
+ error,
);
}
} else {
diff --git a/src/extension/commands/openEASProject.ts b/src/extension/commands/openEASProject.ts
index aa5309097..7760b7c87 100644
--- a/src/extension/commands/openEASProject.ts
+++ b/src/extension/commands/openEASProject.ts
@@ -30,28 +30,25 @@ export class OpenEASProject extends ReactNativeCommand {
if (isExpo) {
try {
- let id = null;
- await expoHelper.getExpoEasProjectId().then(result => {
- id = result;
- });
- let owner = null;
- await expoHelper.getExpoEasProjectOwner().then(result => {
- owner = result;
- });
- let name = null;
- await expoHelper.getExpoEasProjectName().then(result => {
- name = result;
- });
- if (id === null || owner === null) {
+ const id = await expoHelper.getExpoEasProjectId();
+ const owner = await expoHelper.getExpoEasProjectOwner();
+ const slug = await expoHelper.getExpoEasProjectSlug();
+ if (id == null || owner == null) {
const error = localize(
"ExpoProjectNotLinkToEAS",
"Your app not link to EAS project. Please run 'eas init' firstly to bind your app to EAS project.",
);
void vscode.window.showErrorMessage(error);
logger.error(error);
- } else if (name !== null) {
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- const url = `https://expo.dev/accounts/${owner}/projects/${name}`;
+ } else if (!slug) {
+ const error = localize(
+ "ExpoProjectSlugMissing",
+ "Unable to open the EAS project page because expo.slug is missing in app.json.",
+ );
+ void vscode.window.showErrorMessage(error);
+ logger.error(error);
+ } else {
+ const url = `https://expo.dev/accounts/${owner}/projects/${slug}`;
await vscode.env.openExternal(vscode.Uri.parse(url));
}
} catch {
diff --git a/src/extension/commands/setNewArch.ts b/src/extension/commands/setNewArch.ts
index f7ade67ce..b39d3e31d 100644
--- a/src/extension/commands/setNewArch.ts
+++ b/src/extension/commands/setNewArch.ts
@@ -26,12 +26,13 @@ export class SetNewArch extends Command {
const type = await vscode.window.showQuickPick(["Android", "iOS"], {
placeHolder: "Select type for mobile OS",
});
+ if (type === undefined) return;
const isNewArchEnabled = await vscode.window.showQuickPick(["true", "false"], {
placeHolder: "Whether to enable New Architecture",
});
const projectRoot = this.project.getPackager().getProjectPath();
- if (type === undefined || isNewArchEnabled === undefined) return;
+ if (isNewArchEnabled === undefined) return;
if (type === "iOS") {
const podfilePath = path.join(projectRoot, "ios", "Podfile");
let podfileContent = fs.readFileSync(podfilePath, "utf-8");
diff --git a/src/extension/exponent/exponentHelper.ts b/src/extension/exponent/exponentHelper.ts
index 75dfeeb4d..71ff056fa 100644
--- a/src/extension/exponent/exponentHelper.ts
+++ b/src/extension/exponent/exponentHelper.ts
@@ -42,7 +42,7 @@ const NGROK_PACKAGE = "@expo/ngrok";
interface ExpoAppConfig {
expo?: {
owner?: string;
- name?: string;
+ slug?: string;
extra?: {
eas?: {
projectId?: string;
@@ -529,9 +529,9 @@ require('${entryPoint}');`;
return config?.expo?.extra?.eas?.projectId ?? null;
}
- public async getExpoEasProjectName(): Promise {
+ public async getExpoEasProjectSlug(): Promise {
const config = await this.getAppJsonConfig();
- return config?.expo?.name ?? null;
+ return config?.expo?.slug ?? null;
}
private async getAppJsonConfig(): Promise {
diff --git a/test/cdp-proxy/debuggerEndpointHelper.test.ts b/test/cdp-proxy/debuggerEndpointHelper.test.ts
index 3e9c456ef..e8814f6ad 100644
--- a/test/cdp-proxy/debuggerEndpointHelper.test.ts
+++ b/test/cdp-proxy/debuggerEndpointHelper.test.ts
@@ -39,7 +39,14 @@ suite("debuggerEndpointHelper", function () {
const result = await helper.getWSEndpoint(`http://127.0.0.1:${address.port}`);
assert.strictEqual(result, endpoint);
- assert.strictEqual(setEncodingSpy.called, false);
+ assert.strictEqual(
+ setEncodingSpy.thisValues.some(
+ response =>
+ response.req?.getHeader("host") === `127.0.0.1:${address.port}` &&
+ response.req.path === "/json/version",
+ ),
+ false,
+ );
} finally {
setEncodingSpy.restore();
await new Promise((resolve, reject) => {
diff --git a/test/common/reactNativeProjectHelper.test.ts b/test/common/reactNativeProjectHelper.test.ts
index 198ecd792..e664fceee 100644
--- a/test/common/reactNativeProjectHelper.test.ts
+++ b/test/common/reactNativeProjectHelper.test.ts
@@ -23,6 +23,11 @@ suite("ReactNativeProjectHelper", function () {
"app",
"build.gradle",
);
+ const gradlePropertiesPath = path.join(
+ sampleReactNativeProjectDir,
+ "android",
+ "gradle.properties",
+ );
suiteSetup(() => {
fsHelper.makeDirectoryRecursiveSync(path.dirname(buildGradleFilePath));
@@ -32,6 +37,103 @@ suite("ReactNativeProjectHelper", function () {
fsHelper.removePathRecursivelySync(path.join(sampleReactNativeProjectDir, "android"));
});
+ teardown(() => {
+ for (const filePath of [buildGradleFilePath, gradlePropertiesPath]) {
+ if (fs.existsSync(filePath)) {
+ fs.unlinkSync(filePath);
+ }
+ }
+ });
+
+ for (const [properties, expected] of [
+ ["hermesEnabled=true", true],
+ ["hermesEnabled=false", false],
+ [" hermesEnabled = true \r\n", true],
+ ["hermesEnabled: true", true],
+ ["hermesEnabled=TRUE", true],
+ ["hermesEnabled=true\nhermesEnabled=false", false],
+ ["hermesEnabled=false\nhermesEnabled=true", true],
+ ["# hermesEnabled=true\n! hermesEnabled=true", false],
+ ["hermesEnabled=trueSuffix", false],
+ ["otherHermesEnabled=true", false],
+ ] as const) {
+ test(`detects Gradle properties ${JSON.stringify(
+ properties,
+ )} as ${expected} without build.gradle`, () => {
+ fs.writeFileSync(gradlePropertiesPath, properties);
+ assert.strictEqual(
+ ReactNativeProjectHelper.isAndroidHermesEnabled(sampleReactNativeProjectDir),
+ expected,
+ );
+ });
+ }
+
+ for (const enabled of [true, false]) {
+ test(`prefers hermesEnabled=${enabled} over the opposite legacy value`, () => {
+ fs.writeFileSync(gradlePropertiesPath, `hermesEnabled=${enabled}`);
+ fs.writeFileSync(
+ buildGradleFilePath,
+ `project.ext.react = [enableHermes: ${!enabled}]`,
+ );
+ assert.strictEqual(
+ ReactNativeProjectHelper.isAndroidHermesEnabled(sampleReactNativeProjectDir),
+ enabled,
+ );
+ });
+ }
+
+ test("falls back to legacy configuration when the property is absent or commented", () => {
+ fs.writeFileSync(
+ gradlePropertiesPath,
+ "android.useAndroidX=true\n# hermesEnabled=false\n! hermesEnabled=false",
+ );
+ fs.writeFileSync(buildGradleFilePath, "project.ext.react = [enableHermes: true]");
+ assert.strictEqual(
+ ReactNativeProjectHelper.isAndroidHermesEnabled(sampleReactNativeProjectDir),
+ true,
+ );
+ });
+
+ for (const content of [
+ "// Repro only: enableHermes: true",
+ "/* enableHermes: true */",
+ "/*\n enableHermes: true,\n*/",
+ "// enableHermes: true\nproject.ext.react = [enableHermes: false]",
+ 'def example = "enableHermes: true"',
+ "def example = 'enableHermes: true'",
+ "project.ext.react = [enableHermes: trueSuffix]",
+ ]) {
+ test(`ignores commented, quoted or invalid legacy configuration ${JSON.stringify(
+ content,
+ )}`, () => {
+ fs.writeFileSync(buildGradleFilePath, content);
+ assert.strictEqual(
+ ReactNativeProjectHelper.isAndroidHermesEnabled(sampleReactNativeProjectDir),
+ false,
+ );
+ });
+ }
+
+ test("preserves active legacy configuration after strings and comments", () => {
+ fs.writeFileSync(
+ buildGradleFilePath,
+ "def url = 'https://example.com'\n/* enableHermes: false */\n" +
+ "project.ext.react = [enableHermes: true] // active configuration",
+ );
+ assert.strictEqual(
+ ReactNativeProjectHelper.isAndroidHermesEnabled(sampleReactNativeProjectDir),
+ true,
+ );
+ });
+
+ test("returns false when the project root or both configuration files are missing", () => {
+ assert.strictEqual(ReactNativeProjectHelper.isAndroidHermesEnabled(""), false);
+ assert.strictEqual(
+ ReactNativeProjectHelper.isAndroidHermesEnabled(sampleReactNativeProjectDir),
+ false,
+ );
+ });
+
test("isAndroidHermesEnabled should return 'true' if Hermes engine is enabled in the build.gradle file", () => {
const buildGradleFileContent =
"project.ext.react = [\nenableHermes: true, // clean and rebuild if changing\n]";
diff --git a/test/extension/android/androidPlatform.test.ts b/test/extension/android/androidPlatform.test.ts
index 8df1bf2a8..361af7975 100644
--- a/test/extension/android/androidPlatform.test.ts
+++ b/test/extension/android/androidPlatform.test.ts
@@ -2,6 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for details.
import * as fs from "fs";
+import * as os from "os";
import * as path from "path";
import { EventEmitter } from "events";
import assert = require("assert");
@@ -20,6 +21,155 @@ import { LogCatMonitorManager } from "../../../src/extension/android/logCatMonit
import { rimrafAsync } from "../../common/utils";
suite("androidPlatform", function () {
+ suite("build variant compatibility", function () {
+ let projectRoot: string;
+
+ setup(() => {
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rn-android-variant-"));
+ });
+
+ teardown(async () => {
+ await rimrafAsync(projectRoot, {});
+ });
+
+ function writePackage(name: string, version: string, root = projectRoot): string {
+ const packagePath = path.join(root, "node_modules", name, "package.json");
+ fs.mkdirSync(path.dirname(packagePath), { recursive: true });
+ fs.writeFileSync(packagePath, JSON.stringify({ name, version }));
+ return packagePath;
+ }
+
+ function createPlatform(options: Partial = {}): AndroidPlatform {
+ return new AndroidPlatform({
+ platform: PlatformType.Android,
+ workspaceRoot: projectRoot,
+ projectRoot,
+ nodeModulesRoot: projectRoot,
+ reactNativeVersions: {
+ reactNativeVersion: "0.87.1",
+ reactNativeWindowsVersion: "",
+ reactNativeMacOSVersion: "",
+ },
+ variant: "debug",
+ ...options,
+ });
+ }
+
+ for (const [version, option] of [
+ ["10.2.2", "--variant"],
+ ["11.3.8", "--variant"],
+ ["12.0.0", "--mode"],
+ ["20.2.0", "--mode"],
+ ]) {
+ test(`should use ${option} for Android CLI ${version}`, () => {
+ writePackage("@react-native-community/cli-platform-android", version);
+ assert.deepStrictEqual(createPlatform().runArguments, [option, "debug"]);
+ });
+ }
+
+ test("should prefer the Android CLI version over the core CLI version", () => {
+ writePackage("@react-native-community/cli", "11.3.8");
+ writePackage("@react-native-community/cli-platform-android", "20.2.0");
+ assert.deepStrictEqual(createPlatform().runArguments, ["--mode", "debug"]);
+ });
+
+ test("should preserve the old Android option even with a newer core CLI", () => {
+ writePackage("@react-native-community/cli", "20.2.0");
+ writePackage("@react-native-community/cli-platform-android", "11.3.8");
+ assert.deepStrictEqual(createPlatform().runArguments, ["--variant", "debug"]);
+ });
+
+ test("should preserve custom variant names and the target", () => {
+ writePackage("@react-native-community/cli-platform-android", "20.2.0");
+ assert.deepStrictEqual(
+ createPlatform({ variant: "stagingDebug", target: "emulator-5554" }).runArguments,
+ ["--mode", "stagingDebug", "--deviceId", "emulator-5554"],
+ );
+ });
+
+ test("should resolve a hoisted Android CLI package", () => {
+ writePackage("@react-native-community/cli-platform-android", "20.2.0");
+ const appRoot = path.join(projectRoot, "apps", "mobile");
+ assert.deepStrictEqual(
+ createPlatform({ projectRoot: appRoot, nodeModulesRoot: appRoot }).runArguments,
+ ["--mode", "debug"],
+ );
+ });
+
+ test("should resolve an Android CLI package nested under the core CLI", () => {
+ const cliRoot = path.join(
+ projectRoot,
+ "node_modules",
+ "@react-native-community",
+ "cli",
+ );
+ writePackage("@react-native-community/cli", "11.3.8");
+ writePackage("@react-native-community/cli-platform-android", "20.2.0", cliRoot);
+ assert.deepStrictEqual(createPlatform().runArguments, ["--mode", "debug"]);
+ });
+
+ test("should resolve an Android CLI package nested under React Native", () => {
+ const rnRoot = path.join(projectRoot, "node_modules", "react-native");
+ writePackage("react-native", "0.73.0");
+ writePackage("@react-native-community/cli-platform-android", "12.0.0", rnRoot);
+ assert.deepStrictEqual(createPlatform().runArguments, ["--mode", "debug"]);
+ });
+
+ test("should support older CLI packages containing Android commands", () => {
+ writePackage("@react-native-community/cli", "1.0.0");
+ assert.deepStrictEqual(createPlatform().runArguments, ["--variant", "debug"]);
+ });
+
+ test("should support legacy React Native with a bundled CLI", () => {
+ writePackage("react-native", "0.57.8");
+ assert.deepStrictEqual(createPlatform().runArguments, ["--variant", "debug"]);
+ });
+
+ test("should not detect the CLI without a variant", () => {
+ writePackage("@react-native-community/cli-platform-android", "invalid");
+ assert.deepStrictEqual(createPlatform({ variant: undefined }).runArguments, []);
+ });
+
+ test("should not detect the CLI for an empty variant", () => {
+ writePackage("@react-native-community/cli-platform-android", "invalid");
+ assert.deepStrictEqual(createPlatform({ variant: "" }).runArguments, []);
+ });
+
+ test("should preserve explicit runArguments without detecting the CLI", () => {
+ writePackage("@react-native-community/cli-platform-android", "invalid");
+ const runArguments = ["--mode", "devDebug"];
+ assert.deepStrictEqual(createPlatform({ runArguments }).runArguments, runArguments);
+ });
+
+ test("should generate the variant option when runArguments is empty", () => {
+ writePackage("@react-native-community/cli-platform-android", "20.2.0");
+ assert.deepStrictEqual(createPlatform({ runArguments: [] }).runArguments, [
+ "--mode",
+ "debug",
+ ]);
+ });
+
+ test("should report missing CLI metadata instead of guessing for modern RN", () => {
+ writePackage("react-native", "0.87.1");
+ assert.throws(() => createPlatform(), /Cannot determine.*runArguments/);
+ });
+
+ test("should report invalid versions instead of falling back to the core CLI", () => {
+ writePackage("@react-native-community/cli-platform-android", "invalid");
+ writePackage("@react-native-community/cli", "20.2.0");
+ assert.throws(() => createPlatform(), /Invalid package version/);
+ });
+
+ test("should report malformed package metadata", () => {
+ const packagePath = writePackage(
+ "@react-native-community/cli-platform-android",
+ "20.2.0",
+ );
+ fs.writeFileSync(packagePath, "{");
+ assert.throws(() => createPlatform(), /Cannot determine.*runArguments/);
+ });
+ });
+
suite("extensionContext", function () {
const projectRoot = path.join(
__dirname,
diff --git a/test/extension/commands/enableHermes.test.ts b/test/extension/commands/enableHermes.test.ts
index 05be61305..baab8826b 100644
--- a/test/extension/commands/enableHermes.test.ts
+++ b/test/extension/commands/enableHermes.test.ts
@@ -205,6 +205,32 @@ suite("enableHermesCommand", function () {
assert.strictEqual(spawnStub.called, false);
});
+ for (const value of ["true", "false"]) {
+ test(`should skip writing and installing pods when iOS Hermes is already ${value}`, async function () {
+ const iosPath = path.join(tempDir, "ios");
+ fs.mkdirSync(iosPath, { recursive: true });
+ fs.writeFileSync(
+ path.join(iosPath, "Podfile"),
+ `use_react_native!(\n :hermes_enabled => ${value}\n)`,
+ );
+ const showQuickPickStub = Sinon.stub();
+ showQuickPickStub.onFirstCall().returns(Promise.resolve("iOS"));
+ showQuickPickStub.onSecondCall().returns(Promise.resolve(value));
+ const writeFileStub = Sinon.stub().returns(Promise.resolve());
+ const spawnStub = Sinon.stub().returns(Promise.resolve());
+ const { EnableHermes } = createCommandModule(
+ showQuickPickStub,
+ writeFileStub,
+ spawnStub,
+ );
+
+ await runCommand(EnableHermes);
+
+ assert.strictEqual(writeFileStub.called, false);
+ assert.strictEqual(spawnStub.called, false);
+ });
+ }
+
test("should update iOS hermes flag and install pods", async function () {
const iosPath = path.join(tempDir, "ios");
const podfilePath = path.join(iosPath, "Podfile");
diff --git a/test/extension/commands/installExpoGoApplication.test.ts b/test/extension/commands/installExpoGoApplication.test.ts
index 36495677f..839a221a3 100644
--- a/test/extension/commands/installExpoGoApplication.test.ts
+++ b/test/extension/commands/installExpoGoApplication.test.ts
@@ -5,6 +5,8 @@ import assert = require("assert");
import { EventEmitter } from "events";
import Sinon = require("sinon");
import proxyquire = require("proxyquire");
+import { NestedError } from "../../../src/common/error/internalError";
+import { InternalErrorCode } from "../../../src/common/error/internalErrorCode";
suite("installExpoGoApplicationCommand", function () {
function createMockProject(projectPath: string): any {
@@ -63,6 +65,8 @@ suite("installExpoGoApplicationCommand", function () {
debug: Sinon.stub(),
logStream: Sinon.stub(),
};
+ const installAndroidApplicationStub = Sinon.stub().returns(Promise.resolve());
+ const installiOSApplicationStub = Sinon.stub().returns(Promise.resolve());
const module = proxyquire.noCallThru()(
"../../../src/extension/commands/installExpoGoApplication",
@@ -76,12 +80,18 @@ suite("installExpoGoApplicationCommand", function () {
https: {
get: getStub,
},
+ fs: {
+ existsSync: Sinon.stub().returns(false),
+ },
+ os: {
+ platform: () => "darwin",
+ },
"../../common/downloadHelper": {
downloadExpoGo: downloadExpoGoStub,
},
"../../common/installHelper": {
- installAndroidApplication: Sinon.stub().returns(Promise.resolve()),
- installiOSApplication: Sinon.stub().returns(Promise.resolve()),
+ installAndroidApplication: installAndroidApplicationStub,
+ installiOSApplication: installiOSApplicationStub,
},
"../log/OutputChannelLogger": {
OutputChannelLogger: {
@@ -95,6 +105,8 @@ suite("installExpoGoApplicationCommand", function () {
InstallExpoGoApplication: module.InstallExpoGoApplication,
getStub,
showQuickPickStub,
+ installAndroidApplicationStub,
+ installiOSApplicationStub,
};
}
@@ -162,4 +174,55 @@ suite("installExpoGoApplicationCommand", function () {
assert.strictEqual(getStub.called, false);
assert.strictEqual(downloadExpoGoStub.called, false);
});
+
+ for (const platform of ["Android", "iOS"]) {
+ test(`should preserve the original ${platform} download error`, async function () {
+ const originalError = new Error("ECONNRESET while downloading Expo Go");
+ const downloadExpoGoStub = Sinon.stub().returns(Promise.reject(originalError));
+ const {
+ InstallExpoGoApplication,
+ installAndroidApplicationStub,
+ installiOSApplicationStub,
+ } = createCommandModule("2.0.0", downloadExpoGoStub, [platform, "Auto"]);
+
+ await assert.rejects(runCommand(InstallExpoGoApplication), (error: unknown) => {
+ assert.ok(error instanceof NestedError);
+ assert.strictEqual(error.innerError, originalError);
+ assert.strictEqual(error.errorCode, InternalErrorCode.FailedToInstallExpoGo);
+ assert.strictEqual(
+ error.message,
+ `Failed to download Expo Go.: ${originalError.message}`,
+ );
+ return true;
+ });
+ assert.strictEqual(installAndroidApplicationStub.called, false);
+ assert.strictEqual(installiOSApplicationStub.called, false);
+ });
+
+ test(`should preserve the original ${platform} installation error`, async function () {
+ const originalError = new Error(`${platform} device installation failed`);
+ const downloadExpoGoStub = Sinon.stub().returns(Promise.resolve());
+ const {
+ InstallExpoGoApplication,
+ installAndroidApplicationStub,
+ installiOSApplicationStub,
+ } = createCommandModule("2.0.0", downloadExpoGoStub, [platform, "Auto"]);
+ const installStub =
+ platform === "Android" ? installAndroidApplicationStub : installiOSApplicationStub;
+ installStub.returns(Promise.reject(originalError));
+
+ await assert.rejects(runCommand(InstallExpoGoApplication), (error: unknown) => {
+ assert.ok(error instanceof NestedError);
+ assert.strictEqual(error.innerError, originalError);
+ assert.strictEqual(error.errorCode, InternalErrorCode.FailedToInstallExpoGo);
+ assert.strictEqual(
+ error.message,
+ `Failed to install Expo Go.: ${originalError.message}`,
+ );
+ return true;
+ });
+ assert.strictEqual(downloadExpoGoStub.calledOnce, true);
+ assert.strictEqual(installStub.calledOnce, true);
+ });
+ }
});
diff --git a/test/extension/commands/openEASProject.test.ts b/test/extension/commands/openEASProject.test.ts
index 0cce6da7e..3c5f79fe9 100644
--- a/test/extension/commands/openEASProject.test.ts
+++ b/test/extension/commands/openEASProject.test.ts
@@ -7,6 +7,7 @@ import proxyquire = require("proxyquire");
suite("openEASProjectCommand", function () {
function createCommandModule() {
+ const localizedMessage = "Localized EAS project message";
const logger = {
info: Sinon.stub(),
error: Sinon.stub(),
@@ -17,7 +18,7 @@ suite("openEASProjectCommand", function () {
const isExpoManagedAppStub = Sinon.stub().returns(Promise.resolve(true));
const getExpoEasProjectIdStub = Sinon.stub().returns(Promise.resolve("project-id"));
const getExpoEasProjectOwnerStub = Sinon.stub().returns(Promise.resolve("project-owner"));
- const getExpoEasProjectNameStub = Sinon.stub().returns(Promise.resolve("project-name"));
+ const getExpoEasProjectSlugStub = Sinon.stub().returns(Promise.resolve("project-slug"));
class FakeReactNativeCommand {
public project: any;
@@ -30,7 +31,7 @@ suite("openEASProjectCommand", function () {
const module = proxyquire.noCallThru()("../../../src/extension/commands/openEASProject", {
"vscode-nls": {
config: () => () => undefined,
- loadMessageBundle: () => (_key: string, message: string) => message,
+ loadMessageBundle: () => () => localizedMessage,
MessageFormat: { bundle: "bundle" },
BundleFormat: { standalone: "standalone" },
},
@@ -64,12 +65,13 @@ suite("openEASProjectCommand", function () {
isExpoManagedApp: isExpoManagedAppStub,
getExpoEasProjectId: getExpoEasProjectIdStub,
getExpoEasProjectOwner: getExpoEasProjectOwnerStub,
- getExpoEasProjectName: getExpoEasProjectNameStub,
+ getExpoEasProjectSlug: getExpoEasProjectSlugStub,
}),
};
return {
OpenEASProject: module.OpenEASProject,
+ localizedMessage,
project,
logger,
showErrorMessageStub,
@@ -78,7 +80,7 @@ suite("openEASProjectCommand", function () {
isExpoManagedAppStub,
getExpoEasProjectIdStub,
getExpoEasProjectOwnerStub,
- getExpoEasProjectNameStub,
+ getExpoEasProjectSlugStub,
};
}
@@ -127,23 +129,36 @@ suite("openEASProjectCommand", function () {
assert.strictEqual(
stubs.parseStub.calledWithExactly(
- "https://expo.dev/accounts/project-owner/projects/project-name",
+ "https://expo.dev/accounts/project-owner/projects/project-slug",
),
true,
);
assert.strictEqual(stubs.openExternalStub.calledWithExactly({ parsed: true }), true);
});
- test("should not open a URL when the EAS project name is missing", async function () {
- const stubs = createCommandModule();
- stubs.getExpoEasProjectNameStub.returns(Promise.resolve(null));
+ for (const slug of [null, ""]) {
+ test(`should show an error when the EAS project slug is ${JSON.stringify(
+ slug,
+ )}`, async function () {
+ const stubs = createCommandModule();
+ stubs.getExpoEasProjectSlugStub.returns(Promise.resolve(slug));
- await runCommand(stubs);
+ await runCommand(stubs);
- assert.strictEqual(stubs.parseStub.called, false);
- assert.strictEqual(stubs.openExternalStub.called, false);
- assert.strictEqual(stubs.showErrorMessageStub.called, false);
- });
+ assert.strictEqual(stubs.parseStub.called, false);
+ assert.strictEqual(stubs.openExternalStub.called, false);
+ assert.strictEqual(stubs.showErrorMessageStub.calledOnce, true);
+ assert.strictEqual(stubs.logger.error.calledOnce, true);
+ assert.strictEqual(
+ stubs.showErrorMessageStub.firstCall.args[0],
+ stubs.localizedMessage,
+ );
+ assert.strictEqual(
+ stubs.logger.error.firstCall.args[0],
+ stubs.showErrorMessageStub.firstCall.args[0],
+ );
+ });
+ }
test("should log an error when EAS project details cannot be read", async function () {
const error = new Error("failed to read EAS project details");
diff --git a/test/extension/commands/setNewArch.test.ts b/test/extension/commands/setNewArch.test.ts
index b70c2d031..302e9fd2d 100644
--- a/test/extension/commands/setNewArch.test.ts
+++ b/test/extension/commands/setNewArch.test.ts
@@ -345,6 +345,7 @@ suite("setNewArchCommand", function () {
await runCommand(SetNewArch);
+ assert.strictEqual(showQuickPickStub.calledOnce, true);
assert.strictEqual(writeFileStub.called, false);
assert.strictEqual(spawnStub.called, false);
});
diff --git a/test/extension/exponent/exponentHelper.test.ts b/test/extension/exponent/exponentHelper.test.ts
index a977eb52e..a7ce3bce8 100644
--- a/test/extension/exponent/exponentHelper.test.ts
+++ b/test/extension/exponent/exponentHelper.test.ts
@@ -92,7 +92,8 @@ suite("exponentHelper", function () {
JSON.stringify({
expo: {
owner: "owner",
- name: "project",
+ name: "Project Display Name",
+ slug: "project-slug",
extra: { eas: { projectId: "project-id" } },
},
}),
@@ -101,7 +102,7 @@ suite("exponentHelper", function () {
assert.strictEqual(await expoHelper.getExpoEasProjectOwner(), "owner");
assert.strictEqual(await expoHelper.getExpoEasProjectId(), "project-id");
- assert.strictEqual(await expoHelper.getExpoEasProjectName(), "project");
+ assert.strictEqual(await expoHelper.getExpoEasProjectSlug(), "project-slug");
});
test("should cache app.json until its modification time changes", async () => {
@@ -119,7 +120,8 @@ suite("exponentHelper", function () {
? {
expo: {
owner: "owner",
- name: "project",
+ name: "Project Display Name",
+ slug: "project-slug",
extra: { eas: { projectId: "project-id" } },
},
}
@@ -130,7 +132,7 @@ suite("exponentHelper", function () {
assert.strictEqual(await expoHelper.getExpoEasProjectOwner(), "owner");
assert.strictEqual(await expoHelper.getExpoEasProjectId(), "project-id");
- assert.strictEqual(await expoHelper.getExpoEasProjectName(), "project");
+ assert.strictEqual(await expoHelper.getExpoEasProjectSlug(), "project-slug");
assert.strictEqual(readFileCallCount, 1);
assert.strictEqual(await expoHelper.getExpoEasProjectOwner(), "new-owner");
@@ -145,7 +147,18 @@ suite("exponentHelper", function () {
assert.strictEqual(await expoHelper.getExpoEasProjectOwner(), null);
assert.strictEqual(await expoHelper.getExpoEasProjectId(), null);
- assert.strictEqual(await expoHelper.getExpoEasProjectName(), null);
+ assert.strictEqual(await expoHelper.getExpoEasProjectSlug(), null);
+ });
+
+ test("should not use the display name when the slug is missing", async () => {
+ const fs = new FileSystem();
+ sinon.stub(fs, "stat", async () => ({ mtimeMs: 1 } as Stats));
+ sinon.stub(fs, "readFile", async () =>
+ JSON.stringify({ expo: { name: "Project Display Name" } }),
+ );
+ const expoHelper = new ExponentHelper(RESOURCES_ROOT, "", fs);
+
+ assert.strictEqual(await expoHelper.getExpoEasProjectSlug(), null);
});
});
});
diff --git a/test/extension/outputVerifier.test.ts b/test/extension/outputVerifier.test.ts
new file mode 100644
index 000000000..5c9d3a596
--- /dev/null
+++ b/test/extension/outputVerifier.test.ts
@@ -0,0 +1,156 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import assert = require("assert");
+import { EventEmitter } from "events";
+import { OutputVerifier, PatternToFailure } from "../../src/common/outputVerifier";
+
+function makeSpawnResult(stdoutLines: string[], stderrLines: string[], outcomeError?: Error) {
+ const stdout = new EventEmitter() as NodeJS.ReadableStream;
+ const stderr = new EventEmitter() as NodeJS.ReadableStream;
+ const stdin = new EventEmitter() as NodeJS.WritableStream;
+ const spawnedProcess = {} as any;
+
+ const outcome = new Promise((resolve, reject) => {
+ setImmediate(() => {
+ for (const line of stdoutLines) {
+ stdout.emit("data", Buffer.from(line));
+ }
+ for (const line of stderrLines) {
+ stderr.emit("data", Buffer.from(line));
+ }
+ if (outcomeError) {
+ reject(outcomeError);
+ } else {
+ resolve();
+ }
+ });
+ });
+
+ return { stdout, stderr, stdin, spawnedProcess, outcome };
+}
+
+suite("OutputVerifier", function () {
+ test("should resolve when all success patterns are found on stdout", async function () {
+ const verifier = new OutputVerifier(
+ () => Promise.resolve(["BUILD SUCCEEDED", "Launched"]),
+ () => Promise.resolve([]),
+ "iOS",
+ );
+
+ const spawnResult = makeSpawnResult(["BUILD SUCCEEDED\nLaunched app on simulator"], []);
+
+ await verifier.process(spawnResult);
+ });
+
+ test("should reject when not all success patterns are found", async function () {
+ const verifier = new OutputVerifier(
+ () => Promise.resolve(["BUILD SUCCEEDED", "Launched"]),
+ () => Promise.resolve([]),
+ "iOS",
+ );
+
+ const spawnResult = makeSpawnResult(["BUILD SUCCEEDED"], []);
+
+ await assert.rejects(() => verifier.process(spawnResult));
+ });
+
+ test("should reject with failure pattern error when string pattern matches stderr", async function () {
+ const failurePatterns: PatternToFailure[] = [{ pattern: "BUILD FAILED", errorCode: 201 }];
+ const verifier = new OutputVerifier(
+ () => Promise.resolve([]),
+ () => Promise.resolve(failurePatterns),
+ "iOS",
+ );
+
+ const spawnResult = makeSpawnResult([], ["error: BUILD FAILED"]);
+
+ await assert.rejects(() => verifier.process(spawnResult));
+ });
+
+ test("should reject with failure pattern error when RegExp pattern matches", async function () {
+ const failurePatterns: PatternToFailure[] = [{ pattern: /error:\s+\w+/, errorCode: 202 }];
+ const verifier = new OutputVerifier(
+ () => Promise.resolve([]),
+ () => Promise.resolve(failurePatterns),
+ "Android",
+ );
+
+ const spawnResult = makeSpawnResult([], ["error: SomeException"]);
+
+ await assert.rejects(
+ () => verifier.process(spawnResult),
+ (err: Error) => {
+ assert.ok(
+ err.message.includes("202"),
+ "error should reference the matched error code",
+ );
+ return true;
+ },
+ );
+ });
+
+ test("should reject with process error when outcome rejects and no failure pattern matches", async function () {
+ const verifier = new OutputVerifier(
+ () => Promise.resolve(["SUCCESS"]),
+ () => Promise.resolve([]),
+ "Android",
+ );
+
+ const processError = new Error("process exited with code 1");
+ const spawnResult = makeSpawnResult([], [], processError);
+
+ await assert.rejects(() => verifier.process(spawnResult), processError);
+ });
+
+ test("should prefer failure pattern error over process error when both occur", async function () {
+ const failurePatterns: PatternToFailure[] = [{ pattern: "BUILD FAILED", errorCode: 203 }];
+ const verifier = new OutputVerifier(
+ () => Promise.resolve([]),
+ () => Promise.resolve(failurePatterns),
+ "iOS",
+ );
+
+ const processError = new Error("process exited with code 1");
+ const spawnResult = makeSpawnResult([], ["BUILD FAILED"], processError);
+
+ await assert.rejects(
+ () => verifier.process(spawnResult),
+ (err: Error) => {
+ assert.strictEqual(err, processError);
+ assert.ok(
+ err.message.includes("process exited with code 1") &&
+ err.message.includes("203"),
+ "processError.message should be augmented with failure pattern error info",
+ );
+ return true;
+ },
+ );
+ });
+
+ test("should match success patterns case-insensitively", async function () {
+ const verifier = new OutputVerifier(
+ () => Promise.resolve(["build succeeded"]),
+ () => Promise.resolve([]),
+ "iOS",
+ );
+
+ const spawnResult = makeSpawnResult(["BUILD SUCCEEDED"], []);
+
+ await verifier.process(spawnResult);
+ });
+
+ test("should check failure patterns against combined stderr and stdout", async function () {
+ const failurePatterns: PatternToFailure[] = [{ pattern: "FATAL", errorCode: 204 }];
+ const verifier = new OutputVerifier(
+ () => Promise.resolve([]),
+ () => Promise.resolve(failurePatterns),
+ "iOS",
+ );
+
+ // Failure pattern appears on stdout, not stderr
+ const spawnResult = makeSpawnResult(["FATAL error occurred"], []);
+
+ await assert.rejects(() => verifier.process(spawnResult));
+ });
+});