From a202dd0faf56017b14ebda2eacb643d326bde810 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sun, 28 Jun 2026 19:20:15 -0700 Subject: [PATCH] test: link mcp gateway transport coverage (#97609) --- .../mcp-gateway-connect-startup-retry.yaml | 28 ++++++ scripts/e2e/cron-mcp-cleanup-docker-client.ts | 6 +- scripts/e2e/mcp-channels-docker.sh | 2 +- scripts/e2e/mcp-connect-timeout.ts | 55 ----------- scripts/e2e/mcp-websocket-open.ts | 90 ------------------ scripts/test-projects.test-support.mjs | 14 ++- .../runtime}/mcp-channels-docker-client.ts | 6 +- .../qa-lab/runtime/mcp-channels.fixture.ts | 24 +++-- .../runtime/mcp-client-temp-state.fixture.ts | 57 +++++++++++- .../mcp-gateway-transport.e2e.test.ts} | 93 ++++++++++++++++++- test/scripts/mcp-connect-timeout.test.ts | 89 ------------------ test/scripts/mcp-websocket-open.test.ts | 72 -------------- test/scripts/test-projects.test.ts | 29 ++---- 13 files changed, 216 insertions(+), 349 deletions(-) create mode 100644 qa/scenarios/runtime/mcp-gateway-connect-startup-retry.yaml delete mode 100644 scripts/e2e/mcp-connect-timeout.ts delete mode 100644 scripts/e2e/mcp-websocket-open.ts rename {scripts/e2e => test/e2e/qa-lab/runtime}/mcp-channels-docker-client.ts (98%) rename scripts/e2e/mcp-channels-harness.ts => test/e2e/qa-lab/runtime/mcp-channels.fixture.ts (92%) rename scripts/e2e/mcp-client-temp-state.ts => test/e2e/qa-lab/runtime/mcp-client-temp-state.fixture.ts (55%) rename test/{scripts/mcp-channels-harness.test.ts => e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts} (55%) delete mode 100644 test/scripts/mcp-connect-timeout.test.ts delete mode 100644 test/scripts/mcp-websocket-open.test.ts diff --git a/qa/scenarios/runtime/mcp-gateway-connect-startup-retry.yaml b/qa/scenarios/runtime/mcp-gateway-connect-startup-retry.yaml new file mode 100644 index 000000000000..0a3819d94a6f --- /dev/null +++ b/qa/scenarios/runtime/mcp-gateway-connect-startup-retry.yaml @@ -0,0 +1,28 @@ +title: MCP Gateway connect startup retry + +scenario: + id: mcp-gateway-connect-startup-retry + surface: runtime + coverage: + primary: + - gateway.connect-request + - gateway.protocol-version-negotiation + - gateway.startup-retry + objective: Verify the Gateway client connect frame, protocol range, and retry behavior used by MCP channel bridge clients. + successCriteria: + - GatewayClient sends a `connect` request after `connect.challenge`. + - The connect request advertises the current supported protocol range. + - Retryable startup-unavailable connect responses do not fire terminal callbacks and schedule a reconnect. + docsRefs: + - docs/gateway/protocol.md + - docs/cli/mcp.md + - docs/concepts/qa-e2e-automation.md + codeRefs: + - src/gateway/client.test.ts + - test/e2e/qa-lab/runtime/mcp-channels.fixture.ts + - test/e2e/qa-lab/runtime/mcp-client-temp-state.fixture.ts + - test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts + execution: + kind: vitest + path: src/gateway/client.test.ts + summary: Vitest coverage for Gateway connect request, protocol negotiation, and startup retry behavior used by MCP bridge clients. diff --git a/scripts/e2e/cron-mcp-cleanup-docker-client.ts b/scripts/e2e/cron-mcp-cleanup-docker-client.ts index 08cdc5441ddc..3f71930a5d5f 100644 --- a/scripts/e2e/cron-mcp-cleanup-docker-client.ts +++ b/scripts/e2e/cron-mcp-cleanup-docker-client.ts @@ -7,12 +7,12 @@ import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import type { GatewayRpcClient } from "../../test/e2e/qa-lab/runtime/mcp-channels.fixture.ts"; import { readPositiveIntEnv } from "./lib/env-limits.mjs"; -import type { GatewayRpcClient } from "./mcp-channels-harness.ts"; const execFileAsync = promisify(execFile); const PROBE_PID_WAIT_MS = readCronMcpCleanupProbePidWaitMs(); -type McpChannelsHarness = typeof import("./mcp-channels-harness.ts"); +type McpChannelsHarness = typeof import("../../test/e2e/qa-lab/runtime/mcp-channels.fixture.ts"); let mcpChannelsHarness: McpChannelsHarness | undefined; type CronJob = { id?: string }; @@ -21,7 +21,7 @@ type AgentRunResult = { runId?: string; status?: string }; type CronFinishedPayload = { status?: unknown }; async function loadMcpChannelsHarness(): Promise { - mcpChannelsHarness ??= await import("./mcp-channels-harness.ts"); + mcpChannelsHarness ??= await import("../../test/e2e/qa-lab/runtime/mcp-channels.fixture.ts"); return mcpChannelsHarness; } diff --git a/scripts/e2e/mcp-channels-docker.sh b/scripts/e2e/mcp-channels-docker.sh index c86ad43c6623..723c72bb0121 100644 --- a/scripts/e2e/mcp-channels-docker.sh +++ b/scripts/e2e/mcp-channels-docker.sh @@ -66,7 +66,7 @@ docker_e2e_run_with_harness \ tsx scripts/e2e/mcp-channels-seed.ts >/tmp/mcp-channels-seed.log gateway_pid=\"\$(openclaw_e2e_start_gateway \"\$entry\" $PORT /tmp/mcp-channels-gateway.log)\" openclaw_e2e_wait_gateway_ready \"\$gateway_pid\" /tmp/mcp-channels-gateway.log 480 $PORT - tsx scripts/e2e/mcp-channels-docker-client.ts + tsx test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts " >"$CLIENT_LOG" 2>&1 status=${PIPESTATUS[0]} set -e diff --git a/scripts/e2e/mcp-connect-timeout.ts b/scripts/e2e/mcp-connect-timeout.ts deleted file mode 100644 index b04dac462852..000000000000 --- a/scripts/e2e/mcp-connect-timeout.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Mcp Connect Timeout script supports OpenClaw repository automation. -type McpConnectTransport = { - close?(): Promise | void; -}; - -const MCP_TIMEOUT_CLOSE_GRACE_MS = 5_000; - -export async function connectMcpWithTimeout( - client: { connect(transport: TTransport): Promise }, - transport: TTransport, - timeoutMs: number, -): Promise { - let timedOut = false; - let timeout: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise((_, reject) => { - timeout = setTimeout(() => { - timedOut = true; - reject(new Error(`MCP stdio connect timed out after ${timeoutMs}ms`)); - }, timeoutMs); - timeout.unref?.(); - }); - - try { - await Promise.race([client.connect(transport), timeoutPromise]); - } catch (error) { - if (timedOut) { - await closeTimedOutTransport(transport); - } - throw error; - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -} - -async function closeTimedOutTransport(transport: McpConnectTransport): Promise { - if (!transport.close) { - return; - } - let timer: NodeJS.Timeout | undefined; - try { - await Promise.race([ - Promise.resolve(transport.close()).catch(() => undefined), - new Promise((resolve) => { - timer = setTimeout(resolve, MCP_TIMEOUT_CLOSE_GRACE_MS); - timer.unref?.(); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - } - } -} diff --git a/scripts/e2e/mcp-websocket-open.ts b/scripts/e2e/mcp-websocket-open.ts deleted file mode 100644 index 50417d623d6f..000000000000 --- a/scripts/e2e/mcp-websocket-open.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Mcp Websocket Open script supports OpenClaw repository automation. -type WebSocketOpenHandle = { - close?: () => void; - off?: (event: "open" | "error" | "close", listener: (...args: unknown[]) => void) => void; - on?: (event: "error", listener: (...args: unknown[]) => void) => void; - once: (event: "open" | "error" | "close", listener: (...args: unknown[]) => void) => void; - terminate?: () => void; -}; - -function formatCloseValue(value: unknown): string { - if (value === undefined || value === null) { - return ""; - } - if (typeof value === "string") { - return value; - } - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - return value.toString(); - } - if (value instanceof Uint8Array) { - return Buffer.from(value).toString(); - } - return JSON.stringify(value) ?? ""; -} - -export function waitForWebSocketOpen( - ws: WebSocketOpenHandle, - timeoutMs: number, - message = "gateway ws open timeout", -): Promise { - return new Promise((resolve, reject) => { - let settled = false; - - const cleanup = () => { - clearTimeout(timer); - ws.off?.("open", onOpen); - ws.off?.("error", onError); - ws.off?.("close", onClose); - }; - const resolveOpen = () => { - if (settled) { - return; - } - settled = true; - cleanup(); - resolve(); - }; - const rejectOpen = (error: unknown) => { - if (settled) { - return; - } - settled = true; - cleanup(); - reject(error instanceof Error ? error : new Error(String(error))); - }; - const onOpen = () => resolveOpen(); - const onError = (error: unknown) => rejectOpen(error); - const onClose = (code?: unknown, reason?: unknown) => { - const closeDetails = [formatCloseValue(code), formatCloseValue(reason)] - .filter(Boolean) - .join(" "); - const suffix = closeDetails ? `: ${closeDetails}` : ""; - rejectOpen(new Error(`closed before open${suffix}`)); - }; - const timer: ReturnType = setTimeout(() => { - const consumeAbortError = () => {}; - const removeAbortErrorConsumer = () => { - ws.off?.("error", consumeAbortError); - ws.off?.("close", removeAbortErrorConsumer); - }; - try { - ws.off?.("error", onError); - ws.off?.("close", onClose); - ws.on?.("error", consumeAbortError); - ws.once?.("close", removeAbortErrorConsumer); - ws.terminate?.(); - if (typeof ws.terminate !== "function") { - ws.close?.(); - } - } finally { - rejectOpen(new Error(message)); - } - }, timeoutMs); - - timer.unref?.(); - ws.once("open", onOpen); - ws.once("error", onError); - ws.once("close", onClose); - }); -} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index f98585c4f16b..406f5c870233 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -895,9 +895,20 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ], ], [ - "scripts/e2e/mcp-channels-docker-client.ts", + "test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts", ["test/scripts/docker-e2e-plan.test.ts", "test/scripts/plugin-prerelease-test-plan.test.ts"], ], + [ + "test/e2e/qa-lab/runtime/mcp-channels.fixture.ts", + [ + "test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts", + "test/scripts/cron-mcp-cleanup-docker-client.test.ts", + ], + ], + [ + "test/e2e/qa-lab/runtime/mcp-client-temp-state.fixture.ts", + ["test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts"], + ], ["scripts/e2e/mcp-channels-seed.ts", ["test/scripts/docker-e2e-seeds.test.ts"]], ["scripts/e2e/docker-openai-seed.ts", ["test/scripts/docker-e2e-seeds.test.ts"]], [ @@ -925,7 +936,6 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "scripts/e2e/lib/mcp-code-mode-probe-server.ts", ["test/scripts/docker-e2e-seeds.test.ts", "test/scripts/mcp-code-mode-gateway-client.test.ts"], ], - ["scripts/e2e/mcp-client-temp-state.ts", ["test/scripts/mcp-channels-harness.test.ts"]], [ "scripts/e2e/cron-cli-docker.sh", ["test/scripts/docker-build-helper.test.ts", "test/scripts/docker-e2e-observability.test.ts"], diff --git a/scripts/e2e/mcp-channels-docker-client.ts b/test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts similarity index 98% rename from scripts/e2e/mcp-channels-docker-client.ts rename to test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts index 0b720271e2bf..4607e9607867 100644 --- a/scripts/e2e/mcp-channels-docker-client.ts +++ b/test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts @@ -1,4 +1,4 @@ -// Mcp Channels Docker Client script supports OpenClaw repository automation. +// MCP channels Docker client drives the QA-owned channel bridge smoke. import { randomUUID } from "node:crypto"; import { assert, @@ -11,11 +11,11 @@ import { type GatewayRpcClient, maybeApprovePendingBridgePairing, waitFor, -} from "./mcp-channels-harness.ts"; +} from "./mcp-channels.fixture.ts"; import { connectMcpClientWithPairingReconnect, createMcpClientTempState, -} from "./mcp-client-temp-state.ts"; +} from "./mcp-client-temp-state.fixture.ts"; function summarizeSessionRows(rows: Array> | undefined) { return (rows ?? []).map((entry) => ({ diff --git a/scripts/e2e/mcp-channels-harness.ts b/test/e2e/qa-lab/runtime/mcp-channels.fixture.ts similarity index 92% rename from scripts/e2e/mcp-channels-harness.ts rename to test/e2e/qa-lab/runtime/mcp-channels.fixture.ts index 330194e9eeda..832eee18abb9 100644 --- a/scripts/e2e/mcp-channels-harness.ts +++ b/test/e2e/qa-lab/runtime/mcp-channels.fixture.ts @@ -1,4 +1,4 @@ -// Shared MCP-channel Docker E2E harness helpers. +// Shared MCP-channel QA/Docker E2E fixture helpers. // The mounted test harness imports packaged dist modules so bridge assertions run // against the OpenClaw npm tarball installed in the functional image. import process from "node:process"; @@ -6,14 +6,20 @@ import { setTimeout as delay } from "node:timers/promises"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { z } from "zod"; -import { PROTOCOL_VERSION } from "../../dist/gateway/protocol/index.js"; -import { formatErrorMessage } from "../../dist/infra/errors.js"; -import { readStringValue } from "../../dist/normalization-core/string-coerce.js"; -import { createGatewayWsClient, type GatewayEventFrame } from "../lib/gateway-ws-client.ts"; -import { resolveGatewaySuccessPayload } from "./lib/gateway-frame-payload.mjs"; -import { readMcpChannelLimits } from "./mcp-channel-limits.ts"; -import { createMcpClientTempState, type McpClientTempState } from "./mcp-client-temp-state.ts"; -import { connectMcpWithTimeout } from "./mcp-connect-timeout.ts"; +import { PROTOCOL_VERSION } from "../../../../dist/gateway/protocol/index.js"; +import { formatErrorMessage } from "../../../../dist/infra/errors.js"; +import { readStringValue } from "../../../../dist/normalization-core/string-coerce.js"; +import { resolveGatewaySuccessPayload } from "../../../../scripts/e2e/lib/gateway-frame-payload.mjs"; +import { readMcpChannelLimits } from "../../../../scripts/e2e/mcp-channel-limits.ts"; +import { + createGatewayWsClient, + type GatewayEventFrame, +} from "../../../../scripts/lib/gateway-ws-client.ts"; +import { + connectMcpWithTimeout, + createMcpClientTempState, + type McpClientTempState, +} from "./mcp-client-temp-state.fixture.ts"; export const ClaudeChannelNotificationSchema = z.object({ method: z.literal("notifications/claude/channel"), diff --git a/scripts/e2e/mcp-client-temp-state.ts b/test/e2e/qa-lab/runtime/mcp-client-temp-state.fixture.ts similarity index 55% rename from scripts/e2e/mcp-client-temp-state.ts rename to test/e2e/qa-lab/runtime/mcp-client-temp-state.fixture.ts index c99b5b1cd4b0..65fa1700f9ed 100644 --- a/scripts/e2e/mcp-client-temp-state.ts +++ b/test/e2e/qa-lab/runtime/mcp-client-temp-state.fixture.ts @@ -1,4 +1,4 @@ -// Mcp Client Temp State script supports OpenClaw repository automation. +// MCP client temp-state helpers used by QA-owned MCP E2E fixtures. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -16,6 +16,12 @@ export type ReconnectableMcpClientHandle = { transport: { close: () => Promise }; }; +type McpConnectTransport = { + close?(): Promise | void; +}; + +const MCP_TIMEOUT_CLOSE_GRACE_MS = 5_000; + export function createMcpClientTempState(params: { gatewayToken: string; tempRoot?: string; @@ -35,6 +41,55 @@ export function createMcpClientTempState(params: { }; } +export async function connectMcpWithTimeout( + client: { connect(transport: TTransport): Promise }, + transport: TTransport, + timeoutMs: number, +): Promise { + let timedOut = false; + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + timedOut = true; + reject(new Error(`MCP stdio connect timed out after ${timeoutMs}ms`)); + }, timeoutMs); + timeout.unref?.(); + }); + + try { + await Promise.race([client.connect(transport), timeoutPromise]); + } catch (error) { + if (timedOut) { + await closeTimedOutTransport(transport); + } + throw error; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +async function closeTimedOutTransport(transport: McpConnectTransport): Promise { + if (!transport.close) { + return; + } + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + Promise.resolve(transport.close()).catch(() => undefined), + new Promise((resolve) => { + timer = setTimeout(resolve, MCP_TIMEOUT_CLOSE_GRACE_MS); + timer.unref?.(); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + export async function connectMcpClientWithPairingReconnect< T extends ReconnectableMcpClientHandle, >(params: { diff --git a/test/scripts/mcp-channels-harness.test.ts b/test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts similarity index 55% rename from test/scripts/mcp-channels-harness.test.ts rename to test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts index 6147bf876042..5f521e92a784 100644 --- a/test/scripts/mcp-channels-harness.test.ts +++ b/test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts @@ -1,15 +1,20 @@ -// Mcp Channels Harness tests cover mcp channels harness script behavior. +// QA Lab MCP gateway transport tests cover script-backed MCP client state. import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { connectMcpClientWithPairingReconnect, + connectMcpWithTimeout, createMcpClientTempState, type McpClientTempState, -} from "../../scripts/e2e/mcp-client-temp-state.js"; +} from "./mcp-client-temp-state.fixture.ts"; + +describe("MCP gateway transport fixture", () => { + afterEach(() => { + vi.useRealTimers(); + }); -describe("mcp-channels harness", () => { it("creates unique client temp state and removes token files on cleanup", () => { const tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-mcp-harness-test-")); try { @@ -97,4 +102,84 @@ describe("mcp-channels harness", () => { tempState.cleanup(); } }); + + it("resolves when the MCP client connects before the timeout", async () => { + const client = { + connect: vi.fn(async () => undefined), + }; + const transport = { + close: vi.fn(), + }; + + await expect(connectMcpWithTimeout(client, transport, 1000)).resolves.toBeUndefined(); + + expect(client.connect).toHaveBeenCalledWith(transport); + expect(transport.close).not.toHaveBeenCalled(); + }); + + it("closes the transport when MCP initialize hangs", async () => { + vi.useFakeTimers(); + const client = { + connect: vi.fn(() => new Promise(() => {})), + }; + const transport = { + close: vi.fn(), + }; + + const result = connectMcpWithTimeout(client, transport, 100); + const rejection = expect(result).rejects.toThrow("MCP stdio connect timed out after 100ms"); + + await vi.advanceTimersByTimeAsync(100); + await rejection; + expect(transport.close).toHaveBeenCalledOnce(); + }); + + it("waits for timed-out transport cleanup before rejecting", async () => { + vi.useFakeTimers(); + let closeSettled = false; + const client = { + connect: vi.fn(() => new Promise(() => {})), + }; + const transport = { + close: vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => { + closeSettled = true; + resolve(); + }, 25); + }), + ), + }; + + const result = connectMcpWithTimeout(client, transport, 100); + const rejection = expect(result).rejects.toThrow("MCP stdio connect timed out after 100ms"); + + await vi.advanceTimersByTimeAsync(100); + expect(transport.close).toHaveBeenCalledOnce(); + expect(closeSettled).toBe(false); + + await vi.advanceTimersByTimeAsync(25); + await rejection; + expect(closeSettled).toBe(true); + }); + + it("keeps the original timeout error when cleanup rejects", async () => { + vi.useFakeTimers(); + const client = { + connect: vi.fn(() => new Promise(() => {})), + }; + const transport = { + close: vi.fn(async () => { + throw new Error("close failed"); + }), + }; + + const result = connectMcpWithTimeout(client, transport, 100); + const rejection = expect(result).rejects.toThrow("MCP stdio connect timed out after 100ms"); + + await vi.advanceTimersByTimeAsync(100); + await rejection; + expect(transport.close).toHaveBeenCalledOnce(); + }); }); diff --git a/test/scripts/mcp-connect-timeout.test.ts b/test/scripts/mcp-connect-timeout.test.ts deleted file mode 100644 index e862c8b2eb8a..000000000000 --- a/test/scripts/mcp-connect-timeout.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Mcp Connect Timeout tests cover mcp connect timeout script behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { connectMcpWithTimeout } from "../../scripts/e2e/mcp-connect-timeout.ts"; - -describe("MCP stdio connect timeout", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("resolves when the MCP client connects before the timeout", async () => { - const client = { - connect: vi.fn(async () => undefined), - }; - const transport = { - close: vi.fn(), - }; - - await expect(connectMcpWithTimeout(client, transport, 1000)).resolves.toBeUndefined(); - - expect(client.connect).toHaveBeenCalledWith(transport); - expect(transport.close).not.toHaveBeenCalled(); - }); - - it("closes the transport when MCP initialize hangs", async () => { - vi.useFakeTimers(); - const client = { - connect: vi.fn(() => new Promise(() => {})), - }; - const transport = { - close: vi.fn(), - }; - - const result = connectMcpWithTimeout(client, transport, 100); - const rejection = expect(result).rejects.toThrow("MCP stdio connect timed out after 100ms"); - - await vi.advanceTimersByTimeAsync(100); - await rejection; - expect(transport.close).toHaveBeenCalledOnce(); - }); - - it("waits for timed-out transport cleanup before rejecting", async () => { - vi.useFakeTimers(); - let closeSettled = false; - const client = { - connect: vi.fn(() => new Promise(() => {})), - }; - const transport = { - close: vi.fn( - () => - new Promise((resolve) => { - setTimeout(() => { - closeSettled = true; - resolve(); - }, 25); - }), - ), - }; - - const result = connectMcpWithTimeout(client, transport, 100); - const rejection = expect(result).rejects.toThrow("MCP stdio connect timed out after 100ms"); - - await vi.advanceTimersByTimeAsync(100); - expect(transport.close).toHaveBeenCalledOnce(); - expect(closeSettled).toBe(false); - - await vi.advanceTimersByTimeAsync(25); - await rejection; - expect(closeSettled).toBe(true); - }); - - it("keeps the original timeout error when cleanup rejects", async () => { - vi.useFakeTimers(); - const client = { - connect: vi.fn(() => new Promise(() => {})), - }; - const transport = { - close: vi.fn(async () => { - throw new Error("close failed"); - }), - }; - - const result = connectMcpWithTimeout(client, transport, 100); - const rejection = expect(result).rejects.toThrow("MCP stdio connect timed out after 100ms"); - - await vi.advanceTimersByTimeAsync(100); - await rejection; - expect(transport.close).toHaveBeenCalledOnce(); - }); -}); diff --git a/test/scripts/mcp-websocket-open.test.ts b/test/scripts/mcp-websocket-open.test.ts deleted file mode 100644 index 44444359e7e9..000000000000 --- a/test/scripts/mcp-websocket-open.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Mcp Websocket Open tests cover mcp websocket open script behavior. -import { EventEmitter } from "node:events"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { waitForWebSocketOpen } from "../../scripts/e2e/mcp-websocket-open.ts"; - -class FakeWebSocket extends EventEmitter { - terminated = false; - closed = false; - - terminate(): void { - this.terminated = true; - queueMicrotask(() => { - this.emit("error", new Error("socket abort after terminate")); - this.emit("close"); - }); - } - - close(): void { - this.closed = true; - } -} - -describe("mcp channel WebSocket open guard", () => { - beforeEach(() => { - vi.useRealTimers(); - }); - - it("consumes abort errors after open timeouts", async () => { - const ws = new FakeWebSocket(); - const keepAlive = setTimeout(() => {}, 100); - - try { - await expect(waitForWebSocketOpen(ws, 1)).rejects.toThrow("gateway ws open timeout"); - } finally { - clearTimeout(keepAlive); - } - await new Promise((resolve) => { - setImmediate(resolve); - }); - - expect(ws.terminated).toBe(true); - expect(ws.listenerCount("open")).toBe(0); - expect(ws.listenerCount("error")).toBe(0); - expect(ws.listenerCount("close")).toBe(0); - }); - - it("cleans listeners after successful opens", async () => { - const ws = new FakeWebSocket(); - const opened = waitForWebSocketOpen(ws, 100); - - ws.emit("open"); - - await expect(opened).resolves.toBeUndefined(); - expect(ws.terminated).toBe(false); - expect(ws.listenerCount("open")).toBe(0); - expect(ws.listenerCount("error")).toBe(0); - expect(ws.listenerCount("close")).toBe(0); - }); - - it("rejects immediately when the socket closes before opening", async () => { - const ws = new FakeWebSocket(); - const opened = waitForWebSocketOpen(ws, 1000); - - ws.emit("close", 1006, Buffer.from("bye")); - - await expect(opened).rejects.toThrow("closed before open: 1006 bye"); - expect(ws.terminated).toBe(false); - expect(ws.listenerCount("open")).toBe(0); - expect(ws.listenerCount("error")).toBe(0); - expect(ws.listenerCount("close")).toBe(0); - }); -}); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index ef2bee910eba..dee00dbe3f44 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -2009,22 +2009,10 @@ describe("scripts/test-projects changed-target routing", () => { "scripts/lib/plugin-npm-release.ts", ["test/plugin-npm-release.test.ts", "test/plugin-clawhub-release.test.ts"], ], - [ - "scripts/plugin-clawhub-release-check.ts", - ["test/scripts/release-wrapper-scripts.test.ts"], - ], - [ - "scripts/plugin-clawhub-release-plan.ts", - ["test/scripts/release-wrapper-scripts.test.ts"], - ], - [ - "scripts/plugin-npm-release-check.ts", - ["test/scripts/release-wrapper-scripts.test.ts"], - ], - [ - "scripts/plugin-npm-release-plan.ts", - ["test/scripts/release-wrapper-scripts.test.ts"], - ], + ["scripts/plugin-clawhub-release-check.ts", ["test/scripts/release-wrapper-scripts.test.ts"]], + ["scripts/plugin-clawhub-release-plan.ts", ["test/scripts/release-wrapper-scripts.test.ts"]], + ["scripts/plugin-npm-release-check.ts", ["test/scripts/release-wrapper-scripts.test.ts"]], + ["scripts/plugin-npm-release-plan.ts", ["test/scripts/release-wrapper-scripts.test.ts"]], [ "scripts/plugin-release-pretag-pack-check.ts", ["test/scripts/plugin-release-pretag-pack-check.test.ts"], @@ -2445,10 +2433,11 @@ describe("scripts/test-projects changed-target routing", () => { it("routes MCP and cron Docker E2E script targets instead of skipping changed tests", () => { const targets = [ "scripts/e2e/mcp-channels-docker.sh", - "scripts/e2e/mcp-channels-docker-client.ts", + "test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts", + "test/e2e/qa-lab/runtime/mcp-channels.fixture.ts", + "test/e2e/qa-lab/runtime/mcp-client-temp-state.fixture.ts", "scripts/e2e/mcp-channels-seed.ts", "scripts/e2e/docker-openai-seed.ts", - "scripts/e2e/mcp-client-temp-state.ts", "scripts/e2e/mcp-code-mode-gateway-docker.sh", "scripts/e2e/mcp-code-mode-gateway-live-docker.sh", "scripts/e2e/mcp-code-mode-gateway-seed.ts", @@ -2469,13 +2458,13 @@ describe("scripts/test-projects changed-target routing", () => { "test/scripts/docker-e2e-observability.test.ts", "test/scripts/docker-e2e-plan.test.ts", "test/scripts/plugin-prerelease-test-plan.test.ts", + "test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts", + "test/scripts/cron-mcp-cleanup-docker-client.test.ts", "test/scripts/docker-e2e-seeds.test.ts", - "test/scripts/mcp-channels-harness.test.ts", "test/scripts/mcp-code-mode-gateway-client.test.ts", "test/scripts/session-log-mentions.test.ts", "src/agents/agent-bundle-mcp-runtime.test.ts", "src/agents/agent-bundle-mcp-tools.materialize.test.ts", - "test/scripts/cron-mcp-cleanup-docker-client.test.ts", "src/gateway/server.cron.test.ts", "src/gateway/server-methods/agent.test.ts", "src/cron/isolated-agent/run.fast-mode.test.ts",