From cc7bd4d95abf20b8a3218d60ba1e01aa03d0722b Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Sat, 18 Jul 2026 16:35:16 +0800 Subject: [PATCH] fix(qa-lab): bound Node binary lookup (#109428) --- extensions/qa-lab/src/node-exec.test.ts | 35 ++++++++++++++++++++++++- extensions/qa-lab/src/node-exec.ts | 10 ++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/extensions/qa-lab/src/node-exec.test.ts b/extensions/qa-lab/src/node-exec.test.ts index 19cb628cdf30..2d20249db7d4 100644 --- a/extensions/qa-lab/src/node-exec.test.ts +++ b/extensions/qa-lab/src/node-exec.test.ts @@ -1,6 +1,6 @@ // Qa Lab tests cover node exec plugin behavior. import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { runExecMock } = vi.hoisted(() => ({ runExecMock: vi.fn() })); @@ -13,6 +13,10 @@ describe("resolveQaNodeExecPath", () => { runExecMock.mockReset(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("reuses the current exec path when already running under Node", async () => { await expect( resolveQaNodeExecPath({ @@ -66,6 +70,7 @@ describe("resolveQaNodeExecPath", () => { expect(runExecMock).toHaveBeenCalledWith("which", ["node"], { baseEnv: env, logOutput: false, + timeoutMs: 5_000, }); }); @@ -82,6 +87,7 @@ describe("resolveQaNodeExecPath", () => { expect(options).toEqual({ encoding: "utf8", env: { SystemRoot: String.raw`D:\Windows` }, + timeoutMs: 5_000, }); return { stdout: String.raw`D:\nodejs\node.exe` + "\r\n", @@ -92,6 +98,33 @@ describe("resolveQaNodeExecPath", () => { ).resolves.toBe(String.raw`D:\nodejs\node.exe`); }); + it("fails after the lookup timeout when the PATH probe stalls", async () => { + vi.useFakeTimers(); + runExecMock.mockImplementationOnce( + (_file: string, _args: string[], options: { timeoutMs: number }): Promise => + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("timed out")), options.timeoutMs); + }), + ); + + const lookup = resolveQaNodeExecPath({ + execPath: "/opt/homebrew/bin/bun", + platform: "darwin", + versions: { ...process.versions, bun: "1.2.3" }, + }); + const rejection = lookup.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(5_000); + await expect(rejection).resolves.toEqual( + expect.objectContaining({ message: expect.stringContaining("Node not found in PATH") }), + ); + expect(runExecMock).toHaveBeenCalledWith("which", ["node"], { + baseEnv: undefined, + logOutput: false, + timeoutMs: 5_000, + }); + }); + it("throws a clear error when node is unavailable", async () => { await expect( resolveQaNodeExecPath({ diff --git a/extensions/qa-lab/src/node-exec.ts b/extensions/qa-lab/src/node-exec.ts index 44ed58001d74..012d8d955287 100644 --- a/extensions/qa-lab/src/node-exec.ts +++ b/extensions/qa-lab/src/node-exec.ts @@ -9,11 +9,18 @@ type ExecFileAsync = ( options: { encoding: "utf8"; env?: NodeJS.ProcessEnv; + timeoutMs: number; }, ) => Promise<{ stdout: string; stderr: string }>; +const NODE_BINARY_LOOKUP_TIMEOUT_MS = 5_000; + const execFileAsync: ExecFileAsync = async (file, args, options) => - await runExec(file, [...args], { baseEnv: options.env, logOutput: false }); + await runExec(file, [...args], { + baseEnv: options.env, + logOutput: false, + timeoutMs: options.timeoutMs, + }); function isNodeExecPath(execPath: string, platform: NodeJS.Platform): boolean { const pathModule = platform === "win32" ? path.win32 : path.posix; @@ -48,6 +55,7 @@ export async function resolveQaNodeExecPath(params?: { ({ stdout } = await execFileImpl(locator, ["node"], { encoding: "utf8", env: params?.env, + timeoutMs: NODE_BINARY_LOOKUP_TIMEOUT_MS, })); } catch { throw new Error(