From 495de0d5283dd3e4a6ef715b596c4a2892e95915 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Mon, 3 Aug 2026 21:18:02 +0100 Subject: Web search for pi through an existing Chrome over CDP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaches to a browser that is already running — never launches one — using the same endpoint configuration as pi-browser-harness, so a single /browser-target choice governs both packages. One tool, castle_cdp_search, deliberately not named web_search so it coexists with pi-web-access rather than shadowing it. Notes from validating against castle's Chrome: - tbs=qdr:*, the parameter Google's own Tools menu writes, renders an empty page on this profile; the older as_qdr=* works. Any date filter combined with udm=14 is also empty, so recency drops udm. - Target.createTarget must not be raced against the abort signal: raceAbort abandons the promise but cannot cancel the command, and the command's side effect is a tab nothing is left holding. - A search cancelled while queued has to be removed from the semaphore queue, or the slot handed to it later is never counted back. - A decaying rate-limit block stops serving /sorry/ and returns an empty results page instead, indistinguishable from a genuine zero-hit search. --- test/concurrency.test.ts | 86 ++++++++++++++++++++++++++++++++++++++++++++++++ test/endpoint.test.ts | 64 +++++++++++++++++++++++++++++++++++ test/format.test.ts | 52 +++++++++++++++++++++++++++++ test/search-url.test.ts | 35 ++++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 test/concurrency.test.ts create mode 100644 test/endpoint.test.ts create mode 100644 test/format.test.ts create mode 100644 test/search-url.test.ts (limited to 'test') diff --git a/test/concurrency.test.ts b/test/concurrency.test.ts new file mode 100644 index 0000000..500fee0 --- /dev/null +++ b/test/concurrency.test.ts @@ -0,0 +1,86 @@ +/** + * The semaphore, exercised through the only door it has: search(). + * + * These run against a deliberately unreachable CDP endpoint. Every search fails + * fast at the connect step, which is exactly what is wanted — the questions here + * are about slot bookkeeping, not about Google. Hitting the real browser to test + * a counter would be slow, flaky, and rude to whoever is using it. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { MAX_CONCURRENT_SEARCHES, disconnect, search } from "../src/search.ts"; + +const UNREACHABLE = "127.0.0.1:1"; // Nothing listens on port 1. +const quiet = () => {}; + +const withUnreachableBrowser = async (body: () => Promise): Promise => { + const saved = process.env["BU_CDP_HTTP"]; + process.env["BU_CDP_HTTP"] = UNREACHABLE; + disconnect(); + try { + await body(); + } finally { + if (saved === undefined) delete process.env["BU_CDP_HTTP"]; + else process.env["BU_CDP_HTTP"] = saved; + disconnect(); + } +}; + +const attempt = (query: string, signal: AbortSignal) => + search({ query, numResults: 1 }, signal, quiet).then( + () => "fulfilled" as const, + (e: Error) => e.name, + ); + +test("a failed search returns its slot, so the next one still runs", async () => { + await withUnreachableBrowser(async () => { + // Three times the cap, run sequentially. If a slot leaked on the failure + // path, the run past the cap would hang until the test timed out. + for (let i = 0; i < MAX_CONCURRENT_SEARCHES * 3; i++) { + const name = await attempt(`q${i}`, AbortSignal.timeout(10_000)); + assert.equal(name, "BrowserUnavailableError", `attempt ${i}`); + } + }); +}); + +test("aborting while queued does not wedge the semaphore", async () => { + await withUnreachableBrowser(async () => { + // Saturate the cap and queue two more, then cancel the queued ones. The + // bug this guards against: a cancelled waiter that is later handed a slot + // resolves into nothing, and the slot is never counted back. + const cancel = new AbortController(); + const running = Array.from({ length: MAX_CONCURRENT_SEARCHES }, (_, i) => + attempt(`running${i}`, AbortSignal.timeout(10_000)), + ); + const queued = Array.from({ length: 2 }, (_, i) => attempt(`queued${i}`, cancel.signal)); + cancel.abort(); + + await Promise.all(running); + for (const outcome of await Promise.all(queued)) { + assert.ok( + outcome === "SearchAbortedError" || outcome === "BrowserUnavailableError", + `unexpected outcome ${outcome}`, + ); + } + + // The real assertion: the pool still works afterwards. A wedged semaphore + // would leave this hanging rather than failing. + for (let i = 0; i < MAX_CONCURRENT_SEARCHES + 1; i++) { + assert.equal(await attempt(`after${i}`, AbortSignal.timeout(10_000)), "BrowserUnavailableError"); + } + }); +}); + +test("an unreachable endpoint is reported, never silently swapped for a local browser", async () => { + await withUnreachableBrowser(async () => { + const error = await search({ query: "q", numResults: 1 }, AbortSignal.timeout(10_000), quiet).then( + () => null, + (e: Error) => e, + ); + assert.ok(error, "expected a throw"); + assert.equal(error.name, "BrowserUnavailableError"); + assert.match(error.message, new RegExp(UNREACHABLE.replace(".", "\\."))); + }); +}); diff --git a/test/endpoint.test.ts b/test/endpoint.test.ts new file mode 100644 index 0000000..f5c8236 --- /dev/null +++ b/test/endpoint.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + DEFAULT_REMOTE_CDP, + LOCAL_CDP_PORT, + describeCdpTarget, + resolveCdpTarget, +} from "../src/endpoint.ts"; + +const ok = (resolution: ReturnType) => { + assert.equal(resolution.kind, "ok"); + if (resolution.kind !== "ok") throw new Error("unreachable"); + return resolution.target; +}; + +test("the built-in default parses — the fallback in resolveCdpTarget is unreachable", () => { + const target = ok(resolveCdpTarget({}, null)); + assert.deepEqual(target, { host: "10.88.0.25", port: 9223, source: "default" }); + assert.equal(`${target.host}:${target.port}`, DEFAULT_REMOTE_CDP); +}); + +test("BU_CDP_HTTP wins over a stored target", () => { + const target = ok(resolveCdpTarget({ BU_CDP_HTTP: "10.88.0.9:9333" }, "10.88.0.25:9223")); + assert.deepEqual(target, { host: "10.88.0.9", port: 9333, source: "env" }); +}); + +test("a stored target is used when the environment is silent", () => { + const target = ok(resolveCdpTarget({}, "192.168.1.4:9222")); + assert.deepEqual(target, { host: "192.168.1.4", port: 9222, source: "stored" }); +}); + +test("an empty BU_CDP_HTTP does not shadow the stored target", () => { + const target = ok(resolveCdpTarget({ BU_CDP_HTTP: " " }, "192.168.1.4:9222")); + assert.equal(target.source, "stored"); +}); + +test("the local aliases all resolve to this machine", () => { + for (const alias of ["local", "localhost", "off", "none", "0", "no", "LOCAL"]) { + const target = ok(resolveCdpTarget({ BU_CDP_HTTP: alias }, null)); + assert.deepEqual(target, { host: "127.0.0.1", port: LOCAL_CDP_PORT, source: "env" }, alias); + } +}); + +test("IPv6-ish and malformed values are rejected rather than half-parsed", () => { + for (const raw of ["nonsense", "host:", ":9223", "host:0", "host:65536", "host:notaport"]) { + const resolution = resolveCdpTarget({ BU_CDP_HTTP: raw }, null); + assert.equal(resolution.kind, "invalid", raw); + } +}); + +test("a host with a port takes the last colon, so hostnames survive", () => { + const target = ok(resolveCdpTarget({ BU_CDP_HTTP: "castle.local:9223" }, null)); + assert.deepEqual(target, { host: "castle.local", port: 9223, source: "env" }); +}); + +test("describeCdpTarget names where the choice came from", () => { + assert.equal( + describeCdpTarget({ host: "10.88.0.25", port: 9223, source: "default" }), + "10.88.0.25:9223 (built-in default)", + ); + assert.equal(describeCdpTarget({ host: "h", port: 1, source: "env" }), "h:1 (BU_CDP_HTTP)"); + assert.equal(describeCdpTarget({ host: "h", port: 1, source: "stored" }), "h:1 (/browser-target)"); +}); diff --git a/test/format.test.ts b/test/format.test.ts new file mode 100644 index 0000000..3060a62 --- /dev/null +++ b/test/format.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { formatResults } from "../src/format.ts"; +import { SearchChallengeError } from "../src/errors.ts"; + +const results = [ + { title: "First", url: "https://example.com/1", snippet: "A snippet." }, + { title: "Second", url: "https://example.com/2", snippet: "" }, +]; + +test("results are numbered, with the URL on its own line", () => { + const text = formatResults({ + query: "q", + 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)", + results, + }); + assert.match(text, /^Search results for "q" \(2 hits, via Chrome at 10\.88\.0\.25:9223 \(built-in default\)\)/); + assert.match(text, /^1\. First$/m); + assert.match(text, /^ {3}https:\/\/example\.com\/1$/m); + assert.match(text, /^2\. Second$/m); + // A missing snippet must not leave a stray blank indented line. + assert.ok(!/\n {3}\n/.test(text)); +}); + +test("a redirect is reported, because the query URL is then not where we looked", () => { + const text = formatResults({ + query: "q", + searchUrl: "https://www.google.com/search?q=q", + finalUrl: "https://www.google.com/search?q=q&sei=abc", + endpoint: "e", + results, + }); + assert.match(text, /^Landed on: https:\/\/www\.google\.com\/search\?q=q&sei=abc$/m); +}); + +test("the challenge error tells the agent to hand off to a human, with the URL", () => { + const err = new SearchChallengeError({ + challenge: "captcha-widget", + pageUrl: "https://www.google.com/sorry/index?continue=x", + endpoint: "10.88.0.25:9223 (built-in default)", + detail: "Our systems have detected unusual traffic", + }); + assert.equal(err.name, "SearchChallengeError"); + assert.ok(err instanceof Error, "must be throwable as an Error"); + assert.match(err.message, /captcha-widget/); + 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/); +}); diff --git a/test/search-url.test.ts b/test/search-url.test.ts new file mode 100644 index 0000000..13f58d1 --- /dev/null +++ b/test/search-url.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildSearchUrl } from "../src/search.ts"; + +const paramsOf = (url: string): URLSearchParams => new URL(url).searchParams; + +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("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("queries with characters that would break a URL are encoded", () => { + 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"); +}); -- cgit v1.3.1