summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/concurrency.test.ts86
-rw-r--r--test/endpoint.test.ts64
-rw-r--r--test/format.test.ts52
-rw-r--r--test/search-url.test.ts35
4 files changed, 237 insertions, 0 deletions
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<void>): Promise<void> => {
+ 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<typeof resolveCdpTarget>) => {
+ 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");
+});