diff --git a/src/cloud.ts b/src/cloud.ts index bde8d25..329069c 100644 --- a/src/cloud.ts +++ b/src/cloud.ts @@ -1,7 +1,7 @@ // App-runtime helpers for the Browser Use cloud browser. These run in eve's app // runtime (full process.env), NOT in the sandbox, so BROWSER_USE_API_KEY never // leaves the host. Backed by the official `browser-use-sdk`. -import { BrowserUse } from "browser-use-sdk"; +import { BrowserUse, BrowserUseError } from "browser-use-sdk"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -76,6 +76,7 @@ export async function stopCloudBrowser(id: string): Promise { await client().browsers.stop(id); } catch (err) { // A browser already stopped/expired should not fail teardown. + if (!(err instanceof BrowserUseError && err.statusCode === 404)) throw err; if (process.env.BROWSER_USE_EVE_DEBUG) console.error("stopCloudBrowser", err); } } diff --git a/test/cloud.test.ts b/test/cloud.test.ts index 17001bb..3c5d0c2 100644 --- a/test/cloud.test.ts +++ b/test/cloud.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserUseError } from "browser-use-sdk"; // Mock the Cloud SDK: a BrowserUse instance exposing a `browsers` resource. // vi.hoisted so the mocks exist when the hoisted vi.mock factory runs. @@ -7,7 +8,8 @@ const { create, get, stop } = vi.hoisted(() => ({ get: vi.fn(), stop: vi.fn(), })); -vi.mock("browser-use-sdk", () => ({ +vi.mock("browser-use-sdk", async (importOriginal) => ({ + ...(await importOriginal()), // A real class so `new BrowserUse({ apiKey })` yields an instance whose // `browsers` resource is our spies (vi.fn-as-constructor drops the returned obj). BrowserUse: class { @@ -92,13 +94,26 @@ describe("stopCloudBrowser", () => { expect(stop).toHaveBeenCalledWith("b1"); }); - it("swallows errors so teardown is idempotent", async () => { - stop.mockRejectedValue(new Error("already stopped")); + it("swallows the SDK's not-found error so teardown is idempotent", async () => { + stop.mockRejectedValue(new BrowserUseError(404, "Session not found")); await expect(stopCloudBrowser("b1")).resolves.toBeUndefined(); }); - it("does not throw when the API key is missing", async () => { + it.each([401, 403, 422, 429, 500, 503])("propagates HTTP %i", async (status) => { + const error = new BrowserUseError(status, "stop failed"); + stop.mockRejectedValue(error); + await expect(stopCloudBrowser("b1")).rejects.toBe(error); + }); + + it("propagates transport errors even if they look like a not-found error", async () => { + const error = Object.assign(new Error("transport failed"), { statusCode: 404 }); + stop.mockRejectedValue(error); + await expect(stopCloudBrowser("b1")).rejects.toBe(error); + }); + + it("throws before calling the SDK when the API key is missing", async () => { delete process.env.BROWSER_USE_API_KEY; - await expect(stopCloudBrowser("b1")).resolves.toBeUndefined(); + await expect(stopCloudBrowser("b1")).rejects.toThrow("BROWSER_USE_API_KEY is not set"); + expect(stop).not.toHaveBeenCalled(); }); }); diff --git a/test/stop-cloud-browser.test.ts b/test/stop-cloud-browser.test.ts new file mode 100644 index 0000000..0a69f6f --- /dev/null +++ b/test/stop-cloud-browser.test.ts @@ -0,0 +1,117 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import stopTool from "../src/tools/stop-cloud-browser.js"; + +// Use the real SDK and eve.defineTool with a loopback HTTP fixture. The sandbox +// adapter executes the tool's two exact commands against owned temporary files. +const exec = promisify(execFile); +const nativeFetch = globalThis.fetch; +const id = "00000000-0000-4000-8000-000000000051"; +let directory: string; +let origin: string; +let statuses: (number | "disconnect")[]; +let requests = 0; +const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + expect(req.method).toBe("PATCH"); + expect(req.url).toBe(`/api/v2/browsers/${id}`); + expect(JSON.parse(body)).toEqual({ action: "stop" }); + expect(req.headers["x-browser-use-api-key"]).toBe("synthetic-stop-test"); + requests++; + const status = statuses.shift(); + if (status === "disconnect") return req.socket.destroy(); + res.writeHead(status ?? 500, { "content-type": "application/json", connection: "close" }); + res.end(JSON.stringify(status === 200 ? { + id, status: "stopped", liveUrl: null, cdpUrl: null, + startedAt: "2026-09-07T10:00:00Z", timeoutAt: "2026-09-07T12:00:00Z", + finishedAt: "2026-09-07T10:01:00Z", proxyUsedMb: "0", proxyCost: "0", browserCost: "0", + } : { detail: status === 404 ? "Session not found" : "Synthetic stop error" })); +}); +const idPath = () => join(directory, ".bu-browser-id"); +const context = { + getSandbox: async () => ({ + run: async ({ command }: { command: string }) => { + expect([ + "cat /workspace/.bu-browser-id 2>/dev/null || true", + "rm -f /workspace/.bu-browser-id", + ]).toContain(command); + const localCommand = command.replace("/workspace/.bu-browser-id", JSON.stringify(idPath())); + const result = await exec("/bin/sh", ["-c", localCommand], { cwd: directory, env: { PATH: "/usr/bin:/bin" } }); + return { ...result, exitCode: 0 }; + }, + }), + // Other ToolContext methods are unused by stop_cloud_browser. +} as unknown as Parameters>[1]; +const execute = () => stopTool.execute!({}, context); + +beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), "eve-stop-test-")); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No fixture address"); + origin = `http://127.0.0.1:${address.port}`; +}); +beforeEach(async () => { + await writeFile(idPath(), id); + statuses = []; + requests = 0; + vi.stubEnv("BROWSER_USE_API_KEY", "synthetic-stop-test"); + vi.stubEnv("BROWSER_USE_X402_PRIVATE_KEY", ""); + vi.stubEnv("BROWSER_USE_EVE_DEBUG", ""); + vi.stubGlobal("fetch", (input: string, init: RequestInit) => { + // No upstream URL can leave this process, even if the code changes. + expect(input).toBe(`https://api.browser-use.com/api/v2/browsers/${id}`); + expect(init.method).toBe("PATCH"); + return nativeFetch(`${origin}/api/v2/browsers/${id}`, init); + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); +}); + +it.each([200, 404])("clears the stored ID on HTTP %i", async (status) => { + statuses = [status]; + await expect(execute()).resolves.toEqual({ ok: true, stopped: id }); + await expect(readFile(idPath(), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + expect(requests).toBe(1); +}); + +it.each([401, 403, 422, 500, 503, "disconnect"] as const)("keeps the ID and permits retry after %s", async (status) => { + statuses = [status, 200]; + await expect(execute()).rejects.toThrow(); + expect(await readFile(idPath(), "utf8")).toBe(id); + await expect(execute()).resolves.toEqual({ ok: true, stopped: id }); + await expect(readFile(idPath(), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + expect(requests).toBe(2); +}); + +it("keeps the ID when the API key is missing", async () => { + vi.stubEnv("BROWSER_USE_API_KEY", ""); + await expect(execute()).rejects.toThrow("BROWSER_USE_API_KEY is not set"); + expect(await readFile(idPath(), "utf8")).toBe(id); + expect(requests).toBe(0); +}); + +it("does not need a key or make a request without a stored browser", async () => { + await rm(idPath()); + vi.stubEnv("BROWSER_USE_API_KEY", ""); + await expect(execute()).resolves.toEqual({ ok: true, note: "No cloud browser was open." }); + expect(requests).toBe(0); +}); + +it("preserves the SDK's existing 429 retry before clearing the ID", async () => { + statuses = [429, 200]; + await expect(execute()).resolves.toEqual({ ok: true, stopped: id }); + expect(requests).toBe(2); +});