diff --git a/extensions/qa-lab/src/docker-up.runtime.test.ts b/extensions/qa-lab/src/docker-up.runtime.test.ts index ccbb15cc5136..e485b40aa72f 100644 --- a/extensions/qa-lab/src/docker-up.runtime.test.ts +++ b/extensions/qa-lab/src/docker-up.runtime.test.ts @@ -1,6 +1,5 @@ // Qa Lab tests cover docker up plugin behavior. import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { createServer } from "node:net"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -8,31 +7,6 @@ import { runQaDockerUp } from "./docker-up.runtime.js"; type QaDockerUpDeps = NonNullable[1]>; -async function occupyPortOrAcceptExisting(port: number): Promise<{ close: () => Promise }> { - const server = createServer(); - const listening = await new Promise((resolve, reject) => { - server.once("error", (error: NodeJS.ErrnoException) => { - if (error.code === "EADDRINUSE") { - resolve(false); - return; - } - reject(error); - }); - server.listen(port, "127.0.0.1", () => resolve(true)); - }); - - return { - close: async () => { - if (!listening) { - return; - } - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - }, - }; -} - function createHealthyDockerDeps(calls: string[]): QaDockerUpDeps { return { async runCommand(command, args, cwd) { @@ -163,7 +137,8 @@ describe("runQaDockerUp", () => { const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-")); const gatewayPort = 18789; const qaLabPort = 43124; - const resolveHostPort = vi.fn(async (preferredPort: number) => { + const resolveHostPort = vi.fn(async (preferredPort: number, pinned: boolean) => { + expect(pinned).toBe(false); if (preferredPort === gatewayPort) { return 28001; } @@ -172,16 +147,12 @@ describe("runQaDockerUp", () => { } return preferredPort; }); - const gatewayPortReservation = await occupyPortOrAcceptExisting(18789); - const qaLabPortReservation = await occupyPortOrAcceptExisting(43124); try { const result = await runQaDockerUp( { repoRoot: "/repo/openclaw", outputDir, - gatewayPort, - qaLabPort, skipUiBuild: true, usePrebuiltImage: true, }, @@ -202,9 +173,9 @@ describe("runQaDockerUp", () => { expect(result.qaLabUrl).not.toBe(`http://127.0.0.1:${qaLabPort}`); expect(result.gatewayUrl).toBe("http://127.0.0.1:28001/"); expect(result.qaLabUrl).toBe("http://127.0.0.1:28002"); + expect(resolveHostPort).toHaveBeenCalledWith(gatewayPort, false); + expect(resolveHostPort).toHaveBeenCalledWith(qaLabPort, false); } finally { - await gatewayPortReservation.close(); - await qaLabPortReservation.close(); await rm(outputDir, { recursive: true, force: true }); } }); diff --git a/src/plugin-sdk/qa-runtime.test.ts b/src/plugin-sdk/qa-runtime.test.ts index f926e137cd45..ca5f3a5e255a 100644 --- a/src/plugin-sdk/qa-runtime.test.ts +++ b/src/plugin-sdk/qa-runtime.test.ts @@ -2,6 +2,7 @@ * Tests QA runtime command loading and private CLI gating. */ import { Command } from "commander"; +import { createServer } from "node:net"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, @@ -36,6 +37,29 @@ describe("plugin-sdk qa-runtime", () => { restorePrivateQaCliEnv(originalPrivateQaCli); }); + async function occupyLoopbackPort(): Promise<{ close: () => Promise; port: number }> { + const server = createServer(); + const port = await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("test server address unavailable")); + return; + } + resolve(address.port); + }); + }); + return { + port, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; + } + it("stays cold until the runtime seam is used", async () => { const module = await import("./qa-runtime.js"); @@ -273,4 +297,19 @@ describe("plugin-sdk qa-runtime", () => { expect(runCommand).toHaveBeenCalledTimes(2); expect(sleepImpl).toHaveBeenCalledTimes(1); }); + + it("resolves an unpinned QA Docker host port away from an occupied loopback default", async () => { + const module = await import("./qa-runtime.js"); + const reservation = await occupyLoopbackPort(); + try { + await expect(module.resolveQaDockerHostPort(reservation.port, true)).resolves.toBe( + reservation.port, + ); + const fallbackPort = await module.resolveQaDockerHostPort(reservation.port, false); + expect(fallbackPort).toBeGreaterThan(0); + expect(fallbackPort).not.toBe(reservation.port); + } finally { + await reservation.close(); + } + }); }); diff --git a/src/plugin-sdk/qa-runtime.ts b/src/plugin-sdk/qa-runtime.ts index 8dab6c79525b..45a43564faad 100644 --- a/src/plugin-sdk/qa-runtime.ts +++ b/src/plugin-sdk/qa-runtime.ts @@ -395,7 +395,7 @@ async function findFreeQaDockerPort() { return await new Promise((resolve, reject) => { const server = createServer(); server.once("error", reject); - server.listen(0, () => { + server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close();