refactor(parallel): consolidate web search fixtures (#118068)

* test(parallel): consolidate web search fixtures

* test(parallel): preserve MCP protocol fallback coverage
This commit is contained in:
Peter Steinberger
2026-08-02 10:07:52 -07:00
committed by GitHub
parent ad53d4419e
commit c40180bc44
3 changed files with 434 additions and 979 deletions
@@ -1,185 +0,0 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
type EndpointCall = {
url: string;
timeoutSeconds: number;
init: RequestInit;
};
const endpointMockState = vi.hoisted(() => ({
calls: [] as EndpointCall[],
responses: [] as Response[],
}));
function requireEndpointCall(index: number): EndpointCall {
return expectDefined(endpointMockState.calls[index], `Parallel endpoint call ${index}`);
}
vi.mock("openclaw/plugin-sdk/provider-web-search", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-web-search")>();
const runEndpoint = async (
params: EndpointCall,
run: (response: Response) => Promise<unknown>,
) => {
endpointMockState.calls.push(params);
const response = endpointMockState.responses.shift();
if (!response) {
throw new Error("Missing mocked Parallel MCP response.");
}
return await run(response);
};
return {
...actual,
withTrustedWebSearchEndpoint: vi.fn(runEndpoint),
};
});
import { createParallelFreeWebSearchProvider } from "./parallel-free-web-search-provider.js";
function jsonResponse(body: unknown, headers?: Record<string, string>): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json", ...headers },
});
}
function pushHandshake(toolPayload: unknown): void {
endpointMockState.responses.push(
jsonResponse(
{ jsonrpc: "2.0", id: "i", result: { protocolVersion: "2025-06-18" } },
{
"mcp-session-id": "sess-1",
},
),
jsonResponse({ jsonrpc: "2.0" }),
jsonResponse({
jsonrpc: "2.0",
id: "c",
result: { content: [{ type: "text", text: JSON.stringify(toolPayload) }] },
}),
);
}
describe("parallel-free web search provider", () => {
beforeEach(() => {
endpointMockState.calls = [];
endpointMockState.responses = [];
});
it("exposes keyless metadata without claiming auto-detect fallback", () => {
const provider = createParallelFreeWebSearchProvider();
expect(provider.id).toBe("parallel-free");
expect(provider.label).toBe("Parallel Search (Free)");
expect(provider.requiresCredential).toBe(false);
expect(provider.envVars).toEqual([]);
expect(provider.autoDetectOrder).toBeUndefined();
});
it("advertises the shared count contract and free MCP's tighter session_id cap", () => {
const provider = createParallelFreeWebSearchProvider();
const tool = provider.createTool({ config: {}, searchConfig: {} });
if (!tool) {
throw new Error("Expected tool definition");
}
const sessionIdParam = (
tool.parameters as { properties: Record<string, { maxLength?: number }> }
).properties.session_id;
expect(expectDefined(sessionIdParam, "Parallel session_id parameter").maxLength).toBe(100);
const countParam = (
tool.parameters as {
properties: Record<string, { type?: string; minimum?: number; maximum?: number }>;
}
).properties.count;
expect(countParam).toMatchObject({ type: "integer", minimum: 1, maximum: 40 });
});
it("searches via the free MCP and brands the result, with no API key", async () => {
// No PARALLEL_API_KEY needed — the free path ignores keys entirely.
vi.stubEnv("PARALLEL_API_KEY", "par-should-be-ignored"); // pragma: allowlist secret
pushHandshake({
search_id: "s1",
results: [
{
url: "https://example.com",
title: "Example",
publish_date: "2024-01-01",
excerpts: ["hi"],
},
],
});
const provider = createParallelFreeWebSearchProvider();
const tool = provider.createTool({ config: {}, searchConfig: {} });
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
objective: "find examples",
search_queries: ["example"],
});
// Three MCP calls (initialize -> notifications -> tools/call) to the free MCP.
expect(endpointMockState.calls).toHaveLength(3);
const firstCall = requireEndpointCall(0);
expect(firstCall.url).toBe("https://search.parallel.ai/mcp");
// No bearer token on the anonymous free path.
expect((firstCall.init.headers as Record<string, string>).Authorization).toBeUndefined();
expect(result).toMatchObject({ provider: "parallel-free" });
expect(Array.isArray(result.results)).toBe(true);
expect((result.results as unknown[]).length).toBe(1);
vi.unstubAllEnvs();
});
it("drops an over-limit caller session id and mints one within the free MCP's 100-char cap", async () => {
pushHandshake({ search_id: "s1", results: [] });
const provider = createParallelFreeWebSearchProvider();
const tool = provider.createTool({ config: {}, searchConfig: {} });
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({
objective: "session cap check",
search_queries: ["session cap"],
session_id: "x".repeat(150),
});
const toolsCallArgs = (
JSON.parse(requireEndpointCall(2).init.body as string).params as Record<string, unknown>
).arguments as Record<string, unknown>;
const sentSessionId = toolsCallArgs.session_id as string;
// The 150-char caller id is out-of-contract for the free MCP; it is dropped
// and replaced by a generated id that stays within the advertised 100-char cap.
expect(sentSessionId).not.toBe("x".repeat(150));
expect(sentSessionId.length).toBeLessThanOrEqual(100);
});
it("returns a structured error when search_queries is missing", async () => {
const provider = createParallelFreeWebSearchProvider();
const tool = provider.createTool({ config: {}, searchConfig: {} });
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({ objective: "x" });
expect(result.error).toBe("invalid_search_queries");
expect(endpointMockState.calls).toHaveLength(0);
});
it("rejects invalid counts before calling the free MCP", async () => {
const provider = createParallelFreeWebSearchProvider();
const tool = provider.createTool({ config: {}, searchConfig: {} });
if (!tool) {
throw new Error("Expected tool definition");
}
for (const count of [4.5, "3abc", 41]) {
await expect(
tool.execute({
objective: "Count validation",
search_queries: ["count validation"],
count,
}),
).rejects.toThrow("count must be an integer from 1 to 40.");
}
expect(endpointMockState.calls).toHaveLength(0);
});
});
@@ -1,334 +0,0 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
type EndpointCall = {
url: string;
timeoutSeconds: number;
init: RequestInit;
};
const endpointMockState = vi.hoisted(() => ({
calls: [] as EndpointCall[],
responses: [] as Response[],
}));
vi.mock("openclaw/plugin-sdk/provider-web-search", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-web-search")>();
const runEndpoint = async (
params: EndpointCall,
run: (response: Response) => Promise<unknown>,
) => {
endpointMockState.calls.push(params);
const response = endpointMockState.responses.shift();
if (!response) {
throw new Error("Missing mocked Parallel MCP response.");
}
return await run(response);
};
return {
...actual,
withTrustedWebSearchEndpoint: vi.fn(runEndpoint),
};
});
import { runParallelMcpSearch } from "./parallel-mcp-search.runtime.js";
function jsonResponse(body: unknown, headers?: Record<string, string>): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json", ...headers },
});
}
function rawResponse(body: string, contentType: string): Response {
return new Response(body, {
status: 200,
headers: { "Content-Type": contentType },
});
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function readBody(call: EndpointCall): Record<string, unknown> {
if (typeof call.init.body !== "string") {
throw new Error("Expected a JSON string body.");
}
return JSON.parse(call.init.body) as Record<string, unknown>;
}
function headerOf(call: EndpointCall, name: string): string | undefined {
return (call.init.headers as Record<string, string>)[name];
}
function requireEndpointCall(index: number): EndpointCall {
return expectDefined(endpointMockState.calls[index], `Parallel MCP endpoint call ${index}`);
}
describe("runParallelMcpSearch", () => {
beforeEach(() => {
endpointMockState.calls = [];
endpointMockState.responses = [];
});
it("handles SSE notifications, multiline events, JSON batches, and structured payloads", async () => {
endpointMockState.responses.push(
rawResponse(
[
'data: {"jsonrpc":"2.0","method":"notifications/progress"}',
"",
'data: {"jsonrpc":"2.0","id":"ignored",',
'data: "result":{"protocolVersion":"2025-06-18"}}',
"",
].join("\n"),
"text/event-stream",
),
jsonResponse({ jsonrpc: "2.0" }),
jsonResponse([
{ jsonrpc: "2.0", method: "notifications/progress" },
{
jsonrpc: "2.0",
id: "ignored",
result: {
structuredContent: {
search_id: "search_sse",
results: [{ url: "https://example.com", title: "Example", excerpts: ["hi"] }],
},
},
},
]),
);
await expect(
runParallelMcpSearch({ searchQueries: ["test"], maxResults: 5 }),
).resolves.toMatchObject({
search_id: "search_sse",
results: [{ url: "https://example.com", title: "Example" }],
});
});
it.each([
[{ error: { code: -1, message: "boom" } }, "Parallel MCP error"],
[{ result: { isError: true } }, "Parallel MCP tool error"],
[{ result: { content: [] } }, "Parallel MCP returned no parseable content"],
])("surfaces bounded tool-envelope failures", async (envelope, expectedPrefix) => {
const detail = `${"x".repeat(600)}😀tail`;
const detailedEnvelope =
"error" in envelope
? { error: { ...envelope.error, detail } }
: { result: { ...envelope.result, detail } };
endpointMockState.responses.push(
jsonResponse({ result: { protocolVersion: "2025-06-18" } }),
jsonResponse({}),
jsonResponse(detailedEnvelope),
);
await expect(runParallelMcpSearch({ searchQueries: ["test"], maxResults: 5 })).rejects.toThrow(
expectedPrefix,
);
});
it("runs the 3-step handshake and maps results into the REST-compatible shape", async () => {
endpointMockState.responses.push(
jsonResponse(
{ jsonrpc: "2.0", id: "ignored", result: { protocolVersion: "2025-06-18" } },
{ "mcp-session-id": "server-session-1" },
),
jsonResponse({ jsonrpc: "2.0" }), // notifications/initialized ack
jsonResponse({
jsonrpc: "2.0",
id: "ignored",
result: {
content: [
{
type: "text",
text: JSON.stringify({
search_id: "search_abc",
results: [
{
url: "https://example.com",
title: "Example",
publish_date: "2024-01-01",
excerpts: ["hi"],
},
{ url: "https://second.com", title: "Second", excerpts: ["yo"] },
],
}),
},
],
},
}),
);
const response = await runParallelMcpSearch({
objective: "find examples",
searchQueries: ["example query"],
maxResults: 1,
modelName: "claude-opus-4-8",
});
// 3 HTTP calls: initialize, notifications/initialized, tools/call.
expect(endpointMockState.calls.map((c) => readBody(c).method)).toEqual([
"initialize",
"notifications/initialized",
"tools/call",
]);
// Server session id + a negotiated protocol version are echoed post-init.
expect(headerOf(requireEndpointCall(1), "Mcp-Session-Id")).toBe("server-session-1");
expect(headerOf(requireEndpointCall(2), "Mcp-Session-Id")).toBe("server-session-1");
expect(headerOf(requireEndpointCall(2), "MCP-Protocol-Version")).toBe("2025-06-18");
// No bearer token on the anonymous free path.
expect(headerOf(requireEndpointCall(0), "Authorization")).toBeUndefined();
// Every call identifies OpenClaw at the HTTP layer (not just node).
for (const call of endpointMockState.calls) {
expect(headerOf(call, "User-Agent")).toMatch(/^openclaw-parallel\//);
}
// tools/call carries the documented web_search args.
const callArgs = (readBody(requireEndpointCall(2)).params as Record<string, unknown>)
.arguments as Record<string, unknown>;
expect(callArgs).toMatchObject({
objective: "find examples",
search_queries: ["example query"],
model_name: "claude-opus-4-8",
});
expect(typeof callArgs.session_id).toBe("string");
// maxResults applied client-side; mapped to the REST-compatible response.
expect(response.search_id).toBe("search_abc");
expect(response.results).toHaveLength(1);
expect(response.results[0]).toMatchObject({ url: "https://example.com", title: "Example" });
});
it("uses the search queries as the objective when none was supplied", async () => {
endpointMockState.responses.push(
jsonResponse({ jsonrpc: "2.0", id: "i", result: {} }, { "mcp-session-id": "s" }),
jsonResponse({ jsonrpc: "2.0" }),
jsonResponse({
jsonrpc: "2.0",
id: "c",
result: { content: [{ type: "text", text: JSON.stringify({ results: [] }) }] },
}),
);
await runParallelMcpSearch({ searchQueries: ["alpha", "beta"], maxResults: 5 });
const callArgs = (readBody(requireEndpointCall(2)).params as Record<string, unknown>)
.arguments as Record<string, unknown>;
expect(callArgs.objective).toBe("alpha beta");
});
it("forwards a caller-supplied session id verbatim (no re-minting)", async () => {
endpointMockState.responses.push(
jsonResponse({ jsonrpc: "2.0", id: "i", result: {} }, { "mcp-session-id": "s" }),
jsonResponse({ jsonrpc: "2.0" }),
jsonResponse({
jsonrpc: "2.0",
id: "c",
result: { content: [{ type: "text", text: JSON.stringify({ results: [] }) }] },
}),
);
// The MCP client is a dumb transport: an already-normalized caller id (the
// provider runtime caps it at the free MCP's 100-char limit) is forwarded as
// sent, so the MCP session, cache key, and reported id stay in agreement.
const callerSessionId = `sess-${"a".repeat(40)}`;
const response = await runParallelMcpSearch({
searchQueries: ["x"],
maxResults: 5,
sessionId: callerSessionId,
});
const callArgs = (readBody(requireEndpointCall(2)).params as Record<string, unknown>)
.arguments as Record<string, unknown>;
expect(callArgs.session_id).toBe(callerSessionId);
expect(response.session_id).toBe(callerSessionId);
});
it("throws when initialize fails", async () => {
endpointMockState.responses.push(new Response("nope", { status: 500 }));
await expect(runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 })).rejects.toThrow(
/initialize failed \(500\)/,
);
});
it("throws when the initialized acknowledgement fails", async () => {
endpointMockState.responses.push(
jsonResponse(
{ jsonrpc: "2.0", id: "i", result: { protocolVersion: "2025-06-18" } },
{ "mcp-session-id": "server-session-1" },
),
new Response("ack nope", { status: 500 }),
);
await expect(runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 })).rejects.toThrow(
/notifications\/initialized failed \(500\): ack nope/,
);
expect(endpointMockState.calls.map((c) => readBody(c).method)).toEqual([
"initialize",
"notifications/initialized",
]);
expect(headerOf(requireEndpointCall(1), "Mcp-Session-Id")).toBe("server-session-1");
expect(headerOf(requireEndpointCall(1), "MCP-Protocol-Version")).toBe("2025-06-18");
});
it("bounds initialize error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"parallel mcp unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(tracked.response);
const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch(
(cause: unknown) => cause,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/initialize failed \(503\): parallel mcp unavailable/);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds successful MCP bodies without using response.text()", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "Content-Type": "application/json" },
});
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(streamed.response);
const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch(
(cause: unknown) => cause,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"Parallel MCP: text response exceeds 16777216 bytes",
);
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff