mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
test(duckduckgo): move regressions to search boundary (#123188)
This commit is contained in:
committed by
GitHub
parent
d42c0ae1d5
commit
877a9c4aa8
@@ -212,10 +212,3 @@ export async function runDuckDuckGoSearch(params: {
|
||||
writeCache(DDG_SEARCH_CACHE, cacheKey, payload, cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
decodeHtmlEntities,
|
||||
isBotChallenge,
|
||||
parseDuckDuckGoHtml,
|
||||
readDuckDuckGoHtmlResponse,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ vi.mock("./ddg-client.js", () => ({
|
||||
|
||||
describe("duckduckgo web search provider", () => {
|
||||
let createDuckDuckGoWebSearchProvider: typeof import("./ddg-search-provider.js").createDuckDuckGoWebSearchProvider;
|
||||
let ddgClientTesting: typeof import("./ddg-client.js").testing;
|
||||
let runActualDuckDuckGoSearch: typeof import("./ddg-client.js").runDuckDuckGoSearch;
|
||||
|
||||
afterAll(() => {
|
||||
@@ -24,7 +23,7 @@ describe("duckduckgo web search provider", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js"));
|
||||
({ testing: ddgClientTesting, runDuckDuckGoSearch: runActualDuckDuckGoSearch } =
|
||||
({ runDuckDuckGoSearch: runActualDuckDuckGoSearch } =
|
||||
await vi.importActual<typeof import("./ddg-client.js")>("./ddg-client.js"));
|
||||
await import("../index.js");
|
||||
});
|
||||
@@ -34,6 +33,31 @@ describe("duckduckgo web search provider", () => {
|
||||
runDuckDuckGoSearch.mockImplementation(async (params: Record<string, unknown>) => params);
|
||||
});
|
||||
|
||||
async function runHtmlSearch(query: string, html: string) {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(html, {
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
return await runActualDuckDuckGoSearch({ query, cacheTtlMinutes: 0 });
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
function readSearchResults(payload: Record<string, unknown>) {
|
||||
if (!Array.isArray(payload.results)) {
|
||||
throw new Error("Expected DuckDuckGo search results");
|
||||
}
|
||||
return payload.results as Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
siteName?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
it("exposes keyless metadata and enables the plugin in config", () => {
|
||||
const provider = createDuckDuckGoWebSearchProvider();
|
||||
if (!provider.applySelectionConfig) {
|
||||
@@ -174,14 +198,22 @@ describe("duckduckgo web search provider", () => {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(streamed.response);
|
||||
|
||||
await expect(ddgClientTesting.readDuckDuckGoHtmlResponse(streamed.response)).rejects.toThrow(
|
||||
"DuckDuckGo search: text response exceeds 16777216 bytes",
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
runActualDuckDuckGoSearch({
|
||||
query: "duckduckgo bounded response",
|
||||
cacheTtlMinutes: 0,
|
||||
}),
|
||||
).rejects.toThrow("DuckDuckGo search: text response exceeds 16777216 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads region from plugin config and normalizes empty values away", () => {
|
||||
@@ -254,71 +286,84 @@ describe("duckduckgo web search provider", () => {
|
||||
).toBe("off");
|
||||
});
|
||||
|
||||
it("leaves out-of-range numeric html entities intact instead of throwing", () => {
|
||||
expect(() => ddgClientTesting.decodeHtmlEntities("Result � end")).not.toThrow();
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Result � end")).toBe(
|
||||
"Result � end",
|
||||
it("keeps invalid numeric entities intact in returned results", async () => {
|
||||
const payload = await runHtmlSearch(
|
||||
"duckduckgo invalid numeric entities",
|
||||
`
|
||||
<a class="result__a" href="https://example.com/entities">
|
||||
Result � Hex � Smile 😀
|
||||
</a>
|
||||
<a class="result__snippet">Bad � � �</a>
|
||||
`,
|
||||
);
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Hex � tail")).toBe("Hex � tail");
|
||||
// Surrogate-range entities would decode to lone UTF-16 surrogates; keep them intact.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
// A valid supplementary-plane entity still decodes.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Smile 😀")).toBe("Smile 😀");
|
||||
const [result] = readSearchResults(payload);
|
||||
|
||||
expect(result?.title).toContain("Result � Hex � Smile 😀");
|
||||
// Surrogate-range entities would become lone UTF-16 surrogates; preserve their source text.
|
||||
expect(result?.snippet).toContain("Bad � � �");
|
||||
});
|
||||
|
||||
it("does not double-decode escaped entities (decodes & last)", () => {
|
||||
// A result whose text literally shows "<" arrives double-encoded as
|
||||
// "&lt;". Decoding & first would re-decode it into "<", corrupting
|
||||
// the snippet; & must be decoded last.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("How to escape &lt; in HTML")).toBe(
|
||||
"How to escape < in HTML",
|
||||
it("does not double-decode escaped entities in returned results", async () => {
|
||||
const payload = await runHtmlSearch(
|
||||
"duckduckgo escaped entities",
|
||||
`
|
||||
<a class="result__a" href="https://example.com/escaping">
|
||||
How to escape &lt; in HTML
|
||||
</a>
|
||||
<a class="result__snippet">a&#39;b and a&amp;b</a>
|
||||
`,
|
||||
);
|
||||
expect(ddgClientTesting.decodeHtmlEntities("a&#39;b")).toBe("a'b");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("a&amp;b")).toBe("a&b");
|
||||
const [result] = readSearchResults(payload);
|
||||
|
||||
// Decoding & first would turn the literal "<" into "<" and corrupt the result.
|
||||
expect(result?.title).toContain("How to escape < in HTML");
|
||||
expect(result?.title).not.toContain("How to escape < in HTML");
|
||||
expect(result?.snippet).toContain("a'b and a&b");
|
||||
});
|
||||
|
||||
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 & Co
|
||||
</a>
|
||||
<a class="result__snippet">Fast search … with details</a>
|
||||
<a class="result__a" href="https://example.org/direct">Direct result</a>
|
||||
<a class="result__snippet">Second snippet</a>
|
||||
`;
|
||||
it("returns results when href appears before class", async () => {
|
||||
const payload = await runHtmlSearch(
|
||||
"duckduckgo href ordering",
|
||||
`
|
||||
<a href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com" class="result__a">
|
||||
Example & Co
|
||||
</a>
|
||||
<a class="result__snippet">Fast search … with details</a>
|
||||
<a class="result__a" href="https://example.org/direct">Direct result</a>
|
||||
<a class="result__snippet">Second snippet</a>
|
||||
`,
|
||||
);
|
||||
const results = readSearchResults(payload);
|
||||
|
||||
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",
|
||||
},
|
||||
]);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toMatchObject({ url: "https://example.com", siteName: "example.com" });
|
||||
expect(results[0]?.title).toContain("Example & Co");
|
||||
expect(results[0]?.snippet).toContain("Fast search ... with details");
|
||||
expect(results[1]).toMatchObject({
|
||||
url: "https://example.org/direct",
|
||||
siteName: "example.org",
|
||||
});
|
||||
expect(results[1]?.title).toContain("Direct result");
|
||||
expect(results[1]?.snippet).toContain("Second snippet");
|
||||
});
|
||||
|
||||
it("keeps inline result markup from splitting words", () => {
|
||||
const html = `
|
||||
<a class="result__a" href="https://example.com/cafe">Caf<b>é</b> guide</a>
|
||||
<a class="result__snippet">Find the best caf<b>é</b> near you.</a>
|
||||
`;
|
||||
it("keeps inline result markup from splitting returned words", async () => {
|
||||
const payload = await runHtmlSearch(
|
||||
"duckduckgo inline result markup",
|
||||
`
|
||||
<a class="result__a" href="https://example.com/cafe">Caf<b>é</b> guide</a>
|
||||
<a class="result__snippet">Find the best caf<b>é</b> near you.</a>
|
||||
`,
|
||||
);
|
||||
const [result] = readSearchResults(payload);
|
||||
|
||||
expect(ddgClientTesting.parseDuckDuckGoHtml(html)).toEqual([
|
||||
{
|
||||
title: "Café guide",
|
||||
url: "https://example.com/cafe",
|
||||
snippet: "Find the best café near you.",
|
||||
},
|
||||
]);
|
||||
expect(result?.title).toContain("Café guide");
|
||||
expect(result?.title).not.toContain("Caf é");
|
||||
expect(result?.url).toBe("https://example.com/cafe");
|
||||
expect(result?.snippet).toContain("Find the best café near you.");
|
||||
});
|
||||
|
||||
it("detects bot challenge pages without flagging ordinary result snippets", () => {
|
||||
it("rejects bot challenge pages without flagging ordinary result snippets", async () => {
|
||||
const challengeHtml = `
|
||||
<html>
|
||||
<body>
|
||||
@@ -334,7 +379,13 @@ describe("duckduckgo web search provider", () => {
|
||||
<a class="result__snippet">A fun coding challenge for interview prep.</a>
|
||||
`;
|
||||
|
||||
expect(ddgClientTesting.isBotChallenge(challengeHtml)).toBe(true);
|
||||
expect(ddgClientTesting.isBotChallenge(normalHtml)).toBe(false);
|
||||
await expect(runHtmlSearch("duckduckgo bot challenge", challengeHtml)).rejects.toThrow(
|
||||
"DuckDuckGo returned a bot-detection challenge.",
|
||||
);
|
||||
const normalPayload = await runHtmlSearch("duckduckgo ordinary challenge result", normalHtml);
|
||||
const [result] = readSearchResults(normalPayload);
|
||||
expect(result?.url).toBe("https://example.com/challenge");
|
||||
expect(result?.title).toContain("Coding Challenge");
|
||||
expect(result?.snippet).toContain("A fun coding challenge for interview prep.");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user