diff --git a/packages/docs/v4/reference/stagehand.mdx b/packages/docs/v4/reference/stagehand.mdx index b4408f9ad..48bca0858 100644 --- a/packages/docs/v4/reference/stagehand.mdx +++ b/packages/docs/v4/reference/stagehand.mdx @@ -61,26 +61,6 @@ const status = await stagehand.ping(); -## runtimeLoopbackStatus() - -Inspect the runtime loopback connection status. - -```typescript -const status = await stagehand.runtimeLoopbackStatus(); -``` - - - The operation result. - - - Whether loopback is configured. - - - - Whether loopback is connected. - - - ## browserGetVersion() Read version information from the connected browser. @@ -544,25 +524,6 @@ status = await stagehand.ping() -## runtime_loopback_status() - -Inspect the runtime loopback connection status. - -```python -status = await stagehand.runtime_loopback_status() -``` - - - The operation result. - - - Whether loopback is configured. - - - - Whether loopback is connected. - - ## browser_get_version() diff --git a/packages/protocol/schema-registry.ts b/packages/protocol/schema-registry.ts index e2d91a3cd..7be939377 100644 --- a/packages/protocol/schema-registry.ts +++ b/packages/protocol/schema-registry.ts @@ -89,9 +89,6 @@ import { PageWaitForSelectorParamsSchema, PageWaitForSelectorResultSchema, PageWaitForTimeoutParamsSchema, - RuntimeConfigureParamsSchema, - RuntimeConfigureResultSchema, - RuntimeLoopbackStatusResultSchema, StagehandActParamsSchema, StagehandCloseResultSchema, StagehandExtractParamsSchema, @@ -111,16 +108,6 @@ export const StagehandSendToHostBindingSchema = z export const StagehandMethods = { ping: { name: "ping", params: EmptyParamsSchema, result: StagehandPingResultSchema }, - runtimeConfigure: { - name: "runtime.configure", - params: RuntimeConfigureParamsSchema, - result: RuntimeConfigureResultSchema, - }, - runtimeLoopbackStatus: { - name: "runtime.loopback_status", - params: EmptyParamsSchema, - result: RuntimeLoopbackStatusResultSchema, - }, browserGetVersion: { name: "browser.get_version", params: EmptyParamsSchema, diff --git a/packages/protocol/schemas.ts b/packages/protocol/schemas.ts index d709fda25..cb7d48f40 100644 --- a/packages/protocol/schemas.ts +++ b/packages/protocol/schemas.ts @@ -1404,10 +1404,14 @@ export const TelemetryConfigSchema = z export const StagehandInitParamsSchema = z .object({ + protocolVersion: z.literal(STAGEHAND_PROTOCOL_VERSION), + clientInfo: ImplementationInfoSchema, + browserCdpUrl: z.string().min(1).optional(), apiKey: z.string().min(1).optional(), browser: BrowserbaseBrowserSourceSchema.optional(), model: z.union([ModelConfigSchema, ClientModelReferenceSchema]).optional(), telemetry: TelemetryConfigSchema.default(DEFAULT_TELEMETRY_CONFIG), + logLevel: z.enum(["off", "error", "warn", "info", "debug"]).default("info"), systemPrompt: z.string().optional(), selfHeal: z.boolean().optional(), domSettleTimeoutMs: z.number().int().positive().optional(), @@ -1419,17 +1423,6 @@ export const StagehandInitParamsSchema = z .strict() .meta({ id: "StagehandInitParams" }); -export const RuntimeConfigureParamsSchema = z - .object({ - protocolVersion: z.int().positive().optional(), - clientInfo: ImplementationInfoSchema.optional(), - cdpUrl: z.string().min(1), - telemetry: TelemetryConfigSchema.default(DEFAULT_TELEMETRY_CONFIG), - logLevel: z.enum(["off", "error", "warn", "info", "debug"]).default("info"), - }) - .strict() - .meta({ id: "RuntimeConfigureParams" }); - export const StagehandActParamsSchema = z .object({ pageId: z.string().min(1), @@ -1863,21 +1856,6 @@ export const StagehandPingResultSchema = z .strict() .meta({ id: "StagehandPingResult" }); -export const RuntimeConfigureResultSchema = z - .object({ - configured: z.literal(true), - }) - .strict() - .meta({ id: "RuntimeConfigureResult" }); - -export const RuntimeLoopbackStatusResultSchema = z - .object({ - configured: z.boolean(), - connected: z.boolean(), - }) - .strict() - .meta({ id: "RuntimeLoopbackStatusResult" }); - export const BrowserGetVersionResultSchema = z .object({ protocolVersion: z.string().optional(), diff --git a/packages/protocol/stagehand.v4.json b/packages/protocol/stagehand.v4.json index 32294180d..e44432f3b 100644 --- a/packages/protocol/stagehand.v4.json +++ b/packages/protocol/stagehand.v4.json @@ -19,32 +19,6 @@ "required": ["params", "result"], "additionalProperties": false }, - "runtime.configure": { - "type": "object", - "properties": { - "params": { - "$ref": "#/$defs/RuntimeConfigureParams" - }, - "result": { - "$ref": "#/$defs/RuntimeConfigureResult" - } - }, - "required": ["params", "result"], - "additionalProperties": false - }, - "runtime.loopback_status": { - "type": "object", - "properties": { - "params": { - "$ref": "#/$defs/EmptyParams" - }, - "result": { - "$ref": "#/$defs/RuntimeLoopbackStatusResult" - } - }, - "required": ["params", "result"], - "additionalProperties": false - }, "browser.get_version": { "type": "object", "properties": { @@ -880,8 +854,6 @@ }, "required": [ "ping", - "runtime.configure", - "runtime.loopback_status", "browser.get_version", "stagehand.init", "stagehand.close", @@ -1010,109 +982,6 @@ "required": ["ok", "runtime"], "additionalProperties": false }, - "RuntimeConfigureParams": { - "type": "object", - "properties": { - "protocol_version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "client_info": { - "$ref": "#/$defs/ImplementationInfo" - }, - "cdp_url": { - "type": "string", - "minLength": 1 - }, - "telemetry": { - "default": { - "traces": { - "endpoint": "https://example.com/v1/traces", - "headers": {} - } - }, - "$ref": "#/$defs/TelemetryConfig" - }, - "log_level": { - "default": "info", - "type": "string", - "enum": ["off", "error", "warn", "info", "debug"] - } - }, - "required": ["cdp_url"], - "additionalProperties": false - }, - "ImplementationInfo": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "version": { - "type": "string", - "minLength": 1 - } - }, - "required": ["name", "version"], - "additionalProperties": false - }, - "TelemetryConfig": { - "type": "object", - "properties": { - "traces": { - "$ref": "#/$defs/TelemetryTraces" - } - }, - "required": ["traces"], - "additionalProperties": false - }, - "TelemetryTraces": { - "type": "object", - "properties": { - "endpoint": { - "type": "string", - "format": "uri" - }, - "headers": { - "default": {}, - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["endpoint"], - "additionalProperties": false - }, - "RuntimeConfigureResult": { - "type": "object", - "properties": { - "configured": { - "type": "boolean", - "const": true - } - }, - "required": ["configured"], - "additionalProperties": false - }, - "RuntimeLoopbackStatusResult": { - "type": "object", - "properties": { - "configured": { - "type": "boolean" - }, - "connected": { - "type": "boolean" - } - }, - "required": ["configured", "connected"], - "additionalProperties": false - }, "BrowserGetVersionResult": { "type": "object", "properties": { @@ -1137,6 +1006,17 @@ "StagehandInitParams": { "type": "object", "properties": { + "protocol_version": { + "type": "number", + "const": 1 + }, + "client_info": { + "$ref": "#/$defs/ImplementationInfo" + }, + "browser_cdp_url": { + "type": "string", + "minLength": 1 + }, "api_key": { "type": "string", "minLength": 1 @@ -1163,6 +1043,11 @@ }, "$ref": "#/$defs/TelemetryConfig" }, + "log_level": { + "default": "info", + "type": "string", + "enum": ["off", "error", "warn", "info", "debug"] + }, "system_prompt": { "type": "string" }, @@ -1179,6 +1064,22 @@ "$ref": "#/$defs/Caching" } }, + "required": ["protocol_version", "client_info"], + "additionalProperties": false + }, + "ImplementationInfo": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + } + }, + "required": ["name", "version"], "additionalProperties": false }, "BrowserbaseBrowserSource": { @@ -1545,6 +1446,37 @@ "required": ["source"], "additionalProperties": false }, + "TelemetryConfig": { + "type": "object", + "properties": { + "traces": { + "$ref": "#/$defs/TelemetryTraces" + } + }, + "required": ["traces"], + "additionalProperties": false + }, + "TelemetryTraces": { + "type": "object", + "properties": { + "endpoint": { + "type": "string", + "format": "uri" + }, + "headers": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["endpoint"], + "additionalProperties": false + }, "Caching": { "anyOf": [ { @@ -4395,60 +4327,6 @@ "required": ["jsonrpc", "id", "method", "params"], "additionalProperties": false }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "const": "2.0" - }, - "id": { - "$ref": "#/$defs/JSONRPCRequestId" - }, - "method": { - "type": "string", - "const": "runtime.configure" - }, - "params": { - "$ref": "#/$defs/RuntimeConfigureParams" - }, - "traceparent": { - "type": "string" - }, - "tracestate": { - "type": "string" - } - }, - "required": ["jsonrpc", "id", "method", "params"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "const": "2.0" - }, - "id": { - "$ref": "#/$defs/JSONRPCRequestId" - }, - "method": { - "type": "string", - "const": "runtime.loopback_status" - }, - "params": { - "$ref": "#/$defs/EmptyParams" - }, - "traceparent": { - "type": "string" - }, - "tracestate": { - "type": "string" - } - }, - "required": ["jsonrpc", "id", "method", "params"], - "additionalProperties": false - }, { "type": "object", "properties": { diff --git a/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts b/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts index b2871e5b3..d962f1ee9 100644 --- a/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts +++ b/packages/protocol/tests/browser-runtime/rpc-client-smoke.test.ts @@ -9,6 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { z } from "zod/v4"; import { connectRPCClient, type RPCClient } from "../../../sdk-ts/src/rpcClient.js"; import { StagehandMethods } from "../../schema-registry.js"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../schemas.js"; import type { StagehandRpcNotification } from "../../types.js"; const stagehandExtensionDistDir = new URL("../../../server/dist", import.meta.url).pathname; @@ -34,7 +35,12 @@ describe("Stagehand service worker RPC client smoke", () => { serviceWorkerUrlIncludes: "service-worker.js", discoveryTimeoutMs: 15_000, commandTimeoutMs: 15_000, + }); + await rpcClient.send(StagehandMethods.stagehandInit, { + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, logLevel: "debug", + browserCdpUrl: rpcClient.browserWebSocketDebuggerUrl ?? `http://127.0.0.1:${chrome.port}`, }); }, 45_000); @@ -68,8 +74,8 @@ describe("Stagehand service worker RPC client smoke", () => { jsonrpc: "2.0", method: "stagehand.log", params: { - level: "debug", - message: "runtime.configure", + level: "info", + message: "stagehand.init", data: {}, }, }); diff --git a/packages/protocol/tests/protocol/client-llm.test.ts b/packages/protocol/tests/protocol/client-llm.test.ts index 02944b2ed..a66c7ea7c 100644 --- a/packages/protocol/tests/protocol/client-llm.test.ts +++ b/packages/protocol/tests/protocol/client-llm.test.ts @@ -4,6 +4,7 @@ import { StagehandMethods } from "../../schema-registry.js"; import { LLMGenerateParamsSchema, LLMGenerateResultSchema, + STAGEHAND_PROTOCOL_VERSION, StagehandInitParamsSchema, } from "../../schemas.js"; @@ -39,6 +40,8 @@ describe("client-side LLM protocol", () => { it("selects a serializable client model during Stagehand initialization", () => { expect( StagehandInitParamsSchema.parse({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, model: { source: "client", }, diff --git a/packages/protocol/tests/protocol/generated-schema-integrity.test.ts b/packages/protocol/tests/protocol/generated-schema-integrity.test.ts index ec848eca2..78fac9c15 100644 --- a/packages/protocol/tests/protocol/generated-schema-integrity.test.ts +++ b/packages/protocol/tests/protocol/generated-schema-integrity.test.ts @@ -92,7 +92,7 @@ describe("generated Stagehand schema integrity", () => { const methods = asRecord(asRecord(asRecord(protocol.properties).methods).properties); const telemetryDefault = DEFAULT_TELEMETRY_CONFIG; - for (const methodName of ["runtime.configure", "stagehand.init"]) { + for (const methodName of ["stagehand.init"]) { const method = asRecord(methods[methodName]); const params = resolveLocalReference( protocol, diff --git a/packages/protocol/tests/protocol/loopback-protocol.test.ts b/packages/protocol/tests/protocol/loopback-protocol.test.ts index 2b7d864f2..d9f83c919 100644 --- a/packages/protocol/tests/protocol/loopback-protocol.test.ts +++ b/packages/protocol/tests/protocol/loopback-protocol.test.ts @@ -2,46 +2,18 @@ import { describe, expect, it } from "vitest"; import { StagehandMethods, StagehandRpcRequestSchema } from "../../schema-registry.js"; describe("Stagehand loopback protocol", () => { - it("defines runtime.configure as a JSON-RPC method", () => { - const params = StagehandMethods.runtimeConfigure.params.parse({ - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }); - - expect(params).toStrictEqual({ - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, - }); - - expect( - StagehandMethods.runtimeConfigure.result.parse({ - configured: true, - }), - ).toStrictEqual({ - configured: true, - }); - }); - - it("rejects runtime.configure without a CDP URL", () => { - expect(() => StagehandMethods.runtimeConfigure.params.parse({})).toThrow(); - }); - - it("defines runtime.loopback_status as a JSON-RPC method", () => { - expect(StagehandMethods.runtimeLoopbackStatus.params.parse({})).toStrictEqual({}); - expect( - StagehandMethods.runtimeLoopbackStatus.result.parse({ - configured: true, - connected: false, + it("does not expose a separate runtime.configure method", () => { + expect(Object.values(StagehandMethods).map((method) => method.name)).not.toContain( + "runtime.configure", + ); + expect(() => + StagehandRpcRequestSchema.parse({ + jsonrpc: "2.0", + id: 1, + method: "runtime.configure", + params: {}, }), - ).toStrictEqual({ - configured: true, - connected: false, - }); + ).toThrow(); }); it("defines browser.get_version as a JSON-RPC method", () => { @@ -68,37 +40,12 @@ describe("Stagehand loopback protocol", () => { StagehandRpcRequestSchema.parse({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, - }), - ).toStrictEqual({ - jsonrpc: "2.0", - id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, - }, - }); - - expect( - StagehandRpcRequestSchema.parse({ - jsonrpc: "2.0", - id: 2, method: "browser.get_version", params: {}, }), ).toStrictEqual({ jsonrpc: "2.0", - id: 2, + id: 1, method: "browser.get_version", params: {}, }); diff --git a/packages/protocol/tests/protocol/object-model-protocol.test.ts b/packages/protocol/tests/protocol/object-model-protocol.test.ts index 52e44be8e..583a6ab73 100644 --- a/packages/protocol/tests/protocol/object-model-protocol.test.ts +++ b/packages/protocol/tests/protocol/object-model-protocol.test.ts @@ -6,6 +6,7 @@ import { StagehandRpcNotificationSchema, StagehandRpcRequestSchema, } from "../../schema-registry.js"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../schemas.js"; describe("Stagehand object-model protocol", () => { it("derives every Stagehand method name from the RPC definitions", () => { @@ -16,6 +17,9 @@ describe("Stagehand object-model protocol", () => { it("defines stagehand init as a JSON-RPC method", () => { const params = StagehandMethods.stagehandInit.params.parse({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", apiKey: "bb_key", browser: { type: "browserbase", @@ -27,6 +31,9 @@ describe("Stagehand object-model protocol", () => { }); expect(params).toStrictEqual({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", apiKey: "bb_key", browser: { type: "browserbase", @@ -66,6 +73,8 @@ describe("Stagehand object-model protocol", () => { it("rejects model names without a provider prefix", () => { expect(() => StagehandMethods.stagehandInit.params.parse({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, model: { modelName: "gpt-5-mini" }, }), ).toThrow(); @@ -543,8 +552,10 @@ describe("Stagehand object-model protocol", () => { it("accepts telemetry configuration as protocol data", () => { expect( - StagehandMethods.runtimeConfigure.params.parse({ - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", + StagehandMethods.stagehandInit.params.parse({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + browserCdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", telemetry: { traces: { endpoint: "https://otel.example.com/v1/traces", diff --git a/packages/protocol/tests/protocol/wire-casing.test.ts b/packages/protocol/tests/protocol/wire-casing.test.ts index 8638b2987..1c2805c3b 100644 --- a/packages/protocol/tests/protocol/wire-casing.test.ts +++ b/packages/protocol/tests/protocol/wire-casing.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod/v4"; import { encodeWireValue, toWireJsonSchema, wireSchema } from "../../json-rpc/wire-casing.js"; import { StagehandNotifications, StagehandMethods } from "../../schema-registry.js"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../schemas.js"; const snakeCaseKey = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; const snakeCaseMethodSegment = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; @@ -334,6 +335,9 @@ describe("JSON-RPC wire casing", () => { it("preserves arbitrary map keys while encoding nested configuration", () => { const definition = StagehandMethods.stagehandInit; const apiValue = { + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info" as const, apiKey: "bb_key", browser: { type: "browserbase" as const, @@ -354,6 +358,9 @@ describe("JSON-RPC wire casing", () => { }; const wireValue = { + protocol_version: STAGEHAND_PROTOCOL_VERSION, + client_info: { name: "stagehand-sdk-ts", version: "4.0.0" }, + log_level: "info", api_key: "bb_key", browser: { type: "browserbase", diff --git a/packages/protocol/tests/rpc-client/cdp-client.test.ts b/packages/protocol/tests/rpc-client/cdp-client.test.ts index f515b2c38..87ade6920 100644 --- a/packages/protocol/tests/rpc-client/cdp-client.test.ts +++ b/packages/protocol/tests/rpc-client/cdp-client.test.ts @@ -5,7 +5,7 @@ import { resolveBrowserWebSocketUrl, StagehandRuntimeIncompatibleError, waitForPreloadedStagehandServiceWorker, - waitForRuntimeReady, + waitForRuntimeReceiver, waitForServiceWorker, } from "../../../sdk-ts/src/cdpClient.ts"; @@ -394,7 +394,7 @@ describe("waitForPreloadedStagehandServiceWorker", () => { }); }); -describe("waitForRuntimeReady", () => { +describe("waitForRuntimeReceiver", () => { it("resolves when the attached runtime exposes the Stagehand marker and RPC receiver", async () => { const cdp = new FakeCdp().on("Runtime.evaluate", () => ({ result: { @@ -403,7 +403,7 @@ describe("waitForRuntimeReady", () => { })); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { timeout: 1_000, delayFn: async () => {}, }), @@ -442,7 +442,7 @@ describe("waitForRuntimeReady", () => { })); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { pollIntervalMs: 5, timeout: 100, nowFn: () => now, @@ -470,7 +470,7 @@ describe("waitForRuntimeReady", () => { })); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { pollIntervalMs: 1, timeout: 2, nowFn: () => now, @@ -478,7 +478,7 @@ describe("waitForRuntimeReady", () => { now += ms; }, }), - ).rejects.toThrow("Timed out waiting for the Stagehand extension runtime to become ready"); + ).rejects.toThrow("Timed out waiting for the Stagehand extension runtime RPC receiver"); }); it("keeps retrying when readiness evaluation throws", async () => { @@ -498,7 +498,7 @@ describe("waitForRuntimeReady", () => { const cdp = new FakeCdp().on("Runtime.evaluate", () => results.shift() ?? {}); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { pollIntervalMs: 1, timeout: 10, nowFn: () => now, @@ -516,7 +516,7 @@ describe("waitForRuntimeReady", () => { const cdp = new FakeCdp().on("Runtime.evaluate", () => ({})); const error = await rejectedError( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { pollIntervalMs: 1, timeout: 1, nowFn: () => now, @@ -538,7 +538,7 @@ describe("waitForRuntimeReady", () => { })); const error = await rejectedError( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { pollIntervalMs: 1, timeout: 1, nowFn: () => now, @@ -550,7 +550,7 @@ describe("waitForRuntimeReady", () => { expect(error).not.toBeInstanceOf(StagehandRuntimeIncompatibleError); expect(error.message).toContain( - "Timed out waiting for the Stagehand extension runtime to become ready", + "Timed out waiting for the Stagehand extension runtime RPC receiver", ); expect(error.message).toContain("protocolVersion=3"); expect(error.message).not.toContain("undefined"); @@ -562,7 +562,7 @@ describe("waitForRuntimeReady", () => { })); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { allowFallbackInstall: false, timeout: 1_000, nowFn: () => 0, @@ -582,7 +582,7 @@ describe("waitForRuntimeReady", () => { })); await expect( - waitForRuntimeReady(cdp, "worker-session", { + waitForRuntimeReceiver(cdp, "worker-session", { timeout: 1_000, delayFn: async () => {}, }), diff --git a/packages/protocol/tests/rpc-client/options.test.ts b/packages/protocol/tests/rpc-client/options.test.ts index 2b91b1271..6a499dab7 100644 --- a/packages/protocol/tests/rpc-client/options.test.ts +++ b/packages/protocol/tests/rpc-client/options.test.ts @@ -11,13 +11,6 @@ describe("RPCClientOptionsSchema", () => { ).toStrictEqual({ cdpUrl: "http://127.0.0.1:9222", extensionDir: "/tmp/stagehand-extension", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, }); }); @@ -30,13 +23,6 @@ describe("RPCClientOptionsSchema", () => { ).toStrictEqual({ cdpUrl: "http://127.0.0.1:9222", extensionId: "abcdefghijklmnopabcdefghijklmnop", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, }); }); @@ -48,19 +34,12 @@ describe("RPCClientOptionsSchema", () => { }), ).toStrictEqual({ cdpUrl: "wss://connect.browserbase.com/devtools/browser/session", - logLevel: "info", preloadedExtension: true, - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, }); }); - it("accepts a custom OTLP traces destination", () => { - expect( + it("rejects telemetry because it belongs to stagehand.init", () => { + expect(() => RPCClientOptionsSchema.parse({ cdpUrl: "http://127.0.0.1:9222", extensionId: "abcdefghijklmnopabcdefghijklmnop", @@ -71,14 +50,7 @@ describe("RPCClientOptionsSchema", () => { }, }, }), - ).toMatchObject({ - telemetry: { - traces: { - endpoint: "https://collector.example.com/v1/traces", - headers: { Authorization: "Bearer test" }, - }, - }, - }); + ).toThrow(); }); it("rejects options without an explicit extension load mode", () => { diff --git a/packages/protocol/types.ts b/packages/protocol/types.ts index ba16a9234..cc0062e9b 100644 --- a/packages/protocol/types.ts +++ b/packages/protocol/types.ts @@ -169,10 +169,7 @@ import type { PageWaitForSelectorResultSchema, PageWaitForTimeoutParamsSchema, ProxyConfigSchema, - RuntimeConfigureParamsSchema, - RuntimeConfigureResultSchema, RuntimeDescriptorSchema, - RuntimeLoopbackStatusResultSchema, RgbaColorSchema, StagehandActParamsSchema, StagehandCloseResultSchema, @@ -273,7 +270,6 @@ export type StagehandInitParams = z.infer; export type TelemetryConfig = z.infer; export type ImplementationInfo = z.infer; export type RuntimeDescriptor = z.infer; -export type RuntimeConfigureParams = z.infer; export type StagehandActParams = z.infer; export type StagehandObserveParams = z.infer; export type StagehandExtractParams = z.infer; @@ -324,8 +320,6 @@ export type LocatorSendClickEventParams = z.infer; export type LocatorSelectOptionParams = z.infer; export type StagehandPingResult = z.infer; -export type RuntimeConfigureResult = z.infer; -export type RuntimeLoopbackStatusResult = z.infer; export type BrowserGetVersionResult = z.infer; export type StagehandInitResult = z.infer; export type StagehandCloseResult = z.infer; diff --git a/packages/sdk-python/src/stagehand/__init__.py b/packages/sdk-python/src/stagehand/__init__.py index 8a33047e8..4cc7e414b 100644 --- a/packages/sdk-python/src/stagehand/__init__.py +++ b/packages/sdk-python/src/stagehand/__init__.py @@ -25,7 +25,6 @@ MouseButton, PageScreenshotClip, RgbaColor, - RuntimeLoopbackStatusResult, Scale, StagehandMetrics, StagehandPingResult, @@ -86,7 +85,6 @@ "PageScreenshotClip", "ProtocolLocator", "RgbaColor", - "RuntimeLoopbackStatusResult", "Scale", "ScreenshotType", "Stagehand", diff --git a/packages/sdk-python/src/stagehand/_generated/models.py b/packages/sdk-python/src/stagehand/_generated/models.py index 14ff92a52..fbe3b99a2 100644 --- a/packages/sdk-python/src/stagehand/_generated/models.py +++ b/packages/sdk-python/src/stagehand/_generated/models.py @@ -1815,39 +1815,6 @@ class RgbaColor(WireModel): a: Optional[StrictFloat] = None -class RuntimeConfigureParams(WireModel): - model_config = ConfigDict( - extra="forbid", - validate_by_name=True, - ) - protocol_version: Annotated[Optional[StrictInt], Field(gt=0, le=9007199254740991)] = ( - None - ) - client_info: Optional[ImplementationInfo] = None - cdp_url: Annotated[StrictStr, Field(min_length=1)] - telemetry: Annotated[TelemetryConfig, Field(validate_default=True)] = { - "traces": {"endpoint": "https://example.com/v1/traces", "headers": {}} - } - log_level: LogLevel = LogLevel.info - - -class RuntimeConfigureResult(WireModel): - model_config = ConfigDict( - extra="forbid", - validate_by_name=True, - ) - configured: Literal[True] - - -class RuntimeLoopbackStatusResult(WireModel): - model_config = ConfigDict( - extra="forbid", - validate_by_name=True, - ) - configured: StrictBool - connected: StrictBool - - class SameSite(StrEnum): strict = "Strict" lax = "Lax" @@ -1909,12 +1876,16 @@ class StagehandInitParams(WireModel): extra="forbid", validate_by_name=True, ) + protocol_version: Literal[1] + client_info: ImplementationInfo + browser_cdp_url: Annotated[Optional[StrictStr], Field(min_length=1)] = None api_key: Annotated[Optional[StrictStr], Field(min_length=1)] = None browser: Optional[BrowserbaseBrowserSource] = None model: Optional[Union[ModelConfig, ClientModelReference]] = None telemetry: Annotated[TelemetryConfig, Field(validate_default=True)] = { "traces": {"endpoint": "https://example.com/v1/traces", "headers": {}} } + log_level: LogLevel = LogLevel.info system_prompt: Optional[StrictStr] = None self_heal: Optional[StrictBool] = None dom_settle_timeout_ms: Annotated[ diff --git a/packages/sdk-python/src/stagehand/browser_source.py b/packages/sdk-python/src/stagehand/browser_source.py index a844556af..018e862ee 100644 --- a/packages/sdk-python/src/stagehand/browser_source.py +++ b/packages/sdk-python/src/stagehand/browser_source.py @@ -46,6 +46,7 @@ class ResolvedBrowserSource: cdp_url: str keep_alive: bool + resident_browser_connection: bool = False cdp_headers: dict[str, str] | None = None connect_timeout_ms: int | None = None _close_callback: Callable[[], Awaitable[None]] | None = field(default=None, repr=False) @@ -161,6 +162,7 @@ async def close() -> None: return ResolvedBrowserSource( cdp_url=f"http://127.0.0.1:{port}", keep_alive=options.keep_alive or False, + resident_browser_connection=False, connect_timeout_ms=options.connect_timeout_ms, _close_callback=close, ) diff --git a/packages/sdk-python/src/stagehand/cdp_client.py b/packages/sdk-python/src/stagehand/cdp_client.py index f2e1e6bc1..80267d03c 100644 --- a/packages/sdk-python/src/stagehand/cdp_client.py +++ b/packages/sdk-python/src/stagehand/cdp_client.py @@ -147,7 +147,7 @@ async def connect( {"name": STAGEHAND_SEND_TO_HOST_BINDING}, session_id=session_id, ) - await client._wait_for_runtime_ready(session_id, discovery_timeout_ms) + await client._wait_for_runtime_receiver(session_id, discovery_timeout_ms) return client except BaseException: await client.close() @@ -411,7 +411,7 @@ async def _wait_for_service_worker( f"Observed targets: {observed}" ) - async def _wait_for_runtime_ready(self, session_id: str, timeout_ms: int) -> None: + async def _wait_for_runtime_receiver(self, session_id: str, timeout_ms: int) -> None: started = time.monotonic() last_error = "" diff --git a/packages/sdk-python/src/stagehand/client_models.py b/packages/sdk-python/src/stagehand/client_models.py index d726632f2..19aad99ba 100644 --- a/packages/sdk-python/src/stagehand/client_models.py +++ b/packages/sdk-python/src/stagehand/client_models.py @@ -17,8 +17,8 @@ LLMStructuredGenerateResult, ModelConfig, ProxyConfig, - StagehandInitParams, StagehandLog, + TelemetryConfig, ) from ._validation import WireModel @@ -127,9 +127,19 @@ class StagehandClientLoggingConfig(WireModel): on_log: StagehandOnLog | None = None -class StagehandClientInitParams(StagehandInitParams): +class StagehandClientInitParams(WireModel): + model_config = ConfigDict(extra="forbid") + + api_key: Annotated[str | None, Field(min_length=1)] = None browser: BrowserSource = BrowserbaseBrowserSource(type="browserbase") model: ModelConfig | ClientLLM | None = None + telemetry: TelemetryConfig | None = None + system_prompt: str | None = None + self_heal: bool | None = None + dom_settle_timeout_ms: Annotated[ + int | None, Field(gt=0, le=9_007_199_254_740_991, strict=True) + ] = None + cache: _models.Caching | None = None logging: StagehandClientLoggingConfig = Field(default_factory=StagehandClientLoggingConfig) @model_validator(mode="after") diff --git a/packages/sdk-python/src/stagehand/rpc_client.py b/packages/sdk-python/src/stagehand/rpc_client.py index 5fa24d088..28721cddf 100644 --- a/packages/sdk-python/src/stagehand/rpc_client.py +++ b/packages/sdk-python/src/stagehand/rpc_client.py @@ -9,8 +9,6 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError -from ._generated import models - _MAX_REQUEST_ID = 9_007_199_254_740_991 _MAX_PENDING_NOTIFICATIONS = 100 @@ -106,6 +104,11 @@ def __init__(self, transport: _Transport, *, request_timeout_ms: int = 10_000) - self._close_reason: BaseException | None = None self._reader = asyncio.create_task(self._read(), name="stagehand-rpc-reader") + @property + def browser_web_socket_debugger_url(self) -> str | None: + value = getattr(getattr(self, "_transport", None), "web_socket_debugger_url", None) + return value if isinstance(value, str) else None + async def send( self, method: str, @@ -463,8 +466,6 @@ async def connect_rpc_client( discovery_timeout_ms: int = 10_000, command_timeout_ms: int = 10_000, cdp_connect_timeout_ms: int = 10_000, - telemetry: models.TelemetryConfig | None = None, - log_level: str = "info", ) -> RPCClient: from .cdp_client import CDPClient @@ -477,16 +478,4 @@ async def connect_rpc_client( command_timeout_ms=command_timeout_ms, cdp_connect_timeout_ms=cdp_connect_timeout_ms, ) - client = RPCClient(cdp, request_timeout_ms=command_timeout_ms) - configure = models.RuntimeConfigureParams( - cdp_url=cdp.web_socket_debugger_url, - **({"telemetry": telemetry} if telemetry is not None else {}), - log_level=models.LogLevel(log_level), - ) - - try: - await client.send("runtime.configure", configure, models.RuntimeConfigureResult) - except BaseException: - await client.close() - raise - return client + return RPCClient(cdp, request_timeout_ms=command_timeout_ms) diff --git a/packages/sdk-python/src/stagehand/stagehand.py b/packages/sdk-python/src/stagehand/stagehand.py index 80a23615b..62288aec5 100644 --- a/packages/sdk-python/src/stagehand/stagehand.py +++ b/packages/sdk-python/src/stagehand/stagehand.py @@ -26,13 +26,13 @@ ExternalProxyConfig, ExtractOptions, ExtractResult, + ImplementationInfo, LLMGenerateParams, LLMGenerateResult, ModelConfig, ObserveOptions, ObserveResult, ProxyConfig, - RuntimeLoopbackStatusResult, StagehandActParams, StagehandCloseResult, StagehandExtractParams, @@ -48,6 +48,7 @@ from ._generated.models import ( Locator as ProtocolLocator, ) +from ._generated.protocol_version import STAGEHAND_PROTOCOL_VERSION from .browser_context import BrowserContext from .browser_source import ResolvedBrowserSource, resolve_browser_source from .cdp_client import CDPConnectionClosedError @@ -390,13 +391,6 @@ async def ping(self) -> StagehandPingResult: StagehandPingResult, ) - async def runtime_loopback_status(self) -> RuntimeLoopbackStatusResult: - return await self._connected_rpc_client.send( - "runtime.loopback_status", - EmptyParams(), - RuntimeLoopbackStatusResult, - ) - async def browser_get_version(self) -> BrowserGetVersionResult: return await self._connected_rpc_client.send( "browser.get_version", @@ -428,8 +422,6 @@ async def init(self) -> None: extension_dir=str(extension_dir), service_worker_url_includes="service-worker.js", cdp_connect_timeout_ms=browser.connect_timeout_ms or 10_000, - telemetry=self.init_params.telemetry, - log_level=self.init_params.logging.level, ) self._rpc_client = rpc_client self._remove_notification_listener = rpc_client.on_notification( @@ -450,9 +442,14 @@ async def generate(params: LLMGenerateParams) -> LLMGenerateResult: generate, ) + browser_cdp_url = rpc_client.browser_web_socket_debugger_url + if not browser.resident_browser_connection and browser_cdp_url is None: + raise RuntimeError("The browser CDP WebSocket URL is unavailable") await rpc_client.send( "stagehand.init", - self._worker_init_params(), + self._worker_init_params( + None if browser.resident_browser_connection else browser_cdp_url + ), StagehandInitResult, ) self._browser_context = BrowserContext(rpc_client) @@ -606,7 +603,7 @@ def _connected_rpc_client(self) -> RPCClient: ) return self._rpc_client - def _worker_init_params(self) -> StagehandInitParams: + def _worker_init_params(self, browser_cdp_url: str | None) -> StagehandInitParams: values = self.init_params.model_dump( exclude={"browser", "logging", "model"}, exclude_unset=True, @@ -615,6 +612,14 @@ def _worker_init_params(self) -> StagehandInitParams: values["model"] = ClientModelReference(source="client") elif self.init_params.model is not None: values["model"] = self.init_params.model + values["protocol_version"] = STAGEHAND_PROTOCOL_VERSION + values["log_level"] = self.init_params.logging.level + values["client_info"] = ImplementationInfo( + name="stagehand-sdk-python", + version="0.1.0", + ) + if browser_cdp_url is not None: + values["browser_cdp_url"] = browser_cdp_url return StagehandInitParams.model_validate(values) async def _handle_stagehand_notification(self, notification: StagehandLog) -> None: diff --git a/packages/sdk-python/tests/_support.py b/packages/sdk-python/tests/_support.py index 353cd3096..e4c63f5f8 100644 --- a/packages/sdk-python/tests/_support.py +++ b/packages/sdk-python/tests/_support.py @@ -10,6 +10,8 @@ class RecordingRPCClient: + browser_web_socket_debugger_url: str | None = "ws://resolved.example/devtools/browser/1" + def __init__(self, responses: dict[str, object] | None = None) -> None: self.responses = responses or {} self.calls: list[tuple[str, BaseModel, object]] = [] diff --git a/packages/sdk-python/tests/test_cdp_client.py b/packages/sdk-python/tests/test_cdp_client.py index 120fa6c8e..eeb77b74b 100644 --- a/packages/sdk-python/tests/test_cdp_client.py +++ b/packages/sdk-python/tests/test_cdp_client.py @@ -20,6 +20,7 @@ def _ready_marker() -> dict[str, object]: "marker": { "protocolVersion": STAGEHAND_PROTOCOL_VERSION, "serverInfo": {"name": "stagehand", "version": "4.0.0"}, + "state": "ready", }, "hasReceiver": True, } diff --git a/packages/sdk-python/tests/test_client_models.py b/packages/sdk-python/tests/test_client_models.py index e4466c233..01e5d5e6a 100644 --- a/packages/sdk-python/tests/test_client_models.py +++ b/packages/sdk-python/tests/test_client_models.py @@ -37,6 +37,15 @@ def test_client_configuration_rejects_unknown_sdk_options() -> None: }) +@pytest.mark.parametrize("timeout", [True, 9_007_199_254_740_992]) +def test_client_configuration_rejects_invalid_dom_settle_timeouts(timeout: object) -> None: + with pytest.raises(ValidationError): + StagehandClientInitParams.model_validate({ + "browser": {"type": "local"}, + "dom_settle_timeout_ms": timeout, + }) + + def test_client_logging_uses_info_and_pretty_output_by_default() -> None: params = StagehandClientInitParams.model_validate({"browser": {"type": "local"}}) diff --git a/packages/sdk-python/tests/test_rpc_client.py b/packages/sdk-python/tests/test_rpc_client.py index 8b14c413c..9cbe15b57 100644 --- a/packages/sdk-python/tests/test_rpc_client.py +++ b/packages/sdk-python/tests/test_rpc_client.py @@ -449,16 +449,10 @@ async def connect(cls, **kwargs: object) -> "FakeCDPClient": async def send(self, message: JSON) -> None: await super().send(message) - if message.get("method") == "runtime.configure": - await self.incoming.put({ - "jsonrpc": "2.0", - "id": message["id"], - "result": {"configured": True}, - }) @pytest.mark.asyncio -async def test_connect_rpc_client_passes_cdp_options_and_configures_the_runtime( +async def test_connect_rpc_client_passes_cdp_options_without_sending_an_rpc( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(cdp_client, "CDPClient", FakeCDPClient) @@ -482,20 +476,9 @@ async def test_connect_rpc_client_passes_cdp_options_and_configures_the_runtime( "cdp_connect_timeout_ms": 1_003, } transport = FakeCDPClient.instances[-1] - assert transport.sent[0] == { - "jsonrpc": "2.0", - "id": 1, - "method": "runtime.configure", - "params": { - "cdp_url": "ws://resolved.example/devtools/browser/1", - "log_level": "info", - "telemetry": { - "traces": { - "endpoint": "https://example.com/v1/traces", - "headers": {}, - } - }, - }, - } + assert transport.sent == [] + assert client.browser_web_socket_debugger_url == ( + "ws://resolved.example/devtools/browser/1" + ) finally: await client.close() diff --git a/packages/sdk-python/tests/test_stagehand.py b/packages/sdk-python/tests/test_stagehand.py index be9649b59..8a888600b 100644 --- a/packages/sdk-python/tests/test_stagehand.py +++ b/packages/sdk-python/tests/test_stagehand.py @@ -26,7 +26,6 @@ ModelConfig, ObserveResult, PageRef, - RuntimeLoopbackStatusResult, StagehandActParams, StagehandCloseResult, StagehandExtractParams, @@ -36,6 +35,7 @@ StagehandMetrics, StagehandObserveParams, StagehandPingResult, + TelemetryConfig, ) from stagehand.browser_source import ResolvedBrowserSource from stagehand.cdp_client import CDPConnectionClosedError @@ -109,13 +109,11 @@ async def test_stagehand_prints_info_and_higher_logs_while_hiding_debug_by_defau recording = RecordingRPCClient({ "stagehand.init": StagehandInitResult(initialized=True, pages=[]), }) - connect_args: dict[str, object] = {} async def resolve(_: StagehandClientInitParams) -> ResolvedBrowserSource: return ResolvedBrowserSource(cdp_url="test://browser", keep_alive=True) - async def connect(**kwargs: object) -> RPCClient: - connect_args.update(kwargs) + async def connect(**_: object) -> RPCClient: return cast(RPCClient, recording) monkeypatch.setattr(stagehand_module, "resolve_browser_source", resolve) @@ -133,7 +131,7 @@ async def connect(**kwargs: object) -> RPCClient: ]: await notification_listener(StagehandLog.model_validate(log)) - assert connect_args["log_level"] == "info" + assert cast(StagehandInitParams, recording.calls[0][1]).log_level == "info" assert capsys.readouterr().err.splitlines() == [ '[stagehand] INFO Page opened {"pageId":"page-1"}', "[stagehand] WARN Selector fallback", @@ -141,6 +139,77 @@ async def connect(**kwargs: object) -> RPCClient: ] +@pytest.mark.asyncio +async def test_stagehand_forwards_client_initialization_options_and_resolved_cdp_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recording = RecordingRPCClient({ + "stagehand.init": StagehandInitResult(initialized=True, pages=[]), + }) + + async def resolve(_: StagehandClientInitParams) -> ResolvedBrowserSource: + return ResolvedBrowserSource(cdp_url="test://browser", keep_alive=True) + + async def connect(**_: object) -> RPCClient: + return cast(RPCClient, recording) + + monkeypatch.setattr(stagehand_module, "resolve_browser_source", resolve) + monkeypatch.setattr(stagehand_module, "connect_rpc_client", connect) + stagehand = Stagehand( + browser="cdp", + cdp_url="test://browser", + telemetry=TelemetryConfig.model_validate({ + "traces": { + "endpoint": "https://telemetry.example/v1/traces", + "headers": {"authorization": "secret"}, + } + }), + system_prompt="Use the test policy", + self_heal=True, + dom_settle_timeout_ms=2_500, + cache=CacheOptions(threshold=3), + ) + + await stagehand.init() + + init_params = cast(StagehandInitParams, recording.calls[0][1]) + assert init_params.browser_cdp_url == recording.browser_web_socket_debugger_url + assert init_params.telemetry.traces.endpoint == "https://telemetry.example/v1/traces" + assert init_params.telemetry.traces.headers == {"authorization": "secret"} + assert init_params.system_prompt == "Use the test policy" + assert init_params.self_heal is True + assert init_params.dom_settle_timeout_ms == 2_500 + assert init_params.cache is not None + cache = init_params.cache.root + assert not isinstance(cache, bool) + assert cache.threshold == 3 + + +@pytest.mark.asyncio +async def test_stagehand_rejects_missing_resolved_cdp_url_before_init( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recording = RecordingRPCClient({ + "stagehand.init": StagehandInitResult(initialized=True, pages=[]), + }) + recording.browser_web_socket_debugger_url = None + + async def resolve(_: StagehandClientInitParams) -> ResolvedBrowserSource: + return ResolvedBrowserSource(cdp_url="test://browser", keep_alive=True) + + async def connect(**_: object) -> RPCClient: + return cast(RPCClient, recording) + + monkeypatch.setattr(stagehand_module, "resolve_browser_source", resolve) + monkeypatch.setattr(stagehand_module, "connect_rpc_client", connect) + stagehand = Stagehand(browser="cdp", cdp_url="test://browser") + + with pytest.raises(RuntimeError, match="CDP WebSocket URL is unavailable"): + await stagehand.init() + + assert recording.calls == [] + + @pytest.mark.asyncio async def test_stagehand_writes_one_json_object_and_calls_on_log_with_the_structured_event( monkeypatch: pytest.MonkeyPatch, @@ -197,10 +266,6 @@ async def test_stagehand_routes_public_runtime_status_and_metrics_methods( }) recording = RecordingRPCClient({ "ping": StagehandPingResult(ok=True, runtime="service_worker"), - "runtime.loopback_status": RuntimeLoopbackStatusResult( - configured=True, - connected=True, - ), "browser.get_version": BrowserGetVersionResult( protocol_version="1.3", product="Chrome/1", @@ -222,10 +287,6 @@ async def connect(**_: object) -> RPCClient: assert stagehand.browser.cdp_url == "test://browser" assert await stagehand.ping() == StagehandPingResult(ok=True, runtime="service_worker") - assert await stagehand.runtime_loopback_status() == RuntimeLoopbackStatusResult( - configured=True, - connected=True, - ) assert await stagehand.browser_get_version() == BrowserGetVersionResult( protocol_version="1.3", product="Chrome/1", @@ -233,7 +294,6 @@ async def connect(**_: object) -> RPCClient: assert await stagehand.metrics() == metrics assert [method for method, _, _ in recording.calls[1:]] == [ "ping", - "runtime.loopback_status", "browser.get_version", "stagehand.metrics", ] diff --git a/packages/sdk-ts/src/browserSource.ts b/packages/sdk-ts/src/browserSource.ts index 992f4bde5..e21691b6a 100644 --- a/packages/sdk-ts/src/browserSource.ts +++ b/packages/sdk-ts/src/browserSource.ts @@ -19,6 +19,7 @@ export type ResolvedBrowserSource = { cdpHeaders?: Record; browserbaseSessionId?: string; preloadedExtension?: boolean; + residentBrowserConnection: boolean; keepAlive: boolean; close?: () => Promise | void; }; @@ -54,6 +55,7 @@ export async function resolveBrowserSource( cdpUrl: session.cdpUrl, browserbaseSessionId: session.sessionId, preloadedExtension: true, + residentBrowserConnection: false, keepAlive: browser.keepAlive ?? false, close: session.close, }; @@ -64,6 +66,7 @@ export async function resolveBrowserSource( const launched = await (dependencies.launchLocalBrowser ?? launchLocalBrowser)(launchOptions); return { cdpUrl: launched.cdpUrl, + residentBrowserConnection: false, keepAlive: launchOptions.keepAlive ?? false, close: launched.close, }; @@ -72,6 +75,7 @@ export async function resolveBrowserSource( return { cdpUrl: browser.cdpUrl, ...(browser.headers === undefined ? {} : { cdpHeaders: browser.headers }), + residentBrowserConnection: false, keepAlive: true, }; } @@ -94,7 +98,7 @@ async function launchLocalBrowser( ...(process.env.CI ? ["--no-sandbox"] : []), ], userDataDir: options.userDataDir, - port: options.port, + ...(options.port === undefined ? {} : { port: options.port }), logLevel: "silent", }); diff --git a/packages/sdk-ts/src/cdpClient.ts b/packages/sdk-ts/src/cdpClient.ts index d48ebf496..529aabc29 100644 --- a/packages/sdk-ts/src/cdpClient.ts +++ b/packages/sdk-ts/src/cdpClient.ts @@ -248,7 +248,7 @@ export class CDPClient { { name: STAGEHAND_SEND_TO_HOST_BINDING }, attached.sessionId, ); - await waitForRuntimeReady(client, attached.sessionId, { + await waitForRuntimeReceiver(client, attached.sessionId, { timeout: options.discoveryTimeoutMs, runtimeRequirement: options.runtimeRequirement, allowFallbackInstall: options.allowFallbackInstall, @@ -385,17 +385,27 @@ export class CDPClient { type CDPCommandSender = Pick; -export async function waitForRuntimeReady( +type RuntimeWaitOptions = { + timeout: number; + pollIntervalMs?: number; + delayFn?: (ms: number) => Promise; + nowFn?: () => number; + runtimeRequirement?: RuntimeRequirement; + allowFallbackInstall?: boolean; +}; + +export async function waitForRuntimeReceiver( cdp: CDPCommandSender, sessionId: string, - options: { - timeout: number; - pollIntervalMs?: number; - delayFn?: (ms: number) => Promise; - nowFn?: () => number; - runtimeRequirement?: RuntimeRequirement; - allowFallbackInstall?: boolean; - }, + options: RuntimeWaitOptions, +): Promise { + await waitForRuntime(cdp, sessionId, options); +} + +async function waitForRuntime( + cdp: CDPCommandSender, + sessionId: string, + options: RuntimeWaitOptions, ): Promise { const pollIntervalMs = options.pollIntervalMs ?? 100; const delayFn = options.delayFn ?? delay; @@ -438,7 +448,7 @@ export async function waitForRuntimeReady( } throw new Error( - `Timed out waiting for the Stagehand extension runtime to become ready${ + `Timed out waiting for the Stagehand extension runtime RPC receiver${ lastError ? ` (${lastError})` : "" }`, { cause: lastReadiness }, diff --git a/packages/sdk-ts/src/clientSchemas.ts b/packages/sdk-ts/src/clientSchemas.ts index 0eefc8a50..51ed07b14 100644 --- a/packages/sdk-ts/src/clientSchemas.ts +++ b/packages/sdk-ts/src/clientSchemas.ts @@ -116,14 +116,20 @@ export const StagehandClientExtractOptionsSchema = ExtractOptionsSchema.unwrap() .strict() .meta({ id: "StagehandClientExtractOptions" }); -export const StagehandClientInitParamsSchema = StagehandInitParamsSchema.extend({ - browser: BrowserSourceSchema.default({ type: "browserbase" }), - model: z.union([ModelConfigSchema, ClientLLMSchema]).optional(), - logging: StagehandClientLoggingConfigSchema.default({ - level: "info", - format: "pretty", - }), +export const StagehandClientInitParamsSchema = StagehandInitParamsSchema.omit({ + protocolVersion: true, + clientInfo: true, + browserCdpUrl: true, + logLevel: true, }) + .extend({ + browser: BrowserSourceSchema.default({ type: "browserbase" }), + model: z.union([ModelConfigSchema, ClientLLMSchema]).optional(), + logging: StagehandClientLoggingConfigSchema.default({ + level: "info", + format: "pretty", + }), + }) .strict() .superRefine((params, context) => { if (params.browser.type === "browserbase" && params.apiKey === undefined) { diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts index e80fcd7a9..ce639b2b3 100644 --- a/packages/sdk-ts/src/index.ts +++ b/packages/sdk-ts/src/index.ts @@ -16,7 +16,6 @@ export type { InitScriptSource } from "./pageScripts.js"; export { Stagehand } from "./stagehand.js"; export type { BrowserGetVersionResult, - RuntimeLoopbackStatusResult, StagehandMetrics, StagehandPingResult, } from "../../protocol/types.js"; diff --git a/packages/sdk-ts/src/rpcClient.ts b/packages/sdk-ts/src/rpcClient.ts index 39e2d5963..66ca63012 100644 --- a/packages/sdk-ts/src/rpcClient.ts +++ b/packages/sdk-ts/src/rpcClient.ts @@ -30,19 +30,11 @@ import type { import { encodeWireValue, wireSchema } from "../../protocol/json-rpc/wire-casing.js"; import { StagehandNotifications, - StagehandMethods, StagehandRpcNotificationSchema, } from "../../protocol/schema-registry.js"; -import { - DEFAULT_TELEMETRY_CONFIG, - RuntimeConfigureParamsSchema, - STAGEHAND_PROTOCOL_VERSION, - TelemetryConfigSchema, -} from "../../protocol/schemas.js"; import type { StagehandRpcNotification } from "../../protocol/types.js"; import { z } from "zod/v4"; import { CDPClient, type ServiceWorkerInfo } from "./cdpClient.js"; -import { STAGEHAND_SDK_VERSION } from "./version.js"; type PendingRequest = { method: RPCMethod; @@ -59,11 +51,6 @@ type RegisteredRequestHandler = { const TRACER = trace.getTracer("@browserbasehq/stagehand"); const W3C_TRACE_CONTEXT_PROPAGATOR = new W3CTraceContextPropagator(); const MAX_PENDING_NOTIFICATIONS = 100; -const STAGEHAND_SDK_CLIENT_INFO = { - name: "stagehand-sdk-ts", - version: STAGEHAND_SDK_VERSION, -} as const; - const RPCClientOptionsBaseSchema = z .object({ cdpUrl: z.string().min(1), @@ -71,8 +58,6 @@ const RPCClientOptionsBaseSchema = z discoveryTimeoutMs: z.number().int().positive().optional(), commandTimeoutMs: z.number().int().positive().optional(), cdpConnectTimeoutMs: z.number().int().positive().optional(), - telemetry: TelemetryConfigSchema.default(DEFAULT_TELEMETRY_CONFIG), - logLevel: RuntimeConfigureParamsSchema.shape.logLevel, }) .strict(); @@ -98,6 +83,7 @@ export type RPCClientOptions = z.input; export type CDPTransport = { readonly serviceWorker: ServiceWorkerInfo; + readonly webSocketDebuggerUrl?: string; onmessage?: (message: unknown) => void | Promise; onclose?: (reason?: Error) => void; onerror?: (error: Error) => void; @@ -107,6 +93,7 @@ export type CDPTransport = { export class RPCClient { readonly serviceWorker: ServiceWorkerInfo; + readonly browserWebSocketDebuggerUrl?: string; nextRequestId = 1; pending = new Map(); requestHandlers = new Map(); @@ -120,6 +107,7 @@ export class RPCClient { this.cdp = cdp; this.requestTimeoutMs = requestTimeoutMs; this.serviceWorker = cdp.serviceWorker; + this.browserWebSocketDebuggerUrl = cdp.webSocketDebuggerUrl; this.cdp.onmessage = (message) => this.receive(message); this.cdp.onclose = (reason) => this.close(reason); this.cdp.onerror = (error) => this.close(error); @@ -416,19 +404,7 @@ export async function connectRPCClient(input: RPCClientOptions): Promise Promise; @@ -34,6 +34,10 @@ type StagehandAdapters = { }; const stagehandAdapters = new WeakMap(); +const STAGEHAND_SDK_CLIENT_INFO = { + name: "stagehand-sdk-ts", + version: STAGEHAND_SDK_VERSION, +} as const; export class Stagehand { browserContext: BrowserContext | undefined; @@ -68,10 +72,6 @@ export class Stagehand { return this.connectedRpcClient.send(StagehandMethods.ping, {}); } - async runtimeLoopbackStatus(): Promise { - return this.connectedRpcClient.send(StagehandMethods.runtimeLoopbackStatus, {}); - } - async browserGetVersion(): Promise { return this.connectedRpcClient.send(StagehandMethods.browserGetVersion, {}); } @@ -98,8 +98,6 @@ export class Stagehand { ? { preloadedExtension: true as const } : { extensionDir: STAGEHAND_EXTENSION_DIRECTORY_PATH }), serviceWorkerUrlIncludes: "service-worker.js", - telemetry: clientInitParams.telemetry, - logLevel: clientInitParams.logging.level, }); this.rpcClient = rpcClient; this.removeNotificationListener = rpcClient.onNotification((notification) => @@ -114,7 +112,7 @@ export class Stagehand { await rpcClient.send( StagehandMethods.stagehandInit, - stagehandInitParamsForWorker(clientInitParams, browser), + stagehandInitParamsForWorker(clientInitParams, browser, rpcClient), ); this.browserContext = new BrowserContext(rpcClient); } catch (error) { @@ -231,15 +229,27 @@ export class Stagehand { function stagehandInitParamsForWorker( initParams: ResolvedStagehandClientInitParams, resolvedBrowser: ResolvedBrowserSource, + rpcClient: RPCClient, ) { - const { browser, logging: _logging, model, ...protocolParams } = initParams; + const { browser, logging, model, ...protocolParams } = initParams; const protocolModel = model && "generate" in model ? { source: "client" as const } : model; if (browser.type === "browserbase" && !resolvedBrowser.browserbaseSessionId) { throw new Error("Resolved Browserbase source is missing its session ID"); } + if (!resolvedBrowser.residentBrowserConnection && !rpcClient.browserWebSocketDebuggerUrl) { + throw new Error("The browser CDP WebSocket URL is unavailable"); + } return StagehandInitParamsSchema.parse({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: STAGEHAND_SDK_CLIENT_INFO, + logLevel: logging.level, + ...(resolvedBrowser.residentBrowserConnection + ? {} + : { + browserCdpUrl: rpcClient.browserWebSocketDebuggerUrl, + }), ...protocolParams, ...(browser.type === "browserbase" ? { @@ -264,6 +274,7 @@ export function createStagehandWithClientForTest(client: RPCClient): Stagehand { { resolveBrowserSource: async () => ({ cdpUrl: "test://stagehand", + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient: async () => client, diff --git a/packages/sdk-ts/tests/browserSource.test.ts b/packages/sdk-ts/tests/browserSource.test.ts index e3a089869..e8b35134d 100644 --- a/packages/sdk-ts/tests/browserSource.test.ts +++ b/packages/sdk-ts/tests/browserSource.test.ts @@ -24,6 +24,7 @@ describe("resolveBrowserSource", () => { cdpUrl: "wss://connect.browserbase.com/devtools/browser/new-session", browserbaseSessionId: "new-session", preloadedExtension: true, + residentBrowserConnection: false, keepAlive: false, close, }); @@ -95,6 +96,7 @@ describe("resolveBrowserSource", () => { ), ).resolves.toStrictEqual({ cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: false, keepAlive: true, close, }); @@ -104,6 +106,20 @@ describe("resolveBrowserSource", () => { }); }); + it("uses client configuration for a local browser launched on another port", async () => { + const launchLocalBrowser = vi.fn(async () => ({ + cdpUrl: "http://127.0.0.1:9333", + close: vi.fn(), + })); + + await expect( + resolveBrowserSource({ browser: { type: "local", port: 9333 } }, { launchLocalBrowser }), + ).resolves.toMatchObject({ + cdpUrl: "http://127.0.0.1:9333", + residentBrowserConnection: false, + }); + }); + it("connects to an existing CDP browser without owning its cleanup", async () => { await expect( resolveBrowserSource({ @@ -116,6 +132,7 @@ describe("resolveBrowserSource", () => { ).resolves.toStrictEqual({ cdpUrl: "wss://browser.example/devtools/browser/session", cdpHeaders: { Authorization: "Bearer secret" }, + residentBrowserConnection: false, keepAlive: true, }); }); diff --git a/packages/sdk-ts/tests/object-wrapper.test.ts b/packages/sdk-ts/tests/object-wrapper.test.ts index 912c0cc50..5be21b6db 100644 --- a/packages/sdk-ts/tests/object-wrapper.test.ts +++ b/packages/sdk-ts/tests/object-wrapper.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod/v4"; import type { RPCMethod } from "../../protocol/json-rpc/schemas.js"; import { StagehandMethods } from "../../protocol/schema-registry.js"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.js"; import { BrowserClipboard, BrowserContext, Locator, Page } from "../src/index.js"; import { RPCClient } from "../src/rpcClient.js"; import { createStagehandWithClientForTest } from "../src/stagehand.js"; @@ -18,6 +19,7 @@ class FakeProtocolClient extends RPCClient { constructor() { super( { + webSocketDebuggerUrl: "test://stagehand", serviceWorker: { targetId: "worker-target", url: "chrome-extension://stagehand/service-worker.js", @@ -68,6 +70,10 @@ function requestCall( } const stagehandInitCall = requestCall(StagehandMethods.stagehandInit, { + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", + browserCdpUrl: "test://stagehand", telemetry: { traces: { endpoint: "https://example.com/v1/traces", diff --git a/packages/sdk-ts/tests/rpcClient.test.ts b/packages/sdk-ts/tests/rpcClient.test.ts index af335d290..9202f03cb 100644 --- a/packages/sdk-ts/tests/rpcClient.test.ts +++ b/packages/sdk-ts/tests/rpcClient.test.ts @@ -1,11 +1,9 @@ -import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod/v4"; import { JSONRPCErrorCodes, type RPCMethod } from "../../protocol/json-rpc/schemas.js"; import type { JSONRPCMessage } from "../../protocol/json-rpc/types.js"; import { StagehandMethods } from "../../protocol/schema-registry.js"; -import sdkPackageJson from "../package.json" with { type: "json" }; -import { CDPClient } from "../src/cdpClient.js"; -import { connectRPCClient, RPCClient, type CDPTransport } from "../src/rpcClient.js"; +import { RPCClient, type CDPTransport } from "../src/rpcClient.js"; const UppercaseMethod = { name: "test.uppercase", @@ -61,34 +59,6 @@ class ManualCDPTransport implements CDPTransport { } describe("RPCClient", () => { - it("reports the SDK package version when configuring the runtime", async () => { - const cdp = new FakeCDPTransport({ configured: true }); - const connect = vi.spyOn(CDPClient, "connect").mockResolvedValue(cdp as unknown as CDPClient); - - try { - const client = await connectRPCClient({ - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/test", - extensionId: "stagehand", - }); - try { - expect(cdp.sent[0]).toMatchObject({ - jsonrpc: "2.0", - method: "runtime.configure", - params: { - client_info: { - name: "stagehand-sdk-ts", - version: sdkPackageJson.version, - }, - }, - }); - } finally { - client.close(); - } - } finally { - connect.mockRestore(); - } - }); - it("accepts page methods without SDK wrapper methods", async () => { const cdp = new FakeCDPTransport({ matched: true }); const client = new RPCClient(cdp, 1_000); diff --git a/packages/sdk-ts/tests/stagehand-client-init-params.test.ts b/packages/sdk-ts/tests/stagehand-client-init-params.test.ts index 45831b571..75f68a2d1 100644 --- a/packages/sdk-ts/tests/stagehand-client-init-params.test.ts +++ b/packages/sdk-ts/tests/stagehand-client-init-params.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { StagehandInitParamsSchema } from "../../protocol/schemas.js"; +import { STAGEHAND_PROTOCOL_VERSION, StagehandInitParamsSchema } from "../../protocol/schemas.js"; import { StagehandClientActOptionsSchema, StagehandClientExtractOptionsSchema, @@ -265,6 +265,8 @@ describe("Stagehand client browser sources", () => { expect(() => StagehandInitParamsSchema.parse(clientInitParams)).toThrow(); expect( StagehandInitParamsSchema.parse({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, ...protocolParams, browser: { ...clientInitParams.browser, @@ -272,6 +274,9 @@ describe("Stagehand client browser sources", () => { }, }), ).toStrictEqual({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", apiKey: "bb_key", browser: { type: "browserbase", @@ -312,11 +317,15 @@ describe("Stagehand client browser sources", () => { it("keeps local and CDP browser connection settings out of the worker schema", () => { expect(() => StagehandInitParamsSchema.parse({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, browser: { type: "local" }, }), ).toThrow(); expect(() => StagehandInitParamsSchema.parse({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, browser: { type: "cdp", cdpUrl: "wss://browser.example/devtools/browser/session", diff --git a/packages/sdk-ts/tests/stagehand.test.ts b/packages/sdk-ts/tests/stagehand.test.ts index c5c5ef538..a4cfdd185 100644 --- a/packages/sdk-ts/tests/stagehand.test.ts +++ b/packages/sdk-ts/tests/stagehand.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import { z } from "zod/v4"; import type { RPCMethod } from "../../protocol/json-rpc/schemas.js"; import { StagehandMethods } from "../../protocol/schema-registry.js"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.js"; import type { StagehandRpcNotification } from "../../protocol/types.js"; import { Stagehand } from "../src/index.js"; import type { ResolvedBrowserSource } from "../src/browserSource.js"; import { CDPConnectionClosedError } from "../src/cdpClient.js"; -import { RPCClient, type RPCClientOptions } from "../src/rpcClient.js"; +import { RPCClient } from "../src/rpcClient.js"; import { createStagehandWithDependenciesForTest } from "../src/stagehand.js"; +import { STAGEHAND_SDK_VERSION } from "../src/version.js"; type ProtocolCall = { method: string; params: unknown }; @@ -17,9 +19,10 @@ class FakeRPCClient extends RPCClient { responses = new Map(); notificationListeners = new Set<(notification: StagehandRpcNotification) => void>(); - constructor() { + constructor(webSocketDebuggerUrl = "ws://127.0.0.1:9222/devtools/browser/test") { super( { + webSocketDebuggerUrl, serviceWorker: { targetId: "worker-target", url: "chrome-extension://stagehand/service-worker.js", @@ -89,13 +92,14 @@ describe("Stagehand", () => { }); it("initializes through browser source resolution and RPC client connection", async () => { - const rpcClient = new FakeRPCClient(); + const rpcClient = new FakeRPCClient("ws://127.0.0.1:9222/devtools/browser/exact-session"); rpcClient.queueResponse(StagehandMethods.contextPages, [ { pageId: "page-1", url: "about:blank" }, ]); const resolveBrowserSource = vi.fn(async (): Promise => { return { cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: false, keepAlive: true, }; }); @@ -141,19 +145,16 @@ describe("Stagehand", () => { cdpUrl: "http://127.0.0.1:9222", extensionDir: expect.stringContaining("packages/sdk-ts/dist/extension") as string, serviceWorkerUrlIncludes: "service-worker.js", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, - } satisfies RPCClientOptions); + }); expect(pages[0]?.pageId).toBe("page-1"); expect(rpcClient.calls).toStrictEqual([ { method: "stagehand.init", params: { + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: STAGEHAND_SDK_VERSION }, + logLevel: "info", + browserCdpUrl: "ws://127.0.0.1:9222/devtools/browser/exact-session", apiKey: "bb_key", telemetry: { traces: { @@ -185,6 +186,7 @@ describe("Stagehand", () => { cdpUrl: "wss://connect.browserbase.com/devtools/browser/session", browserbaseSessionId: "session_123", preloadedExtension: true, + residentBrowserConnection: true, keepAlive: true, }), connectRpcClient, @@ -195,21 +197,17 @@ describe("Stagehand", () => { expect(connectRpcClient).toHaveBeenCalledWith({ cdpUrl: "wss://connect.browserbase.com/devtools/browser/session", - logLevel: "info", preloadedExtension: true, serviceWorkerUrlIncludes: "service-worker.js", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, - } satisfies RPCClientOptions); + }); expect(rpcClient.calls).toStrictEqual([ { method: "stagehand.init", params: { + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: STAGEHAND_SDK_VERSION }, + logLevel: "info", apiKey: "bb_key", browser: { type: "browserbase", @@ -248,6 +246,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient, @@ -259,27 +258,31 @@ describe("Stagehand", () => { expect(connectRpcClient).toHaveBeenCalledWith({ cdpUrl: "http://127.0.0.1:9222", extensionDir: expect.stringContaining("packages/sdk-ts/dist/extension") as string, - logLevel: "info", serviceWorkerUrlIncludes: "service-worker.js", - telemetry: { - traces: { - endpoint: "https://collector.example.com/v1/traces", - headers: { Authorization: "Bearer test" }, + }); + expect(rpcClient.calls[0]).toMatchObject({ + method: "stagehand.init", + params: { + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: STAGEHAND_SDK_VERSION }, + logLevel: "info", + browserCdpUrl: "ws://127.0.0.1:9222/devtools/browser/test", + telemetry: { + traces: { + endpoint: "https://collector.example.com/v1/traces", + headers: { Authorization: "Bearer test" }, + }, }, }, - } satisfies RPCClientOptions); + }); }); - it("routes public runtime status and metrics methods through the protocol", async () => { + it("routes public runtime and metrics methods through the protocol", async () => { const rpcClient = new FakeRPCClient(); rpcClient.queueResponse(StagehandMethods.ping, { ok: true, runtime: "service_worker", }); - rpcClient.queueResponse(StagehandMethods.runtimeLoopbackStatus, { - configured: true, - connected: true, - }); rpcClient.queueResponse(StagehandMethods.browserGetVersion, { protocolVersion: "1.3", product: "Chrome/1", @@ -312,6 +315,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -324,10 +328,6 @@ describe("Stagehand", () => { ok: true, runtime: "service_worker", }); - await expect(stagehand.runtimeLoopbackStatus()).resolves.toStrictEqual({ - configured: true, - connected: true, - }); await expect(stagehand.browserGetVersion()).resolves.toStrictEqual({ protocolVersion: "1.3", product: "Chrome/1", @@ -335,7 +335,6 @@ describe("Stagehand", () => { await expect(stagehand.metrics()).resolves.toStrictEqual(metrics); expect(rpcClient.calls.slice(1)).toStrictEqual([ { method: "ping", params: {} }, - { method: "runtime.loopback_status", params: {} }, { method: "browser.get_version", params: {} }, { method: "stagehand.metrics", params: {} }, ]); @@ -360,6 +359,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -373,6 +373,10 @@ describe("Stagehand", () => { { method: "stagehand.init", params: { + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: STAGEHAND_SDK_VERSION }, + logLevel: "info", + browserCdpUrl: "ws://127.0.0.1:9222/devtools/browser/test", model: { source: "client" }, telemetry: { traces: { @@ -396,6 +400,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: true, keepAlive: false, close: closeBrowser, }), @@ -411,6 +416,9 @@ describe("Stagehand", () => { { method: "stagehand.init", params: { + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: STAGEHAND_SDK_VERSION }, + logLevel: "info", telemetry: { traces: { endpoint: "https://example.com/v1/traces", @@ -440,6 +448,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -516,6 +525,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: false, keepAlive: true, }), connectRpcClient: async () => rpcClient, @@ -554,6 +564,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: true, keepAlive: true, close: closeBrowser, }), @@ -577,6 +588,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: true, keepAlive: false, close: closeBrowser, }), @@ -601,6 +613,7 @@ describe("Stagehand", () => { { resolveBrowserSource: async () => ({ cdpUrl: "http://127.0.0.1:9222", + residentBrowserConnection: true, keepAlive: false, close: async () => { throw cleanupError; diff --git a/packages/server/controllers/runtimeController.ts b/packages/server/controllers/runtimeController.ts index 57b85f564..ed63009a8 100644 --- a/packages/server/controllers/runtimeController.ts +++ b/packages/server/controllers/runtimeController.ts @@ -1,4 +1,4 @@ -import type { EmptyParams, RuntimeConfigureParams } from "../../protocol/types.js"; +import type { EmptyParams } from "../../protocol/types.js"; import type { HandlerContext } from "../rpcRouter.js"; import type { StagehandRuntime } from "../runtime.js"; @@ -11,21 +11,7 @@ export function createRuntimeController(runtime: StagehandRuntime) { }; } - async function configure(params: RuntimeConfigureParams, { logger }: HandlerContext) { - logger.setLevel(params.logLevel); - logger.debug("runtime.configure", {}); - runtime.tracing.configure(params.telemetry); - return runtime.configureLoopback(params); - } - - async function loopbackStatus(_params: EmptyParams, { logger }: HandlerContext) { - logger.debug("runtime.loopback_status", {}); - return runtime.loopbackStatus(); - } - return { ping, - configure, - loopbackStatus, }; } diff --git a/packages/server/controllers/stagehandController.ts b/packages/server/controllers/stagehandController.ts index 543ee41a5..d42720239 100644 --- a/packages/server/controllers/stagehandController.ts +++ b/packages/server/controllers/stagehandController.ts @@ -3,6 +3,7 @@ import type { StagehandActParams, StagehandExtractParams, StagehandInitParams, + StagehandInitResult, StagehandObserveParams, } from "../../protocol/types.js"; import type { HandlerContext } from "../rpcRouter.js"; @@ -12,15 +13,27 @@ import * as cacheService from "../services/cacheService.js"; import * as extractService from "../services/extractService.js"; import * as observeService from "../services/observeService.js"; -export function createStagehandController(runtime: StagehandRuntime) { +export type StagehandControllerOptions = { + initialize?: (params: StagehandInitParams) => Promise; + close?: () => Promise; +}; + +export function createStagehandController( + runtime: StagehandRuntime, + options: StagehandControllerOptions = {}, +) { + const initialize = options.initialize ?? ((params) => runtime.initialize(params)); + const closeRuntime = options.close ?? (() => runtime.close()); + async function init(params: StagehandInitParams, { logger }: HandlerContext) { + logger.setLevel(params.logLevel); logger.info("stagehand.init", {}); - return await runtime.initialize(params); + return await initialize(params); } async function close(_params: EmptyParams, { logger }: HandlerContext) { logger.info("stagehand.close", {}); - await runtime.close(); + await closeRuntime(); return { closed: true as const }; } diff --git a/packages/server/rpcRouter.ts b/packages/server/rpcRouter.ts index eb9e8eefb..0f432119b 100644 --- a/packages/server/rpcRouter.ts +++ b/packages/server/rpcRouter.ts @@ -10,7 +10,11 @@ import { W3CTraceContextPropagator } from "@opentelemetry/core"; import type { RPCMethod } from "../protocol/json-rpc/schemas.js"; import { wireSchema } from "../protocol/json-rpc/wire-casing.js"; import { StagehandMethods } from "../protocol/schema-registry.js"; -import type { StagehandRpcRequest } from "../protocol/types.js"; +import type { + StagehandInitParams, + StagehandInitResult, + StagehandRpcRequest, +} from "../protocol/types.js"; import { z } from "zod/v4"; import { createBrowserController } from "./controllers/browserController.js"; import { createContextController } from "./controllers/contextController.js"; @@ -27,6 +31,11 @@ export type HandlerContext = { logger: StagehandLogger; }; +export type RPCRouterOptions = { + initializeStagehand?: (params: StagehandInitParams) => Promise; + closeStagehand?: () => Promise; +}; + export class RPCRouter { readonly runtimeController; readonly browserController; @@ -35,10 +44,16 @@ export class RPCRouter { readonly pageController; readonly locatorController; - constructor(readonly runtime: StagehandRuntime) { + constructor( + readonly runtime: StagehandRuntime, + options: RPCRouterOptions = {}, + ) { this.runtimeController = createRuntimeController(runtime); this.browserController = createBrowserController(runtime); - this.stagehandController = createStagehandController(runtime); + this.stagehandController = createStagehandController(runtime, { + ...(options.initializeStagehand ? { initialize: options.initializeStagehand } : {}), + ...(options.closeStagehand ? { close: options.closeStagehand } : {}), + }); this.contextController = createContextController(runtime); this.pageController = createPageController(runtime); this.locatorController = createLocatorController(runtime); @@ -89,16 +104,6 @@ export class RPCRouter { parseParams(StagehandMethods.ping, request.params), context, ); - case "runtime.configure": - return this.runtimeController.configure( - parseParams(StagehandMethods.runtimeConfigure, request.params), - context, - ); - case "runtime.loopback_status": - return this.runtimeController.loopbackStatus( - parseParams(StagehandMethods.runtimeLoopbackStatus, request.params), - context, - ); case "browser.get_version": return this.browserController.getVersion( parseParams(StagehandMethods.browserGetVersion, request.params), diff --git a/packages/server/runtime.ts b/packages/server/runtime.ts index ac8a6e0cd..e8479009b 100644 --- a/packages/server/runtime.ts +++ b/packages/server/runtime.ts @@ -86,9 +86,6 @@ import type { PageWaitForSelectorParams, PageWaitForSelectorResult, PageWaitForTimeoutParams, - RuntimeConfigureParams, - RuntimeConfigureResult, - RuntimeLoopbackStatusResult, StagehandInitParams, StagehandInitResult, SnapshotResult, @@ -199,6 +196,7 @@ export type UnderstudyRuntimeLocator = { export type StagehandBrowserSession = { readonly connected: boolean; + prepareForInitialization?(): Promise; getVersion(): Promise; pages(): UnderstudyRuntimePage[]; newPage(url?: string): Promise; @@ -257,6 +255,7 @@ export class StagehandRuntime { ); browserSession?: StagehandBrowserSession; pagesById = new Map(); + private initializationInProgress = false; constructor( readonly adapters: ResolvedStagehandRuntimeAdapters, @@ -265,15 +264,7 @@ export class StagehandRuntime { this.logger = new StagehandLogger(tracing, adapters.emitLog); } - loopbackStatus(): RuntimeLoopbackStatusResult { - return { - configured: this.browserSession !== undefined, - connected: this.browserSession?.connected ?? false, - }; - } - - async configureLoopback(params: RuntimeConfigureParams): Promise { - this.logger.setLevel(params.logLevel); + async replaceBrowserConnection(params: { cdpUrl: string }): Promise { const { cdpUrl } = params; const previousSession = this.browserSession; this.browserSession = undefined; @@ -287,28 +278,43 @@ export class StagehandRuntime { this.browserSession = undefined; throw error; } - - return { configured: true }; } async initialize(params: StagehandInitParams): Promise { if (this.state.getState().status !== "created") { throw new Error("Stagehand has already been initialized"); } + if (this.initializationInProgress) { + throw new Error("Stagehand initialization is already in progress"); + } + this.initializationInProgress = true; - const pages = await this.contextPages(); - this.state.setState( - StagehandRuntimeStateSchema.parse({ - status: "initialized", - initParams: params, - }), - true, - ); - - return { - initialized: true, - pages, - }; + try { + this.logger.setLevel(params.logLevel); + if (!this.browserSession) { + if (!params.browserCdpUrl) { + throw new Error("stagehand.init requires browserCdpUrl until resident mode is active"); + } + await this.replaceBrowserConnection({ cdpUrl: params.browserCdpUrl }); + } + await this.browserSession?.prepareForInitialization?.(); + const pages = await this.contextPages(); + this.tracing.configure(params.telemetry); + this.state.setState( + StagehandRuntimeStateSchema.parse({ + status: "initialized", + initParams: params, + }), + true, + ); + + return { + initialized: true, + pages, + }; + } finally { + this.initializationInProgress = false; + } } async browserGetVersion(): Promise { diff --git a/packages/server/tests/client-llm-client.test.ts b/packages/server/tests/client-llm-client.test.ts index 9f44e21a7..24deb1dbc 100644 --- a/packages/server/tests/client-llm-client.test.ts +++ b/packages/server/tests/client-llm-client.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.js"; import { generateWithClientLlm } from "../llm/clientLlmClient.js"; import * as llmService from "../services/llmService.js"; import { createStagehandRuntime } from "../runtime.js"; @@ -79,14 +80,13 @@ describe("client LLM generation", () => { clientLLMGenerate: request, }); - await runtime.configureLoopback({ + await runtime.replaceBrowserConnection({ cdpUrl: "ws://browser.example", - logLevel: "info", - telemetry: { - traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, - }, }); await runtime.initialize({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info", model: { source: "client" }, telemetry: { traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, diff --git a/packages/server/tests/rpc-router.test.ts b/packages/server/tests/rpc-router.test.ts index 028aa8113..60aa8f8fb 100644 --- a/packages/server/tests/rpc-router.test.ts +++ b/packages/server/tests/rpc-router.test.ts @@ -4,8 +4,9 @@ import { SimpleSpanProcessor, type SpanProcessor, } from "@opentelemetry/sdk-trace-web"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { StagehandRpcRequestSchema } from "../../protocol/schema-registry.ts"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.ts"; import { createStagehandRuntime } from "../runtime.ts"; import { RPCRouter } from "../rpcRouter.ts"; import { createStagehandTracingRuntime, type StagehandTracing } from "../tracing.ts"; @@ -137,6 +138,43 @@ describe("Stagehand RPC router", () => { expect(logSpan?.parentSpanContext?.spanId).toBe(requestSpan?.spanContext().spanId); await tracing.shutdown(); }); + + it("applies the init log level before logging and delegates lifecycle overrides", async () => { + const tracing = configuredTracing(createStagehandTracingRuntime({ registerGlobals: false })); + const logs: string[] = []; + const initializeStagehand = vi.fn(async () => ({ initialized: true as const, pages: [] })); + const closeStagehand = vi.fn(async () => {}); + const runtime = createStagehandRuntime( + { + emitLog: (log) => logs.push(log.message), + }, + tracing, + ); + const router = new RPCRouter(runtime, { initializeStagehand, closeStagehand }); + const initRequest = request({ + id: 15, + method: "stagehand.init", + params: { + protocol_version: STAGEHAND_PROTOCOL_VERSION, + client_info: { name: "stagehand-sdk-ts", version: "4.0.0" }, + browser_cdp_url: "ws://127.0.0.1:9222/devtools/browser/session", + log_level: "off", + }, + }); + + await expect(router.handle(initRequest)).resolves.toStrictEqual({ + initialized: true, + pages: [], + }); + expect(logs).not.toContain("stagehand.init"); + expect(initializeStagehand).toHaveBeenCalledOnce(); + expect(initializeStagehand).toHaveBeenCalledWith(initRequest.params); + + await expect( + router.handle(request({ id: 16, method: "stagehand.close", params: {} })), + ).resolves.toStrictEqual({ closed: true }); + expect(closeStagehand).toHaveBeenCalledOnce(); + }); }); function createRouter(tracing: StagehandTracing): RPCRouter { diff --git a/packages/server/tests/runtime-state.test.ts b/packages/server/tests/runtime-state.test.ts index 3775fd57d..57dd21ab7 100644 --- a/packages/server/tests/runtime-state.test.ts +++ b/packages/server/tests/runtime-state.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it, vi } from "vitest"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.js"; import type { StagehandBrowserSession } from "../runtime.js"; import { createStagehandRuntime } from "../runtime.js"; +const clientMetadata = { + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info" as const, +}; + function createBrowserSession( overrides: Partial = {}, ): StagehandBrowserSession { @@ -39,15 +46,13 @@ describe("Stagehand runtime state", () => { const runtime = createStagehandRuntime({ browserSessionFactory: async () => createBrowserSession(), }); + const configureTracing = vi.spyOn(runtime.tracing, "configure").mockImplementation(() => {}); - await runtime.configureLoopback({ + await runtime.replaceBrowserConnection({ cdpUrl: "ws://browser.example", - logLevel: "info", - telemetry: { - traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, - }, }); await runtime.initialize({ + ...clientMetadata, model: { modelName: "openai/gpt-5" }, telemetry: { traces: { @@ -61,6 +66,7 @@ describe("Stagehand runtime state", () => { expect(runtime.state.getState()).toStrictEqual({ status: "initialized", initParams: { + ...clientMetadata, model: { modelName: "openai/gpt-5" }, telemetry: { traces: { @@ -71,6 +77,13 @@ describe("Stagehand runtime state", () => { selfHeal: true, }, }); + expect(configureTracing).toHaveBeenCalledOnce(); + expect(configureTracing).toHaveBeenCalledWith({ + traces: { + endpoint: "https://collector.example.com/v1/traces", + headers: { Authorization: "Bearer test" }, + }, + }); }); it("leaves server state unchanged when initialization fails", async () => { @@ -83,16 +96,13 @@ describe("Stagehand runtime state", () => { }), }); - await runtime.configureLoopback({ + await runtime.replaceBrowserConnection({ cdpUrl: "ws://browser.example", - logLevel: "info", - telemetry: { - traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, - }, }); await expect( runtime.initialize({ + ...clientMetadata, telemetry: { traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, }, @@ -101,20 +111,44 @@ describe("Stagehand runtime state", () => { expect(runtime.state.getState()).toStrictEqual({ status: "created" }); }); + it("rejects a concurrent initialization before creating another browser session", async () => { + let releaseInitialization!: () => void; + const initializationGate = new Promise((resolve) => { + releaseInitialization = resolve; + }); + const browserSessionFactory = vi.fn(async () => + createBrowserSession({ prepareForInitialization: async () => await initializationGate }), + ); + const runtime = createStagehandRuntime({ browserSessionFactory }); + const params = { + ...clientMetadata, + browserCdpUrl: "ws://browser.example", + telemetry: { + traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, + }, + }; + + const firstInitialization = runtime.initialize(params); + await expect(runtime.initialize(params)).rejects.toThrow( + "Stagehand initialization is already in progress", + ); + expect(browserSessionFactory).toHaveBeenCalledOnce(); + + releaseInitialization(); + await expect(firstInitialization).resolves.toMatchObject({ initialized: true }); + }); + it("clears initialized configuration when Stagehand closes", async () => { const close = vi.fn(); const runtime = createStagehandRuntime({ browserSessionFactory: async () => createBrowserSession({ close }), }); - await runtime.configureLoopback({ + await runtime.replaceBrowserConnection({ cdpUrl: "ws://browser.example", - logLevel: "info", - telemetry: { - traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, - }, }); await runtime.initialize({ + ...clientMetadata, model: { modelName: "openai/gpt-5", apiKey: "secret" }, telemetry: { traces: { endpoint: "https://collector.example.com/v1/traces", headers: {} }, diff --git a/packages/server/tests/stagehand-clients.test.ts b/packages/server/tests/stagehand-clients.test.ts index 5783dc7d3..01d8fa039 100644 --- a/packages/server/tests/stagehand-clients.test.ts +++ b/packages/server/tests/stagehand-clients.test.ts @@ -2,6 +2,7 @@ import { trace } from "@opentelemetry/api"; import { describe, expect, it, vi } from "vitest"; import { JSONRPCRequestSchema, JSONRPCResponseSchema } from "../../protocol/json-rpc/schemas.ts"; import type { JSONRPCResponse } from "../../protocol/json-rpc/types.ts"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.ts"; import { STAGEHAND_SEND_TO_HOST_BINDING, StagehandRpcNotificationSchema, @@ -99,6 +100,7 @@ class FakeBrowserSession implements StagehandBrowserSession { closed = false; connected = true; getVersionCalls = 0; + prepareForInitializationCalls = 0; readonly pageRefs: FakeUnderstudyRuntimePage[]; activePageRef: UnderstudyRuntimePage | undefined; readonly setActivePageCalls: UnderstudyRuntimePage[] = []; @@ -131,6 +133,10 @@ class FakeBrowserSession implements StagehandBrowserSession { return this.version; } + async prepareForInitialization(): Promise { + this.prepareForInitializationCalls += 1; + } + pages(): UnderstudyRuntimePage[] { return this.pageRefs; } @@ -549,10 +555,8 @@ async function createConfiguredHandler( await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }); return handle; @@ -563,20 +567,21 @@ async function createConfiguredRuntime(session: FakeBrowserSession) { browserSessionFactory: async () => session, }); - await runtime.configureLoopback({ + await runtime.replaceBrowserConnection({ cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - logLevel: "info", - telemetry: { - traces: { - endpoint: "https://example.com/v1/traces", - headers: {}, - }, - }, }); return runtime; } +function configuredInitParams(cdpUrl: string) { + return { + protocol_version: STAGEHAND_PROTOCOL_VERSION, + client_info: { name: "stagehand-sdk-ts", version: "4.0.0" }, + browser_cdp_url: cdpUrl, + }; +} + describe("Stagehand worker clients", () => { it("accepts only the shared Stagehand Chrome binding name", () => { expect(StagehandSendToHostBindingSchema.parse(STAGEHAND_SEND_TO_HOST_BINDING)).toBe( @@ -712,27 +717,7 @@ describe("Stagehand worker clients", () => { }); }); - it("reports unconfigured loopback status", async () => { - const handle = createHandle(); - - await expect( - handle({ - jsonrpc: "2.0", - id: 3, - method: "runtime.loopback_status", - params: {}, - }), - ).resolves.toStrictEqual({ - jsonrpc: "2.0", - id: 3, - result: { - configured: false, - connected: false, - }, - }); - }); - - it("configures the browser session and reports connected status", async () => { + it("configures the browser session during stagehand.init", async () => { const sessions: FakeBrowserSession[] = []; const handle = createHandle({ browserSessionFactory: async () => { @@ -746,39 +731,23 @@ describe("Stagehand worker clients", () => { handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }), ).resolves.toStrictEqual({ jsonrpc: "2.0", id: 1, result: { - configured: true, + initialized: true, + pages: [], }, }); expect(sessions).toHaveLength(1); - - await expect( - handle({ - jsonrpc: "2.0", - id: 4, - method: "runtime.loopback_status", - params: {}, - }), - ).resolves.toStrictEqual({ - jsonrpc: "2.0", - id: 4, - result: { - configured: true, - connected: true, - }, - }); + expect(sessions[0]?.prepareForInitializationCalls).toBe(1); }); - it("closes the previous browser session when reconfigured", async () => { + it("rejects a second stagehand.init without replacing the browser session", async () => { const sessions: FakeBrowserSession[] = []; const handle = createHandle({ browserSessionFactory: async () => { @@ -791,23 +760,20 @@ describe("Stagehand worker clients", () => { await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/first", - }, - }); - await handle({ - jsonrpc: "2.0", - id: 2, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/second", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/first"), }); + await expect( + handle({ + jsonrpc: "2.0", + id: 2, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/second"), + }), + ).resolves.toMatchObject({ error: { message: "Stagehand has already been initialized" } }); - expect(sessions).toHaveLength(2); - expect(sessions[0]?.closed).toBe(true); - expect(sessions[1]?.closed).toBe(false); + expect(sessions).toHaveLength(1); + expect(sessions[0]?.closed).toBe(false); }); it("closes the browser session on stagehand.close", async () => { @@ -819,10 +785,8 @@ describe("Stagehand worker clients", () => { await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }); await expect( @@ -841,22 +805,6 @@ describe("Stagehand worker clients", () => { }); expect(session.closed).toBe(true); - - await expect( - handle({ - jsonrpc: "2.0", - id: 3, - method: "runtime.loopback_status", - params: {}, - }), - ).resolves.toStrictEqual({ - jsonrpc: "2.0", - id: 3, - result: { - configured: false, - connected: false, - }, - }); }); it("calls Browser.getVersion through the browser session", async () => { @@ -874,10 +822,8 @@ describe("Stagehand worker clients", () => { await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }); await expect( @@ -935,10 +881,8 @@ describe("Stagehand worker clients", () => { await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }); await expect( @@ -973,10 +917,8 @@ describe("Stagehand worker clients", () => { await handle({ jsonrpc: "2.0", id: 1, - method: "runtime.configure", - params: { - cdpUrl: "ws://127.0.0.1:9222/devtools/browser/session", - }, + method: "stagehand.init", + params: configuredInitParams("ws://127.0.0.1:9222/devtools/browser/session"), }); await expect(