diff --git a/package-lock.json b/package-lock.json index 3cfe74cd4..951a264b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8928,9 +8928,9 @@ "dev": true }, "node_modules/markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "dev": true, "funding": [ { @@ -20222,9 +20222,9 @@ "dev": true }, "markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "dev": true, "requires": { "argparse": "^2.0.1", diff --git a/src/cdp-proxy/debuggerEndpointHelper.ts b/src/cdp-proxy/debuggerEndpointHelper.ts index a1dcfc426..5668acff6 100644 --- a/src/cdp-proxy/debuggerEndpointHelper.ts +++ b/src/cdp-proxy/debuggerEndpointHelper.ts @@ -191,12 +191,13 @@ export class DebuggerEndpointHelper { } const request = driver.get(url, requestOptions, response => { - let data = ""; - response.setEncoding("utf8"); - response.on("data", (chunk: string) => { - data += chunk; + const chunks: Uint8Array[] = []; + response.on("data", (chunk: Buffer) => { + chunks.push(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + }); + response.on("end", () => { + fulfill(Buffer.concat(chunks).toString("utf8")); }); - response.on("end", () => fulfill(data)); response.on("error", reject); }); diff --git a/src/extension/commands/cleanRestartPackager.ts b/src/extension/commands/cleanRestartPackager.ts index 6ba1534c7..9666cb926 100644 --- a/src/extension/commands/cleanRestartPackager.ts +++ b/src/extension/commands/cleanRestartPackager.ts @@ -57,46 +57,60 @@ export class CleanRestartPackager extends ReactNativeCommand { if (platformId === HostPlatformId.WINDOWS) { // Windows: Use netstat and taskkill + let outcome: string; try { const netstatResult = await childProcess.exec( `netstat -ano | findstr :${port}`, ); - const outcome = await netstatResult.outcome; - - if (outcome) { - // Extract PID from netstat output - const lines = outcome.split("\n"); - for (const line of lines) { - const match = line.match(/\s+LISTENING\s+(\d+)/); - if (match && match[1]) { - const pid = match[1]; - logger.info(`Found Metro process with PID: ${pid}`); - // eslint-disable-next-line no-await-in-loop -- kill PIDs one at a time; taskkill /T kills process trees, so later PIDs may already be gone - await childProcess - .exec(`taskkill /PID ${pid} /F /T`) - .then(killResult => killResult.outcome); - logger.info(`Successfully terminated process ${pid}`); - } - } - } + outcome = await netstatResult.outcome; } catch (error) { logger.info(`No Metro process found on port ${port}`); + return; + } + + // Extract PID from netstat output + const lines = outcome.split("\n"); + for (const line of lines) { + const match = line.match(/\s+LISTENING\s+(\d+)/); + if (match && match[1]) { + const pid = match[1]; + logger.info(`Found Metro process with PID: ${pid}`); + try { + // eslint-disable-next-line no-await-in-loop -- kill PIDs one at a time; taskkill /T kills process trees, so later PIDs may already be gone + await childProcess + .exec(`taskkill /PID ${pid} /F /T`) + .then(killResult => killResult.outcome); + logger.info(`Successfully terminated process ${pid}`); + } catch (error) { + logger.warning( + `Failed to terminate Metro process ${pid}: ${String(error)}`, + ); + } + } } } else { // macOS/Linux: Use lsof and kill + let outcome: string; try { const lsofResult = await childProcess.exec(`lsof -ti:${port}`); - const outcome = await lsofResult.outcome; + outcome = await lsofResult.outcome; + } catch (error) { + logger.info(`No Metro process found on port ${port}`); + return; + } - if (outcome && outcome.trim()) { - const pid = outcome.trim(); - logger.info(`Found Metro process with PID: ${pid}`); + if (outcome && outcome.trim()) { + const pid = outcome.trim(); + logger.info(`Found Metro process with PID: ${pid}`); + try { const killResult = await childProcess.exec(`kill -9 ${pid}`); await killResult.outcome; logger.info(`Successfully terminated process ${pid}`); + } catch (error) { + logger.warning( + `Failed to terminate Metro process ${pid}: ${String(error)}`, + ); } - } catch (error) { - logger.info(`No Metro process found on port ${port}`); } } } catch (error) { diff --git a/src/extension/commands/enableHermes.ts b/src/extension/commands/enableHermes.ts index dea2cbfc9..80058e0f8 100644 --- a/src/extension/commands/enableHermes.ts +++ b/src/extension/commands/enableHermes.ts @@ -80,11 +80,11 @@ export class EnableHermes extends Command { const gradleFileContent = fs.readFileSync(gradleFilePath, "utf-8"); const hermesMatches = gradleFileContent.match( - /^([ \t]*)(#\s*)?hermesEnabled\s*=\s*\w*/m, + /^([\t ]*)(#\s*)?hermesEnabled\s*=\s*\w*/m, ); if (hermesMatches && !hermesMatches[2]) { const updatedHermes = gradleFileContent.replace( - /^([ \t]*)hermesEnabled\s*=\s*\w*/m, + /^([\t ]*)hermesEnabled\s*=\s*\w*/m, `$1hermesEnabled=${isHermesEnabled}`, ); await this.nodeFileSystem.writeFile(gradleFilePath, updatedHermes); diff --git a/src/extension/commands/expoDoctor.ts b/src/extension/commands/expoDoctor.ts index b78e951b8..df119b9b2 100644 --- a/src/extension/commands/expoDoctor.ts +++ b/src/extension/commands/expoDoctor.ts @@ -17,9 +17,26 @@ export class expoDoctor extends ReactNativeCommand { async baseFn(): Promise { assert(this.project); const projectRootPath = this.project.getPackager().getProjectPath(); - const res = await new ChildProcess().exec("npx expo-doctor", { cwd: projectRootPath }); + const result = new ChildProcess().spawn("npx", ["expo-doctor"], { + cwd: projectRootPath, + }); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + result.stdout.on("data", data => stdoutChunks.push(data.toString())); + result.stderr.on("data", data => stderrChunks.push(data.toString())); + logger.info("Running diagnostics..."); - const outcome = await res.outcome; - logger.info(outcome); + try { + await result.outcome; + } finally { + const stdout = stdoutChunks.join(""); + const stderr = stderrChunks.join(""); + if (stdout) { + logger.info(stdout); + } + if (stderr) { + logger.error(stderr); + } + } } } diff --git a/test/cdp-proxy/debuggerEndpointHelper.test.ts b/test/cdp-proxy/debuggerEndpointHelper.test.ts index 98fe082a6..3e9c456ef 100644 --- a/test/cdp-proxy/debuggerEndpointHelper.test.ts +++ b/test/cdp-proxy/debuggerEndpointHelper.test.ts @@ -3,6 +3,8 @@ import assert = require("assert"); import * as sinon from "sinon"; +import * as http from "http"; +import { AddressInfo } from "net"; import { DebuggerEndpointHelper } from "../../src/cdp-proxy/debuggerEndpointHelper"; import { PromiseUtil } from "../../src/common/node/promise"; @@ -15,6 +17,37 @@ suite("debuggerEndpointHelper", function () { } }); + test("getWSEndpoint should preserve Buffer responses and decode split UTF-8 characters", async function () { + const endpoint = "ws://localhost:8081/\u4e2d\u6587"; + const body = Buffer.from(JSON.stringify({ webSocketDebuggerUrl: endpoint }), "utf8"); + const splitIndex = body.indexOf("\u4e2d") + 1; + const setEncodingSpy = sinon.spy(http.IncomingMessage.prototype, "setEncoding"); + const server = http.createServer((_request, response) => { + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.write(body.subarray(0, splitIndex)); + setImmediate(() => response.end(body.subarray(splitIndex))); + }); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + const helper = new DebuggerEndpointHelper(); + + const result = await helper.getWSEndpoint(`http://127.0.0.1:${address.port}`); + + assert.strictEqual(result, endpoint); + assert.strictEqual(setEncodingSpy.called, false); + } finally { + setEncodingSpy.restore(); + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + }); + } + }); + test("getWSEndpoint should return /json/version webSocketDebuggerUrl when present", async function () { const helper = new DebuggerEndpointHelper(); const fetchJsonStub = sinon.stub(helper as any, "fetchJson"); diff --git a/test/extension/commands/cleanRestartPackager.test.ts b/test/extension/commands/cleanRestartPackager.test.ts index 62225e366..ba83b4534 100644 --- a/test/extension/commands/cleanRestartPackager.test.ts +++ b/test/extension/commands/cleanRestartPackager.test.ts @@ -176,6 +176,33 @@ suite("cleanRestartPackagerCommand", function () { assert.strictEqual(restartStub.calledOnce, true); }); + test("should warn when terminating a Metro process fails on Windows", async function () { + const execStub = Sinon.stub(); + execStub + .withArgs("netstat -ano | findstr :9090") + .returns(createExecResult("TCP 127.0.0.1:9090 0.0.0.0:0 LISTENING 12345")); + execStub + .withArgs("taskkill /PID 12345 /F /T") + .returns(createRejectedExecResult(new Error("access denied"))); + execStub.withArgs("watchman watch-del-all").returns(createExecResult("")); + const restartStub = Sinon.stub().returns(Promise.resolve()); + const { CleanRestartPackager, logger } = createCommandModule( + HostPlatformId.WINDOWS, + execStub, + ); + + await runCommand(CleanRestartPackager, tempDir, restartStub); + + assert.strictEqual( + logger.warning.calledWith( + "Failed to terminate Metro process 12345: Error: access denied", + ), + true, + ); + assert.strictEqual(logger.info.calledWith("No Metro process found on port 9090"), false); + assert.strictEqual(restartStub.calledOnce, true); + }); + test("should kill Metro process on macOS and restart packager", async function () { const execStub = Sinon.stub(); execStub.withArgs("lsof -ti:9090").returns(createExecResult("23456\n")); @@ -193,6 +220,31 @@ suite("cleanRestartPackagerCommand", function () { assert.strictEqual(restartStub.calledWithExactly(9090), true); }); + test("should warn when terminating a Metro process fails on Unix", async function () { + const execStub = Sinon.stub(); + execStub.withArgs("lsof -ti:9090").returns(createExecResult("23456\n")); + execStub + .withArgs("kill -9 23456") + .returns(createRejectedExecResult(new Error("operation not permitted"))); + execStub.withArgs("watchman watch-del-all").returns(createExecResult("")); + const restartStub = Sinon.stub().returns(Promise.resolve()); + const { CleanRestartPackager, logger } = createCommandModule( + HostPlatformId.LINUX, + execStub, + ); + + await runCommand(CleanRestartPackager, tempDir, restartStub); + + assert.strictEqual( + logger.warning.calledWith( + "Failed to terminate Metro process 23456: Error: operation not permitted", + ), + true, + ); + assert.strictEqual(logger.info.calledWith("No Metro process found on port 9090"), false); + assert.strictEqual(restartStub.calledOnce, true); + }); + test("should remove Metro cache recursively before restarting packager", async function () { const metroCachePath = path.join(tempDir, "node_modules", ".cache", "metro"); const nestedCachePath = path.join(metroCachePath, "nested"); diff --git a/test/extension/commands/expoDoctor.test.ts b/test/extension/commands/expoDoctor.test.ts index e3b794eb3..9163dd3e7 100644 --- a/test/extension/commands/expoDoctor.test.ts +++ b/test/extension/commands/expoDoctor.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for details. import assert = require("assert"); +import { EventEmitter } from "events"; import Sinon = require("sinon"); import proxyquire = require("proxyquire"); @@ -9,7 +10,7 @@ suite("expoDoctorCommand", function () { const projectRootPath = "test-project"; function createCommandModule( - execStub: Sinon.SinonStub, + spawnStub: Sinon.SinonStub, logger = { info: Sinon.stub(), error: Sinon.stub(), @@ -22,7 +23,7 @@ suite("expoDoctorCommand", function () { } class FakeChildProcess { - public exec = execStub; + public spawn = spawnStub; } const module = proxyquire.noCallThru()("../../../src/extension/commands/expoDoctor", { @@ -55,11 +56,22 @@ suite("expoDoctorCommand", function () { }; } - function createExecResult(outcome: Promise): any { - return Promise.resolve({ - process: {}, - outcome, + function createSpawnResult(stdout: string, stderr: string, error?: Error): any { + const stdoutStream = new EventEmitter(); + const stderrStream = new EventEmitter(); + const outcome = new Promise((resolve, reject) => { + setImmediate(() => { + stdoutStream.emit("data", Buffer.from(stdout)); + stderrStream.emit("data", Buffer.from(stderr)); + error ? reject(error) : resolve(); + }); }); + + return { + stdout: stdoutStream, + stderr: stderrStream, + outcome, + }; } async function runCommand( @@ -76,13 +88,13 @@ suite("expoDoctorCommand", function () { } test("should run expo doctor from the project root and log the outcome", async function () { - const execStub = Sinon.stub().returns(createExecResult(Promise.resolve("No issues found"))); - const { expoDoctor, logger } = createCommandModule(execStub); + const spawnStub = Sinon.stub().returns(createSpawnResult("No issues found", "")); + const { expoDoctor, logger } = createCommandModule(spawnStub); await runCommand(expoDoctor); assert.strictEqual( - execStub.calledWithExactly("npx expo-doctor", { cwd: projectRootPath }), + spawnStub.calledWithExactly("npx", ["expo-doctor"], { cwd: projectRootPath }), true, ); assert.strictEqual(logger.info.calledWithExactly("Running diagnostics..."), true); @@ -91,28 +103,32 @@ suite("expoDoctorCommand", function () { test("should propagate an error when starting expo doctor fails", async function () { const error = new Error("could not start expo doctor"); - const execStub = Sinon.stub().returns(Promise.reject(error)); - const { expoDoctor, logger } = createCommandModule(execStub); + const spawnStub = Sinon.stub().throws(error); + const { expoDoctor, logger } = createCommandModule(spawnStub); await assert.rejects(() => runCommand(expoDoctor), error); assert.strictEqual(logger.info.called, false); }); - test("should propagate an error when expo doctor exits unsuccessfully", async function () { + test("should log output and propagate an error when expo doctor exits unsuccessfully", async function () { const error = new Error("expo doctor failed"); - const execStub = Sinon.stub().returns(createExecResult(Promise.reject(error))); - const { expoDoctor, logger } = createCommandModule(execStub); + const spawnStub = Sinon.stub().returns( + createSpawnResult("Issues found", "Dependency mismatch", error), + ); + const { expoDoctor, logger } = createCommandModule(spawnStub); await assert.rejects(() => runCommand(expoDoctor), error); assert.strictEqual(logger.info.calledWithExactly("Running diagnostics..."), true); + assert.strictEqual(logger.info.calledWithExactly("Issues found"), true); + assert.strictEqual(logger.error.calledWithExactly("Dependency mismatch"), true); }); test("should require a project before running expo doctor", async function () { - const execStub = Sinon.stub().returns(createExecResult(Promise.resolve(""))); - const { expoDoctor } = createCommandModule(execStub); + const spawnStub = Sinon.stub().returns(createSpawnResult("", "")); + const { expoDoctor } = createCommandModule(spawnStub); const command = expoDoctor.formInstance(); await assert.rejects(() => command.baseFn(), assert.AssertionError); - assert.strictEqual(execStub.called, false); + assert.strictEqual(spawnStub.called, false); }); });