From 877a9c4aa81f3bbde843d2f656e481f3c3e0eb24 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 15:07:09 -0700 Subject: [PATCH] test(duckduckgo): move regressions to search boundary (#123188) --- extensions/duckduckgo/src/ddg-client.ts | 7 - .../src/ddg-search-provider.test.ts | 177 +++++++++++------- 2 files changed, 114 insertions(+), 70 deletions(-) diff --git a/extensions/duckduckgo/src/ddg-client.ts b/extensions/duckduckgo/src/ddg-client.ts index 212d909d3ade..d15d7922c983 100644 --- a/extensions/duckduckgo/src/ddg-client.ts +++ b/extensions/duckduckgo/src/ddg-client.ts @@ -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, -}; diff --git a/extensions/duckduckgo/src/ddg-search-provider.test.ts b/extensions/duckduckgo/src/ddg-search-provider.test.ts index b7c1bc07a477..43504c8563c2 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.test.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.test.ts @@ -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("./ddg-client.js")); await import("../index.js"); }); @@ -34,6 +33,31 @@ describe("duckduckgo web search provider", () => { runDuckDuckGoSearch.mockImplementation(async (params: Record) => 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) { + 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", + ` + + Result � Hex � Smile 😀 + + Bad � � � + `, ); - 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", + ` + + How to escape &lt; in HTML + + a&#39;b and a&amp;b + `, ); - 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 = ` - - Example & Co - - Fast search … with details - Direct result - Second snippet - `; + it("returns results when href appears before class", async () => { + const payload = await runHtmlSearch( + "duckduckgo href ordering", + ` + + Example & Co + + Fast search … with details + Direct result + Second snippet + `, + ); + 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 = ` - Café guide - Find the best café near you. - `; + it("keeps inline result markup from splitting returned words", async () => { + const payload = await runHtmlSearch( + "duckduckgo inline result markup", + ` + Café guide + Find the best café near you. + `, + ); + 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 = ` @@ -334,7 +379,13 @@ describe("duckduckgo web search provider", () => { A fun coding challenge for interview prep. `; - 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."); }); });