test: collapse search helper suites

This commit is contained in:
Peter Steinberger
2026-03-25 00:41:48 +00:00
parent 83591fabfb
commit f7de5c3b83
9 changed files with 268 additions and 347 deletions

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
describe("duckduckgo plugin", () => {
it("registers a keyless web search provider", () => {
const webSearchProviders: unknown[] = [];
plugin.register({
registerWebSearchProvider(provider: unknown) {
webSearchProviders.push(provider);
},
} as never);
expect(plugin.id).toBe("duckduckgo");
expect(webSearchProviders).toHaveLength(1);
const provider = webSearchProviders[0] as Record<string, unknown>;
expect(provider.id).toBe("duckduckgo");
expect(provider.requiresCredential).toBe(false);
expect(provider.envVars).toEqual([]);
});
});

View File

@@ -1,78 +0,0 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DDG_SAFE_SEARCH, resolveDdgRegion, resolveDdgSafeSearch } from "./config.js";
describe("duckduckgo config", () => {
it("reads region from plugin config", () => {
expect(
resolveDdgRegion({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
region: "de-de",
},
},
},
},
},
} as never),
).toBe("de-de");
});
it("normalizes empty region to undefined", () => {
expect(
resolveDdgRegion({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
region: " ",
},
},
},
},
},
} as never),
).toBeUndefined();
});
it("defaults safeSearch to moderate", () => {
expect(resolveDdgSafeSearch(undefined)).toBe(DEFAULT_DDG_SAFE_SEARCH);
});
it("accepts strict and off safeSearch values", () => {
expect(
resolveDdgSafeSearch({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
safeSearch: "strict",
},
},
},
},
},
} as never),
).toBe("strict");
expect(
resolveDdgSafeSearch({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
safeSearch: "off",
},
},
},
},
},
} as never),
).toBe("off");
});
});

View File

@@ -1,75 +0,0 @@
import { describe, expect, it } from "vitest";
import { __testing } from "./ddg-client.js";
describe("duckduckgo html parsing", () => {
it("decodes direct and redirect urls", () => {
expect(
__testing.decodeDuckDuckGoUrl(
"https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dclaw",
),
).toBe("https://example.com/search?q=claw");
expect(__testing.decodeDuckDuckGoUrl("https://example.com")).toBe("https://example.com");
});
it("decodes common html entities", () => {
expect(__testing.decodeHtmlEntities("Fish &amp; Chips&nbsp;&hellip; &#39;ok&#39;")).toBe(
"Fish & Chips ... 'ok'",
);
});
it("parses results when href appears before class", () => {
const html = `
<a href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com" class="result__a">
Example &amp; Co
</a>
<a class="result__snippet">Fast&nbsp;search &hellip; with details</a>
<a class="result__a" href="https://example.org/direct">Direct result</a>
<a class="result__snippet">Second snippet</a>
`;
expect(__testing.parseDuckDuckGoHtml(html)).toEqual([
{
title: "Example & Co",
url: "https://example.com",
snippet: "Fast search ... with details",
},
{
title: "Direct result",
url: "https://example.org/direct",
snippet: "Second snippet",
},
]);
});
it("returns no results for bot challenge pages", () => {
const html = `
<html>
<body>
<form>
<h1>Are you a human?</h1>
<div class="g-recaptcha">captcha</div>
</form>
</body>
</html>
`;
expect(__testing.isBotChallenge(html)).toBe(true);
expect(__testing.parseDuckDuckGoHtml(html)).toEqual([]);
});
it("does not treat ordinary result snippets mentioning challenge as bot pages", () => {
const html = `
<a class="result__a" href="https://example.com/challenge">Coding Challenge</a>
<a class="result__snippet">A fun coding challenge for interview prep.</a>
`;
expect(__testing.isBotChallenge(html)).toBe(false);
expect(__testing.parseDuckDuckGoHtml(html)).toEqual([
{
title: "Coding Challenge",
url: "https://example.com/challenge",
snippet: "A fun coding challenge for interview prep.",
},
]);
});
});

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DDG_SAFE_SEARCH, resolveDdgRegion, resolveDdgSafeSearch } from "./config.js";
const { runDuckDuckGoSearch } = vi.hoisted(() => ({
runDuckDuckGoSearch: vi.fn(async (params: Record<string, unknown>) => params),
@@ -9,15 +10,42 @@ vi.mock("./ddg-client.js", () => ({
}));
describe("duckduckgo web search provider", () => {
beforeEach(() => {
let createDuckDuckGoWebSearchProvider: typeof import("./ddg-search-provider.js").createDuckDuckGoWebSearchProvider;
let ddgClientTesting: typeof import("./ddg-client.js").__testing;
let plugin: typeof import("../index.js").default;
beforeAll(async () => {
vi.resetModules();
({ createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js"));
({ __testing: ddgClientTesting } =
await vi.importActual<typeof import("./ddg-client.js")>("./ddg-client.js"));
({ default: plugin } = await import("../index.js"));
});
beforeEach(() => {
runDuckDuckGoSearch.mockReset();
runDuckDuckGoSearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
it("exposes keyless metadata and enables the plugin in config", async () => {
const { createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js");
it("registers a keyless web search provider", () => {
const webSearchProviders: unknown[] = [];
plugin.register({
registerWebSearchProvider(provider: unknown) {
webSearchProviders.push(provider);
},
} as never);
expect(plugin.id).toBe("duckduckgo");
expect(webSearchProviders).toHaveLength(1);
const provider = webSearchProviders[0] as Record<string, unknown>;
expect(provider.id).toBe("duckduckgo");
expect(provider.requiresCredential).toBe(false);
expect(provider.envVars).toEqual([]);
});
it("exposes keyless metadata and enables the plugin in config", () => {
const provider = createDuckDuckGoWebSearchProvider();
if (!provider.applySelectionConfig) {
throw new Error("Expected applySelectionConfig to be defined");
@@ -32,7 +60,6 @@ describe("duckduckgo web search provider", () => {
});
it("maps generic tool arguments into DuckDuckGo search params", async () => {
const { createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js");
const provider = createDuckDuckGoWebSearchProvider();
const tool = provider.createTool({
config: { test: true },
@@ -63,4 +90,138 @@ describe("duckduckgo web search provider", () => {
safeSearch: "off",
});
});
it("reads region from plugin config and normalizes empty values away", () => {
expect(
resolveDdgRegion({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
region: "de-de",
},
},
},
},
},
} as never),
).toBe("de-de");
expect(
resolveDdgRegion({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
region: " ",
},
},
},
},
},
} as never),
).toBeUndefined();
});
it("defaults safeSearch to moderate and accepts strict and off", () => {
expect(resolveDdgSafeSearch(undefined)).toBe(DEFAULT_DDG_SAFE_SEARCH);
expect(
resolveDdgSafeSearch({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
safeSearch: "strict",
},
},
},
},
},
} as never),
).toBe("strict");
expect(
resolveDdgSafeSearch({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
safeSearch: "off",
},
},
},
},
},
} as never),
).toBe("off");
});
it("decodes direct and redirect urls plus common html entities", () => {
expect(
ddgClientTesting.decodeDuckDuckGoUrl(
"https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dclaw",
),
).toBe("https://example.com/search?q=claw");
expect(ddgClientTesting.decodeDuckDuckGoUrl("https://example.com")).toBe("https://example.com");
expect(ddgClientTesting.decodeHtmlEntities("Fish &amp; Chips&nbsp;&hellip; &#39;ok&#39;")).toBe(
"Fish & Chips ... 'ok'",
);
});
it("parses results when href appears before class", () => {
const html = `
<a href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com" class="result__a">
Example &amp; Co
</a>
<a class="result__snippet">Fast&nbsp;search &hellip; with details</a>
<a class="result__a" href="https://example.org/direct">Direct result</a>
<a class="result__snippet">Second snippet</a>
`;
expect(ddgClientTesting.parseDuckDuckGoHtml(html)).toEqual([
{
title: "Example & Co",
url: "https://example.com",
snippet: "Fast search ... with details",
},
{
title: "Direct result",
url: "https://example.org/direct",
snippet: "Second snippet",
},
]);
});
it("detects bot challenge pages without flagging ordinary result snippets", () => {
const challengeHtml = `
<html>
<body>
<form>
<h1>Are you a human?</h1>
<div class="g-recaptcha">captcha</div>
</form>
</body>
</html>
`;
const normalHtml = `
<a class="result__a" href="https://example.com/challenge">Coding Challenge</a>
<a class="result__snippet">A fun coding challenge for interview prep.</a>
`;
expect(ddgClientTesting.isBotChallenge(challengeHtml)).toBe(true);
expect(ddgClientTesting.parseDuckDuckGoHtml(challengeHtml)).toEqual([]);
expect(ddgClientTesting.isBotChallenge(normalHtml)).toBe(false);
expect(ddgClientTesting.parseDuckDuckGoHtml(normalHtml)).toEqual([
{
title: "Coding Challenge",
url: "https://example.com/challenge",
snippet: "A fun coding challenge for interview prep.",
},
]);
});
});

View File

@@ -1,86 +0,0 @@
import type { Context } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { convertMessages } from "../../node_modules/@mariozechner/pi-ai/dist/providers/google-shared.js";
import {
asRecord,
expectConvertedRoles,
makeGeminiCliAssistantMessage,
makeGeminiCliModel,
makeGoogleAssistantMessage,
makeModel,
} from "./google-shared.test-helpers.js";
describe("google-shared convertTools", () => {
it("ensures function call comes after user turn, not after model turn", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
{
role: "user",
content: "Hello",
},
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "Hi!" }]),
makeGoogleAssistantMessage(model.id, [
{
type: "toolCall",
id: "call_1",
name: "myTool",
arguments: {},
},
]),
],
} as unknown as Context;
const contents = convertMessages(model, context);
expectConvertedRoles(contents, ["user", "model", "model"]);
const toolCallPart = contents[2].parts?.find(
(part) => typeof part === "object" && part !== null && "functionCall" in part,
);
const toolCall = asRecord(toolCallPart);
expect(toolCall.functionCall).toBeTruthy();
});
it("strips tool call and response ids for google-gemini-cli", () => {
const model = makeGeminiCliModel("gemini-3-flash");
const context = {
messages: [
{
role: "user",
content: "Use a tool",
},
makeGeminiCliAssistantMessage(model.id, [
{
type: "toolCall",
id: "call_1",
name: "myTool",
arguments: { arg: "value" },
thoughtSignature: "dGVzdA==",
},
]),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "myTool",
content: [{ type: "text", text: "Tool result" }],
isError: false,
timestamp: 0,
},
],
} as unknown as Context;
const contents = convertMessages(model, context);
const parts = contents.flatMap((content) => content.parts ?? []);
const toolCallPart = parts.find(
(part) => typeof part === "object" && part !== null && "functionCall" in part,
);
const toolResponsePart = parts.find(
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
);
const toolCall = asRecord(toolCallPart);
const toolResponse = asRecord(toolResponsePart);
expect(asRecord(toolCall.functionCall).id).toBeUndefined();
expect(asRecord(toolResponse.functionResponse).id).toBeUndefined();
});
});

View File

@@ -8,6 +8,8 @@ import {
asRecord,
expectConvertedRoles,
getFirstToolParameters,
makeGeminiCliAssistantMessage,
makeGeminiCliModel,
makeGoogleAssistantMessage,
makeModel,
} from "./google-shared.test-helpers.js";
@@ -285,4 +287,77 @@ describe("google-shared convertMessages", () => {
expect(toolResponse.functionResponse).toBeTruthy();
expect(contents[3].role).toBe("user");
});
it("ensures function call comes after user turn, not after model turn", () => {
const model = makeModel("gemini-1.5-pro");
const context = {
messages: [
{
role: "user",
content: "Hello",
},
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "Hi!" }]),
makeGoogleAssistantMessage(model.id, [
{
type: "toolCall",
id: "call_1",
name: "myTool",
arguments: {},
},
]),
],
} as unknown as Context;
const contents = convertMessages(model, context);
expectConvertedRoles(contents, ["user", "model", "model"]);
const toolCallPart = contents[2].parts?.find(
(part) => typeof part === "object" && part !== null && "functionCall" in part,
);
const toolCall = asRecord(toolCallPart);
expect(toolCall.functionCall).toBeTruthy();
});
it("strips tool call and response ids for google-gemini-cli", () => {
const model = makeGeminiCliModel("gemini-3-flash");
const context = {
messages: [
{
role: "user",
content: "Use a tool",
},
makeGeminiCliAssistantMessage(model.id, [
{
type: "toolCall",
id: "call_1",
name: "myTool",
arguments: { arg: "value" },
thoughtSignature: "dGVzdA==",
},
]),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "myTool",
content: [{ type: "text", text: "Tool result" }],
isError: false,
timestamp: 0,
},
],
} as unknown as Context;
const contents = convertMessages(model, context);
const parts = contents.flatMap((content) => content.parts ?? []);
const toolCallPart = parts.find(
(part) => typeof part === "object" && part !== null && "functionCall" in part,
);
const toolResponsePart = parts.find(
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
);
const toolCall = asRecord(toolCallPart);
const toolResponse = asRecord(toolResponsePart);
expect(asRecord(toolCall.functionCall).id).toBeUndefined();
expect(asRecord(toolResponse.functionResponse).id).toBeUndefined();
});
});

View File

@@ -1,15 +0,0 @@
import { describe, expect, it } from "vitest";
import { __testing } from "./grok-web-search-provider.js";
describe("grok web search provider helpers", () => {
it("prefers configured api keys and resolves grok scoped defaults", () => {
expect(__testing.resolveGrokApiKey({ apiKey: "xai-secret" })).toBe("xai-secret");
expect(__testing.resolveGrokModel()).toBe("grok-4-1-fast");
expect(__testing.resolveGrokInlineCitations()).toBe(false);
});
it("reads grok-specific overrides from scoped config", () => {
expect(__testing.resolveGrokModel({ model: "xai/grok-4-fast" })).toBe("xai/grok-4-fast");
expect(__testing.resolveGrokInlineCitations({ inlineCitations: true })).toBe(true);
});
});

View File

@@ -1,66 +0,0 @@
import { describe, expect, it } from "vitest";
import { __testing } from "./web-search-shared.js";
describe("xai web search shared helpers", () => {
it("uses sane defaults for model and inline citations", () => {
expect(__testing.resolveXaiWebSearchModel()).toBe(__testing.XAI_DEFAULT_WEB_SEARCH_MODEL);
expect(__testing.resolveXaiInlineCitations()).toBe(false);
});
it("reads grok-scoped overrides for model and inline citations", () => {
const searchConfig = {
grok: {
model: "xai/grok-4-fast",
inlineCitations: true,
},
};
expect(__testing.resolveXaiWebSearchModel(searchConfig)).toBe("xai/grok-4-fast");
expect(__testing.resolveXaiInlineCitations(searchConfig)).toBe(true);
});
it("extracts text and deduplicated citations from response output", () => {
expect(
__testing.extractXaiWebSearchContent({
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "hello",
annotations: [
{ type: "url_citation", url: "https://a.test" },
{ type: "url_citation", url: "https://a.test" },
],
},
],
},
],
}),
).toEqual({
text: "hello",
annotationCitations: ["https://a.test"],
});
});
it("builds wrapped payloads with optional inline citations", () => {
expect(
__testing.buildXaiWebSearchPayload({
query: "q",
provider: "grok",
model: "grok-4-fast",
tookMs: 12,
content: "body",
citations: ["https://a.test"],
}),
).toMatchObject({
query: "q",
provider: "grok",
model: "grok-4-fast",
tookMs: 12,
citations: ["https://a.test"],
externalContent: expect.objectContaining({ wrapped: true }),
});
});
});

View File

@@ -4,12 +4,19 @@ import {
} from "openclaw/plugin-sdk/provider-web-search";
import { describe, expect, it } from "vitest";
import { withEnv } from "../../test/helpers/extensions/env.js";
import { __testing as grokProviderTesting } from "./src/grok-web-search-provider.js";
import { __testing } from "./web-search.js";
const { extractXaiWebSearchContent, resolveXaiInlineCitations, resolveXaiWebSearchModel } =
__testing;
describe("xai web search config resolution", () => {
it("prefers configured api keys and resolves grok scoped defaults", () => {
expect(grokProviderTesting.resolveGrokApiKey({ apiKey: "xai-secret" })).toBe("xai-secret");
expect(grokProviderTesting.resolveGrokModel()).toBe("grok-4-1-fast");
expect(grokProviderTesting.resolveGrokInlineCitations()).toBe(false);
});
it("uses config apiKey when provided", () => {
const searchConfig = { grok: { apiKey: "xai-test-key" } }; // pragma: allowlist secret
expect(
@@ -66,6 +73,26 @@ describe("xai web search config resolution", () => {
expect(resolveXaiInlineCitations({ grok: { inlineCitations: true } })).toBe(true);
expect(resolveXaiInlineCitations({ grok: { inlineCitations: false } })).toBe(false);
});
it("builds wrapped payloads with optional inline citations", () => {
expect(
grokProviderTesting.buildXaiWebSearchPayload({
query: "q",
provider: "grok",
model: "grok-4-fast",
tookMs: 12,
content: "body",
citations: ["https://a.test"],
}),
).toMatchObject({
query: "q",
provider: "grok",
model: "grok-4-fast",
tookMs: 12,
citations: ["https://a.test"],
externalContent: expect.objectContaining({ wrapped: true }),
});
});
});
describe("xai web search response parsing", () => {