summaryrefslogtreecommitdiff
path: root/test/concurrency.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'test/concurrency.test.ts')
-rw-r--r--test/concurrency.test.ts86
1 files changed, 86 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(".", "\\.")));
+ });
+});