diff options
Diffstat (limited to 'test')
| -rw-r--r-- | test/engine-target.test.ts | 89 | ||||
| -rw-r--r-- | test/format.test.ts | 20 | ||||
| -rw-r--r-- | test/search-url.test.ts | 97 |
3 files changed, 183 insertions, 23 deletions
diff --git a/test/engine-target.test.ts b/test/engine-target.test.ts new file mode 100644 index 0000000..e5ced6d --- /dev/null +++ b/test/engine-target.test.ts @@ -0,0 +1,89 @@ +/** + * Engine selection, and the promise that an unsupported recency window is + * refused rather than dropped. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { ENGINE_ENV_VAR, DEFAULT_ENGINE, resolveEngine } from "../src/engine-target.ts"; +import { enginesSupportingRecency, parseEngineId } from "../src/engines.ts"; +import { selectEngine } from "../src/search.ts"; + +const ok = (r: ReturnType<typeof resolveEngine>) => { + assert.equal(r.kind, "ok"); + if (r.kind !== "ok") throw new Error("unreachable"); + return r; +}; + +test("nothing configured means the built-in default", () => { + const r = ok(resolveEngine(null, {}, null)); + assert.equal(r.id, DEFAULT_ENGINE); + assert.equal(r.source, "default"); +}); + +test("precedence is parameter, then env, then stored", () => { + assert.equal(ok(resolveEngine(null, {}, "bing")).id, "bing"); + assert.equal(ok(resolveEngine(null, { [ENGINE_ENV_VAR]: "brave" }, "bing")).id, "brave"); + // The parameter beating the env var is the deliberate difference from the CDP + // target: it is what lets the agent fall back when an engine is blocked. + const r = ok(resolveEngine("duckduckgo", { [ENGINE_ENV_VAR]: "brave" }, "bing")); + assert.equal(r.id, "duckduckgo"); + assert.equal(r.source, "parameter"); +}); + +test("aliases resolve, including the short ones a human would type", () => { + for (const [raw, expected] of [ + ["ddg", "duckduckgo"], + ["duck", "duckduckgo"], + ["DuckDuckGo", "duckduckgo"], + [" bing ", "bing"], + ["g", "google"], + ["BRAVE", "brave"], + ] as const) { + assert.equal(parseEngineId(raw), expected, raw); + } +}); + +test("an unknown name is reported, never quietly skipped for the next source", () => { + // A typo in the env var must not silently search Google instead. + const r = resolveEngine(null, { [ENGINE_ENV_VAR]: "gooogle" }, "bing"); + assert.equal(r.kind, "invalid"); + if (r.kind !== "invalid") throw new Error("unreachable"); + assert.equal(r.source, "env"); + assert.equal(r.raw, "gooogle"); +}); + +test("blank values fall through rather than counting as a choice", () => { + assert.equal(ok(resolveEngine(" ", { [ENGINE_ENV_VAR]: " " }, "bing")).id, "bing"); +}); + +test("an unsupported recency window is refused, and names engines that support it", async () => { + // Bing has no year window and Brave has no time filter; both must say so + // rather than returning unfiltered results that look filtered. + for (const engine of ["bing", "brave"] as const) { + const error = await selectEngine({ query: "q", numResults: 5, recency: "year", engine }).then( + () => null, + (e: Error) => e, + ); + assert.ok(error, `${engine} should have refused`); + assert.equal(error.name, "RecencyUnsupportedError", engine); + for (const alt of enginesSupportingRecency("year")) assert.match(error.message, new RegExp(alt), engine); + } +}); + +test("windows an engine does support are accepted", async () => { + for (const [engine, recency] of [ + ["google", "year"], + ["duckduckgo", "year"], + ["bing", "month"], + ] as const) { + const chosen = await selectEngine({ query: "q", numResults: 5, recency, engine }); + assert.equal(chosen.engine.id, engine); + } +}); + +test("brave with no recency is fine — the refusal is about the window, not the engine", async () => { + const chosen = await selectEngine({ query: "q", numResults: 5, engine: "brave" }); + assert.equal(chosen.engine.id, "brave"); +}); diff --git a/test/format.test.ts b/test/format.test.ts index 3060a62..74e37e1 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -15,9 +15,16 @@ test("results are numbered, with the URL on its own line", () => { searchUrl: "https://www.google.com/search?q=q", finalUrl: "https://www.google.com/search?q=q", endpoint: "10.88.0.25:9223 (built-in default)", + engine: "Google", + engineSource: "built-in default", results, }); - assert.match(text, /^Search results for "q" \(2 hits, via Chrome at 10\.88\.0\.25:9223 \(built-in default\)\)/); + // The engine has to be named on every result set: the agent may have switched + // engines to get around a block, and two indexes are not interchangeable. + assert.match( + text, + /^Search results for "q" — 2 hits from Google \(built-in default\), via Chrome at 10\.88\.0\.25:9223 \(built-in default\)$/m, + ); assert.match(text, /^1\. First$/m); assert.match(text, /^ {3}https:\/\/example\.com\/1$/m); assert.match(text, /^2\. Second$/m); @@ -31,6 +38,8 @@ test("a redirect is reported, because the query URL is then not where we looked" searchUrl: "https://www.google.com/search?q=q", finalUrl: "https://www.google.com/search?q=q&sei=abc", endpoint: "e", + engine: "Bing", + engineSource: "/search-engine", results, }); assert.match(text, /^Landed on: https:\/\/www\.google\.com\/search\?q=q&sei=abc$/m); @@ -41,6 +50,8 @@ test("the challenge error tells the agent to hand off to a human, with the URL", challenge: "captcha-widget", pageUrl: "https://www.google.com/sorry/index?continue=x", endpoint: "10.88.0.25:9223 (built-in default)", + engine: "Google", + alternatives: ["duckduckgo", "bing", "brave"], detail: "Our systems have detected unusual traffic", }); assert.equal(err.name, "SearchChallengeError"); @@ -49,4 +60,11 @@ test("the challenge error tells the agent to hand off to a human, with the URL", assert.match(err.message, /https:\/\/www\.google\.com\/sorry\/index\?continue=x/); assert.match(err.message, /Ask the user/); assert.match(err.message, /10\.88\.0\.25:9223/); + // Switching engines is the agent's own move and must be offered before the + // human handoff, since a block on one engine says nothing about the others. + for (const alt of ["duckduckgo", "bing", "brave"]) assert.match(err.message, new RegExp(alt)); + assert.ok( + err.message.indexOf("retry the same query with engine") < err.message.indexOf("Ask the user"), + "the self-service fallback must come before the human handoff", + ); }); diff --git a/test/search-url.test.ts b/test/search-url.test.ts index 13f58d1..2425f76 100644 --- a/test/search-url.test.ts +++ b/test/search-url.test.ts @@ -1,35 +1,88 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { RECENCY_VALUES, allEngines, getEngine, type Recency } from "../src/engines.ts"; import { buildSearchUrl } from "../src/search.ts"; -const paramsOf = (url: string): URLSearchParams => new URL(url).searchParams; +const urlFor = (id: Parameters<typeof getEngine>[0], recency?: Recency) => + new URL(buildSearchUrl(getEngine(id), { query: "sqlite wal mode", numResults: 7, recency })); -test("a plain query asks for the Web tab in English", () => { - const url = buildSearchUrl({ query: "typebox json schema", numResults: 10 }); - assert.equal(new URL(url).origin + new URL(url).pathname, "https://www.google.com/search"); - const p = paramsOf(url); - assert.equal(p.get("q"), "typebox json schema"); - assert.equal(p.get("num"), "10"); - assert.equal(p.get("hl"), "en"); - assert.equal(p.get("udm"), "14"); - assert.equal(p.get("tbs"), null); +test("google uses the Web tab, and swaps it for as_qdr when time-limited", () => { + const plain = urlFor("google"); + assert.equal(plain.origin + plain.pathname, "https://www.google.com/search"); + assert.equal(plain.searchParams.get("udm"), "14"); + assert.equal(plain.searchParams.get("num"), "7"); + assert.equal(plain.searchParams.get("as_qdr"), null); + + const dated = urlFor("google", "month"); + // Both findings in one assertion pair: tbs is never used, and udm must be + // dropped when a date filter is present or the page renders empty. + assert.equal(dated.searchParams.get("as_qdr"), "m"); + assert.equal(dated.searchParams.get("udm"), null); + assert.equal(dated.searchParams.get("tbs"), null); +}); + +test("duckduckgo uses the no-JS html endpoint and df=", () => { + const plain = urlFor("duckduckgo"); + assert.equal(plain.origin + plain.pathname, "https://html.duckduckgo.com/html/"); + assert.equal(plain.searchParams.get("q"), "sqlite wal mode"); + assert.equal(plain.searchParams.get("df"), null); + + for (const [recency, code] of [ + ["day", "d"], + ["week", "w"], + ["month", "m"], + ["year", "y"], + ] as const) { + assert.equal(urlFor("duckduckgo", recency).searchParams.get("df"), code, recency); + } +}); + +test("bing uses count=, and drops it when filtering because count cancels filters", () => { + const plain = urlFor("bing"); + assert.equal(plain.origin + plain.pathname, "https://www.bing.com/search"); + assert.equal(plain.searchParams.get("count"), "7"); + assert.equal(plain.searchParams.get("filters"), null); + + for (const [recency, code] of [ + ["day", "ez1"], + ["week", "ez2"], + ["month", "ez3"], + ] as const) { + const dated = urlFor("bing", recency); + assert.equal(dated.searchParams.get("filters"), `ex1:"${code}"`, recency); + // Observed: sending count alongside filters silently returns unfiltered + // results that look entirely plausible. This assertion is the regression. + assert.equal(dated.searchParams.get("count"), null, `${recency}: count must be dropped`); + } }); -test("recency uses as_qdr and drops udm — tbs and udm both render an empty page", () => { - const cases = { day: "d", week: "w", month: "m", year: "y" } as const; - for (const [recency, expected] of Object.entries(cases)) { - const url = buildSearchUrl({ query: "q", numResults: 5, recency: recency as keyof typeof cases }); - const p = paramsOf(url); - assert.equal(p.get("as_qdr"), expected, recency); - assert.equal(p.get("tbs"), null, `${recency}: tbs must not be used`); - assert.equal(p.get("udm"), null, `${recency}: udm must be dropped alongside a date filter`); +test("brave is a plain query — it advertises no time filter at all", () => { + const plain = urlFor("brave"); + assert.equal(plain.origin + plain.pathname, "https://search.brave.com/search"); + assert.equal(plain.searchParams.get("q"), "sqlite wal mode"); + assert.deepEqual(getEngine("brave").recency, {}); +}); + +test("every advertised recency window maps to a non-empty parameter value", () => { + // Guards the failure this whole design exists to prevent: an engine claiming + // support for a window it then silently drops from the URL. + for (const engine of allEngines()) { + for (const recency of RECENCY_VALUES) { + const code = engine.recency[recency]; + if (code === undefined) continue; + assert.ok(code.length > 0, `${engine.id}/${recency} maps to an empty value`); + const url = buildSearchUrl(engine, { query: "q", numResults: 5, recency }); + assert.ok(url.includes(encodeURIComponent(code)) || url.includes(code), `${engine.id}/${recency} lost its value`); + } } }); -test("queries with characters that would break a URL are encoded", () => { +test("queries with characters that would break a URL are encoded, on every engine", () => { const query = 'site:example.com "exact phrase" a&b?c=d #frag +plus/slash'; - const url = buildSearchUrl({ query, numResults: 3 }); - assert.equal(paramsOf(url).get("q"), query); - assert.ok(!url.includes(" "), "no raw spaces in the URL"); + for (const engine of allEngines()) { + const url = buildSearchUrl(engine, { query, numResults: 3 }); + assert.equal(new URL(url).searchParams.get("q"), query, engine.id); + assert.ok(!url.includes(" "), `${engine.id}: no raw spaces in the URL`); + } }); |
