From cef277d067bd5d679d2955ddb6fa5834689da6c9 Mon Sep 17 00:00:00 2001 From: EmmaYuan1015 Date: Wed, 21 Oct 2026 14:52:06 +0800 Subject: [PATCH 1/9] Skip redundant pod install for existing Hermes setting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/extension/commands/enableHermes.ts | 3 ++- test/extension/commands/enableHermes.test.ts | 26 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/extension/commands/enableHermes.ts b/src/extension/commands/enableHermes.ts index dea2cbfc9..1dde8f1a2 100644 --- a/src/extension/commands/enableHermes.ts +++ b/src/extension/commands/enableHermes.ts @@ -40,7 +40,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 = @@ -52,6 +52,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/test/extension/commands/enableHermes.test.ts b/test/extension/commands/enableHermes.test.ts index 8d5de2ae4..b9da1d30a 100644 --- a/test/extension/commands/enableHermes.test.ts +++ b/test/extension/commands/enableHermes.test.ts @@ -162,6 +162,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"); From af06ec18ab16cc4b15842e0ab68d2f831e427cdb Mon Sep 17 00:00:00 2001 From: EmmaYuan1015 Date: Wed, 28 Oct 2026 11:02:02 +0800 Subject: [PATCH 2/9] Preserve underlying Expo Go download and installation errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../commands/installExpoGoApplication.ts | 25 ++++--- .../commands/installExpoGoApplication.test.ts | 67 ++++++++++++++++++- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/extension/commands/installExpoGoApplication.ts b/src/extension/commands/installExpoGoApplication.ts index 88a99ba88..f6c62b33a 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/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); + }); + } }); From 0451195c753b3075498e37f0a9963e837ac28312 Mon Sep 17 00:00:00 2001 From: RKS Date: Fri, 30 Oct 2026 00:09:29 -0400 Subject: [PATCH 3/9] Stop New Architecture command when platform selection is cancelled --- src/extension/commands/setNewArch.ts | 3 ++- test/extension/commands/setNewArch.test.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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/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); }); From faa54d3177b889e41d94a2a8070ccdfea16e65ec Mon Sep 17 00:00:00 2001 From: ConnorQi01 Date: Fri, 30 Oct 2026 15:19:20 +0800 Subject: [PATCH 4/9] Add unit tests for OutputVerifier Closes #2950 --- test/extension/outputVerifier.test.ts | 156 ++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 test/extension/outputVerifier.test.ts 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)); + }); +}); From 6fd4cd40d6dd3e2e464db87f23af9222234c76aa Mon Sep 17 00:00:00 2001 From: ZhiyanJia Date: Wed, 4 Nov 2026 11:19:43 +0800 Subject: [PATCH 5/9] Fix Android Hermes detection for modern Gradle configuration --- src/common/reactNativeProjectHelper.ts | 30 +++++- test/common/reactNativeProjectHelper.test.ts | 102 +++++++++++++++++++ 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/common/reactNativeProjectHelper.ts b/src/common/reactNativeProjectHelper.ts index 129511be8..8d8160806 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/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]"; From 1bb8006c806f76dbc79cce4feafa3e9e496e928c Mon Sep 17 00:00:00 2001 From: ZhiyanJia Date: Wed, 4 Nov 2026 15:12:16 +0800 Subject: [PATCH 6/9] fix: support Android build variants with newer React Native CLI versions --- README.md | 2 +- package.nls.json | 2 +- src/common/customRequire.ts | 1 + src/extension/android/androidPlatform.ts | 68 +++++++- .../extension/android/androidPlatform.test.ts | 150 ++++++++++++++++++ 5 files changed, 220 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 84098e7aa..21a20ad99 100644 --- a/README.md +++ b/README.md @@ -662,7 +662,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 6b4043408..576243201 100644 --- a/package.nls.json +++ b/package.nls.json @@ -76,7 +76,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/extension/android/androidPlatform.ts b/src/extension/android/androidPlatform.ts index 83c404dc5..3daeb71ee 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"; @@ -300,7 +303,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 ( @@ -315,6 +318,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/test/extension/android/androidPlatform.test.ts b/test/extension/android/androidPlatform.test.ts index 6a095eb7a..950f2c193 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, From 74982ed3c6ddecf89010deff00b6409f307958e1 Mon Sep 17 00:00:00 2001 From: EmmaYuan1015 Date: Thu, 5 Nov 2026 10:56:55 +0800 Subject: [PATCH 7/9] Scope debugger endpoint encoding assertion to the target response Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/cdp-proxy/debuggerEndpointHelper.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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) => { From 50ac83891f5b4523c42bf2555ad5c952b57ea89a Mon Sep 17 00:00:00 2001 From: ZhiyanJia Date: Thu, 5 Nov 2026 13:50:07 +0800 Subject: [PATCH 8/9] fix: use slug instead of display name in EAS project URL --- src/extension/commands/openEASProject.ts | 27 ++++++-------- src/extension/exponent/exponentHelper.ts | 6 +-- .../extension/commands/openEASProject.test.ts | 37 +++++++++++++------ .../extension/exponent/exponentHelper.test.ts | 23 +++++++++--- 4 files changed, 58 insertions(+), 35 deletions(-) diff --git a/src/extension/commands/openEASProject.ts b/src/extension/commands/openEASProject.ts index 268115c21..7760b7c87 100644 --- a/src/extension/commands/openEASProject.ts +++ b/src/extension/commands/openEASProject.ts @@ -30,18 +30,9 @@ 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; - }); + const id = await expoHelper.getExpoEasProjectId(); + const owner = await expoHelper.getExpoEasProjectOwner(); + const slug = await expoHelper.getExpoEasProjectSlug(); if (id == null || owner == null) { const error = localize( "ExpoProjectNotLinkToEAS", @@ -49,9 +40,15 @@ export class OpenEASProject extends ReactNativeCommand { ); 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/exponent/exponentHelper.ts b/src/extension/exponent/exponentHelper.ts index 024bbc314..e496ecffc 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; @@ -528,9 +528,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/extension/commands/openEASProject.test.ts b/test/extension/commands/openEASProject.test.ts index 0cce6da7e..c2c3304a8 100644 --- a/test/extension/commands/openEASProject.test.ts +++ b/test/extension/commands/openEASProject.test.ts @@ -17,7 +17,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; @@ -64,7 +64,7 @@ suite("openEASProjectCommand", function () { isExpoManagedApp: isExpoManagedAppStub, getExpoEasProjectId: getExpoEasProjectIdStub, getExpoEasProjectOwner: getExpoEasProjectOwnerStub, - getExpoEasProjectName: getExpoEasProjectNameStub, + getExpoEasProjectSlug: getExpoEasProjectSlugStub, }), }; @@ -78,7 +78,7 @@ suite("openEASProjectCommand", function () { isExpoManagedAppStub, getExpoEasProjectIdStub, getExpoEasProjectOwnerStub, - getExpoEasProjectNameStub, + getExpoEasProjectSlugStub, }; } @@ -127,23 +127,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], + "Unable to open the EAS project page because expo.slug is missing in app.json.", + ); + 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/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); }); }); }); From 604f1e93ffd015488b648acdede2d4c89fce9210 Mon Sep 17 00:00:00 2001 From: ZhiyanJia Date: Thu, 5 Nov 2026 14:35:21 +0800 Subject: [PATCH 9/9] test: fix EAS project command tests after localization build --- test/extension/commands/openEASProject.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/extension/commands/openEASProject.test.ts b/test/extension/commands/openEASProject.test.ts index c2c3304a8..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(), @@ -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" }, }, @@ -70,6 +71,7 @@ suite("openEASProjectCommand", function () { return { OpenEASProject: module.OpenEASProject, + localizedMessage, project, logger, showErrorMessageStub, @@ -149,7 +151,7 @@ suite("openEASProjectCommand", function () { assert.strictEqual(stubs.logger.error.calledOnce, true); assert.strictEqual( stubs.showErrorMessageStub.firstCall.args[0], - "Unable to open the EAS project page because expo.slug is missing in app.json.", + stubs.localizedMessage, ); assert.strictEqual( stubs.logger.error.firstCall.args[0],