test(exa): consolidate private helper coverage (#136874)

This commit is contained in:
Dallin Romney 2026-09-03 14:05:26 -07:00 • committed by GitHub
parent f5c254cf95
commit 2d7ce2bbfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 150 additions and 201 deletions

View file

@ -560,13 +560,6 @@ export async function executeExaWebSearchProviderTool(
}
export const testing = {
parseExaContents,
buildExaCacheKey,
resolveExaApiKey,
resolveExaDescription,
resolveExaSearchCount,
resolveExaSearchEndpoint,
resolveFreshnessStartDate,
readExaErrorDetail,
readExaSearchResults,
} as const;

View file

@ -52,6 +52,19 @@ function streamingJsonResponse(params: { chunkCount: number; chunkSize: number }
};
}
type JsonRecord = Record<string, unknown>;
function requireExaTool(webSearch: JsonRecord, searchConfig: JsonRecord = {}) {
const tool = createExaWebSearchProvider().createTool({
config: { plugins: { entries: { exa: { config: { webSearch } } } } },
searchConfig,
} as never);
if (!tool) {
throw new Error("Expected Exa tool definition");
}
return tool;
}
describe("exa web search provider", () => {
it("does not send or cache an already canceled search", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
@ -184,65 +197,145 @@ describe("exa web search provider", () => {
expect(pluginEntry.enabled).toBe(true);
});
it("prefers scoped configured api keys over environment fallbacks", () => {
expect(testing.resolveExaApiKey({ apiKey: "exa-secret" })).toBe("exa-secret");
});
it("resolves Exa search base URL overrides", () => {
expect(testing.resolveExaSearchEndpoint()).toEqual({
endpoint: "https://api.exa.ai/search",
});
expect(testing.resolveExaSearchEndpoint({ baseUrl: "https://proxy.example/exa" })).toEqual({
endpoint: "https://proxy.example/exa/search",
});
expect(testing.resolveExaSearchEndpoint({ baseUrl: "proxy.example/exa/search/" })).toEqual({
endpoint: "https://proxy.example/exa/search",
});
expect(testing.resolveExaSearchEndpoint({ baseUrl: "ftp://proxy.example/exa" })).toEqual({
docs: "https://docs.openclaw.ai/tools/exa-search",
error: "invalid_base_url",
message:
"plugins.entries.exa.config.webSearch.baseUrl must be a valid http(s) URL. Got: ftp://proxy.example/exa",
});
});
it("partitions Exa cache keys by resolved endpoint", () => {
const base = {
type: "auto" as const,
query: "openclaw",
count: 5,
};
expect(
testing.buildExaCacheKey({
...base,
endpoint: "https://api.exa.ai/search",
}),
).not.toBe(
testing.buildExaCacheKey({
...base,
endpoint: "https://proxy.example/exa/search",
}),
it("applies scoped auth, endpoint, contents, freshness, and result normalization at the tool boundary", async () => {
const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now());
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(
JSON.stringify({
results: [
{
url: "https://example.test/highlights",
highlights: ["first", "", "second"],
text: "ignored",
},
{ url: "https://example.test/text", text: "text fallback" },
],
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const tool = requireExaTool(
{ apiKey: "exa-config-key", baseUrl: "https://proxy.example/exa/" },
{ maxResults: 120 },
);
try {
const args = {
query: "Exa boundary",
freshness: "month",
contents: {
text: { maxCharacters: 1200 },
highlights: {
maxCharacters: 4000,
query: "latest model launches",
numSentences: 4,
highlightsPerUrl: 2,
},
summary: { query: "launch details" },
},
};
const result = await tool.execute(args);
const descriptions = (result.results as Array<{ description: string }>).map(
(entry) => entry.description,
);
expect(descriptions[0]?.split("\n---\n")[1]?.split("\n<<<END")[0]).toBe("first\nsecond");
expect(descriptions[1]?.split("\n---\n")[1]?.split("\n<<<END")[0]).toBe("text fallback");
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://proxy.example/exa/search");
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
"x-api-key": "exa-config-key",
});
const bodyAt = (index: number) => {
const body = fetchMock.mock.calls[index]?.[1]?.body;
if (typeof body !== "string") {
throw new Error("Expected Exa JSON request body");
}
return JSON.parse(body);
};
expect(bodyAt(0)).toMatchObject({
query: "Exa boundary",
numResults: 100,
contents: args.contents,
});
expect(Date.parse(bodyAt(0).startPublishedDate)).not.toBeNaN();
await tool.execute({ query: "cache partitions" });
await tool.execute({ query: "cache partitions", contents: { highlights: true } });
expect(fetchMock).toHaveBeenCalledTimes(2);
await tool.execute({ query: "cache partitions", contents: { highlights: false } });
await tool.execute({ query: "cache partitions", contents: { text: false } });
await tool.execute({ query: "cache partitions", contents: { summary: false } });
const defaultTool = requireExaTool({ apiKey: "exa-config-key" }, { maxResults: 120 });
await defaultTool.execute(args);
await requireExaTool(
{ apiKey: "exa-config-key", baseUrl: "proxy.example/exa/search/" },
{ maxResults: 120 },
).execute({ ...args, query: "bare endpoint" });
expect(fetchMock.mock.calls[5]?.[0]).toBe("https://api.exa.ai/search");
expect(fetchMock.mock.calls[6]?.[0]).toBe("https://proxy.example/exa/search");
for (const [count, expected] of [
["+05", 5],
["2e1", 20],
] as const) {
await defaultTool.execute({ query: `count ${count}`, count });
expect(bodyAt(fetchMock.mock.calls.length - 1).numResults).toBe(expected);
}
for (const count of ["0x10", 1.5]) {
await expect(defaultTool.execute({ query: `count ${count}`, count })).rejects.toThrow(
"count must be an integer from 1 to 100",
);
}
const inheritedText = { maxCharacters: 1 };
const inheritedPrototype = Object.defineProperty({}, "query", {
get: () => {
throw new Error("read");
},
});
Object.setPrototypeOf(inheritedText, inheritedPrototype);
await defaultTool.execute({ query: "inherited", contents: { text: inheritedText } });
expect(bodyAt(fetchMock.mock.calls.length - 1).contents).toEqual({
text: { maxCharacters: 1 },
});
} finally {
clock.mockRestore();
fetchMock.mockRestore();
}
});
it("partitions Exa cache keys by effective content options", () => {
const base = {
endpoint: "https://api.exa.ai/search",
type: "auto" as const,
query: "openclaw",
count: 5,
};
const defaultKey = testing.buildExaCacheKey(base);
expect(testing.buildExaCacheKey({ ...base, contents: { highlights: true } })).toBe(defaultKey);
const disabledKeys = [
testing.buildExaCacheKey({ ...base, contents: { highlights: false } }),
testing.buildExaCacheKey({ ...base, contents: { text: false } }),
testing.buildExaCacheKey({ ...base, contents: { summary: false } }),
];
expect(disabledKeys).not.toContain(defaultKey);
expect(new Set(disabledKeys).size).toBe(disabledKeys.length);
it.each([
[
{ baseUrl: "ftp://proxy.example/exa" },
{ query: "invalid endpoint" },
"invalid_base_url",
"plugins.entries.exa.config.webSearch.baseUrl must be a valid http(s) URL. Got: ftp://proxy.example/exa",
],
[
{},
{ query: "invalid contents", contents: { highlights: { numSentences: 0 } } },
"invalid_contents",
"contents.highlights.numSentences must be a positive integer.",
],
[
{},
{ query: "latest gpu news", freshness: "day", date_after: "2026-03-01" },
"conflicting_time_filters",
"freshness cannot be combined with date_after or date_before. Use one time-filter mode.",
],
[
{},
{ query: "latest gpu news", date_after: "2026-02-31" },
"invalid_date",
"date_after must be YYYY-MM-DD format.",
],
])("returns public validation errors", async (webSearch, args, error, message) => {
await expect(
requireExaTool({ apiKey: "exa-test-key", ...webSearch }).execute(args),
).resolves.toEqual({
error,
message,
docs: `https://docs.openclaw.ai/tools/${error === "invalid_base_url" ? "exa-search" : "web"}`,
});
});
it.each([0, 1])("honors the current cache TTL %s", async (cacheTtlMinutes) => {
@ -297,89 +390,8 @@ describe("exa web search provider", () => {
}
});
it("normalizes Exa result descriptions from highlights before text", () => {
expect(
testing.resolveExaDescription({
highlights: ["first", "", "second"],
text: "full text",
}),
).toBe("first\nsecond");
expect(testing.resolveExaDescription({ text: "full text" })).toBe("full text");
});
it("handles month freshness without date overflow", () => {
const iso = testing.resolveFreshnessStartDate("month");
expect(Number.isNaN(Date.parse(iso))).toBe(false);
});
it("accepts current Exa contents object options from the docs", () => {
expect(
testing.parseExaContents({
text: { maxCharacters: 1200 },
highlights: {
maxCharacters: 4000,
query: "latest model launches",
numSentences: 4,
highlightsPerUrl: 2,
},
summary: { query: "launch details" },
}),
).toEqual({
value: {
text: { maxCharacters: 1200 },
highlights: {
maxCharacters: 4000,
query: "latest model launches",
numSentences: 4,
highlightsPerUrl: 2,
},
summary: { query: "launch details" },
},
});
});
it.each([
{ name: "a non-string value", query: { value: 123 } },
{
name: "a throwing getter",
query: {
get() {
throw new Error("Unrelated inherited contents option was accessed");
},
},
},
])("ignores inherited text query with $name", ({ query }) => {
const prototype = Object.defineProperty({}, "query", query);
const text = Object.assign(Object.create(prototype), { maxCharacters: 1 });
expect(testing.parseExaContents({ text })).toEqual({
value: { text: { maxCharacters: 1 } },
});
});
it("rejects invalid Exa contents objects", () => {
expect(
testing.parseExaContents({
highlights: { numSentences: 0 },
}),
).toEqual({
error: "invalid_contents",
message: "contents.highlights.numSentences must be a positive integer.",
docs: "https://docs.openclaw.ai/tools/web",
});
});
it("exposes newer documented Exa search types and count limits", () => {
const provider = createExaWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-secret" } } } } },
},
searchConfig: {},
});
if (!tool) {
throw new Error("Expected tool definition");
}
const tool = requireExaTool({ apiKey: "exa-secret" });
const parameters = tool.parameters as {
properties?: {
@ -397,62 +409,6 @@ describe("exa web search provider", () => {
"deep-reasoning",
"instant",
]);
expect(testing.resolveExaSearchCount(80, 10)).toBe(80);
expect(testing.resolveExaSearchCount(120, 10)).toBe(100);
expect(testing.resolveExaSearchCount("+05", 10)).toBe(5);
expect(testing.resolveExaSearchCount("0x10", 10)).toBe(10);
expect(testing.resolveExaSearchCount("1e2", 10)).toBe(10);
expect(testing.resolveExaSearchCount(1.5, 10)).toBe(10);
});
it("returns validation errors for conflicting time filters", async () => {
const provider = createExaWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-secret" } } } } },
},
searchConfig: {},
});
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "latest gpu news",
freshness: "day",
date_after: "2026-03-01",
});
expect(result).toEqual({
error: "conflicting_time_filters",
message:
"freshness cannot be combined with date_after or date_before. Use one time-filter mode.",
docs: "https://docs.openclaw.ai/tools/web",
});
});
it("returns validation errors for invalid date input", async () => {
const provider = createExaWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-secret" } } } } },
},
searchConfig: {},
});
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "latest gpu news",
date_after: "2026-02-31",
});
expect(result).toEqual({
error: "invalid_date",
message: "date_after must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
});
});
it("reports malformed Exa API JSON with a stable provider error", async () => {