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(