diff --git a/packages/sdk-ts/src/runtimeCompatibility.ts b/packages/sdk-ts/src/runtimeCompatibility.ts index cf05cc8ff..f95badb51 100644 --- a/packages/sdk-ts/src/runtimeCompatibility.ts +++ b/packages/sdk-ts/src/runtimeCompatibility.ts @@ -30,6 +30,11 @@ export const DEFAULT_RUNTIME_REQUIREMENT: RuntimeRequirement = Object.freeze({ maximumProtocolVersion: STAGEHAND_PROTOCOL_VERSION, }); +const RuntimeMarkerEnvelopeSchema = z.looseObject({ + protocolVersion: z.unknown(), + serverInfo: z.unknown(), +}); + export function negotiateRuntimeCompatibility( required: RuntimeRequirement, raw: unknown, @@ -42,7 +47,17 @@ export function negotiateRuntimeCompatibility( }; try { - const result = RuntimeDescriptorSchema.safeParse(raw); + const marker = RuntimeMarkerEnvelopeSchema.safeParse(raw); + if (!marker.success) + return { + kind: "unknown", + reason: "unreadable-marker", + detail: z.prettifyError(marker.error), + }; + const result = RuntimeDescriptorSchema.safeParse({ + protocolVersion: marker.data.protocolVersion, + serverInfo: marker.data.serverInfo, + }); if (!result.success) return { kind: "unknown", diff --git a/packages/sdk-ts/tests/runtimeCompatibility.test.ts b/packages/sdk-ts/tests/runtimeCompatibility.test.ts index 82172e277..552ed6578 100644 --- a/packages/sdk-ts/tests/runtimeCompatibility.test.ts +++ b/packages/sdk-ts/tests/runtimeCompatibility.test.ts @@ -70,12 +70,18 @@ describe("negotiateRuntimeCompatibility", () => { kind: "unknown", reason: "unreadable-marker", })); - it("reports unknown marker keys as unreadable", () => + it("accepts operational readiness fields around the strict descriptor", () => expect( - negotiateRuntimeCompatibility(requirement, { ...marker(4), status: "ready" }), - ).toMatchObject({ - kind: "unknown", - reason: "unreadable-marker", + negotiateRuntimeCompatibility(requirement, { + ...marker(4), + name: "stagehand", + state: "ready", + connected: true, + }), + ).toStrictEqual({ + kind: "compatible", + protocolVersion: 4, + serverInfo: { name: "stagehand", version: "4.0.0" }, })); it("does not throw for an unreadable proxy", () => { const raw = new Proxy({}, { get: () => throwOnRead() }); diff --git a/packages/server/controllers/contextController.ts b/packages/server/controllers/contextController.ts index 9f4013e1b..1ea360969 100644 --- a/packages/server/controllers/contextController.ts +++ b/packages/server/controllers/contextController.ts @@ -18,7 +18,10 @@ import type { import type { HandlerContext } from "../rpcRouter.js"; import type { StagehandRuntime } from "../runtime.js"; -export function createContextController(runtime: StagehandRuntime) { +export function createContextController( + runtime: StagehandRuntime, + options: { close?: () => Promise } = {}, +) { async function pages(_params: EmptyParams, { logger }: HandlerContext) { logger.debug("context.pages", {}); return runtime.contextPages(); @@ -41,7 +44,11 @@ export function createContextController(runtime: StagehandRuntime) { async function close(_params: EmptyParams, { logger }: HandlerContext) { logger.debug("context.close", {}); - return runtime.contextClose(); + if (options.close) { + await options.close(); + return { closed: true }; + } + return await runtime.contextClose(); } async function addInitScript(params: ContextAddInitScriptParams, { logger }: HandlerContext) { diff --git a/packages/server/rpcRouter.ts b/packages/server/rpcRouter.ts index f4250c261..718b19682 100644 --- a/packages/server/rpcRouter.ts +++ b/packages/server/rpcRouter.ts @@ -32,6 +32,7 @@ export type HandlerContext = { export type RPCRouterOptions = { initializeStagehand?: (params: StagehandInitParams) => Promise; closeStagehand?: () => Promise; + closeContext?: () => Promise; }; export class RPCRouter { @@ -48,7 +49,10 @@ export class RPCRouter { ...(options.initializeStagehand ? { initialize: options.initializeStagehand } : {}), ...(options.closeStagehand ? { close: options.closeStagehand } : {}), }); - this.contextController = createContextController(runtime); + this.contextController = createContextController( + runtime, + options.closeContext ? { close: options.closeContext } : {}, + ); this.pageController = createPageController(runtime); this.locatorController = createLocatorController(runtime); } diff --git a/packages/server/runtime.ts b/packages/server/runtime.ts index 98d58ac07..de35b5d69 100644 --- a/packages/server/runtime.ts +++ b/packages/server/runtime.ts @@ -236,8 +236,15 @@ export type StagehandBrowserSession = { export type StagehandBrowserSessionFactory = ( cdpUrl: string, logger: StagehandLogger, + lifecycle?: StagehandBrowserSessionLifecycle, ) => Promise; +export type StagehandBrowserSessionLifecycle = { + bootstrapMode?: "resident"; + onConnected?(): void; + onDisconnected?(): void; +}; + export type StagehandRuntimeAdapters = { browserSessionFactory?: StagehandBrowserSessionFactory; emitLog?: StagehandLogEmitter; @@ -275,7 +282,20 @@ export class StagehandRuntime { ); browserSession?: StagehandBrowserSession; pagesById = new Map(); + private browserSessionGeneration = 0; private initializationInProgress = false; + private readonly contextInitScripts: string[] = []; + private contextExtraHTTPHeaders?: ContextSetExtraHTTPHeadersParams["headers"]; + private contextDomainPolicy?: DomainPolicy | null; + private readonly pageInitScriptsById = new Map(); + private readonly pageExtraHTTPHeadersById = new Map< + string, + PageSetExtraHTTPHeadersParams["headers"] + >(); + private readonly pageViewportById = new Map< + string, + Pick + >(); constructor( readonly adapters: ResolvedStagehandRuntimeAdapters, @@ -284,18 +304,37 @@ export class StagehandRuntime { this.logger = new StagehandLogger(tracing, adapters.emitLog); } - async replaceBrowserConnection(params: { cdpUrl: string }): Promise { + browserConnectionStatus(): { configured: boolean; connected: boolean } { + return { + configured: this.browserSession !== undefined, + connected: this.browserSession?.connected ?? false, + }; + } + + async replaceBrowserConnection( + params: { cdpUrl: string }, + lifecycle?: StagehandBrowserSessionLifecycle, + ): Promise { const { cdpUrl } = params; + const generation = ++this.browserSessionGeneration; const previousSession = this.browserSession; this.browserSession = undefined; this.pagesById.clear(); await previousSession?.close(); + let browserSession: StagehandBrowserSession | undefined; try { - this.browserSession = await this.adapters.browserSessionFactory(cdpUrl, this.logger); + if (generation !== this.browserSessionGeneration) { + throw new Error("Stagehand browser session bootstrap was superseded"); + } + browserSession = await this.adapters.browserSessionFactory(cdpUrl, this.logger, lifecycle); + if (generation !== this.browserSessionGeneration) { + throw new Error("Stagehand browser session bootstrap was superseded"); + } + this.browserSession = browserSession; } catch (error) { - await this.browserSession?.close(); - this.browserSession = undefined; + await browserSession?.close(); + if (generation === this.browserSessionGeneration) this.browserSession = undefined; throw error; } } @@ -337,6 +376,32 @@ export class StagehandRuntime { } } + /** Restores initialization-dependent browser instrumentation after replacing a CDP session. */ + async restoreInitializedBrowserSession(): Promise { + if (this.state.getState().status !== "initialized") return; + const session = this.requireBrowserSession(); + await session.prepareForInitialization?.(); + for (const source of this.contextInitScripts) await session.addInitScript(source); + if (this.contextExtraHTTPHeaders) { + await session.setExtraHTTPHeaders(this.contextExtraHTTPHeaders); + } + if (this.contextDomainPolicy !== undefined) { + await session.setDomainPolicy(this.contextDomainPolicy); + } + await this.contextPages(); + for (const [pageId, page] of this.pagesById) { + for (const source of this.pageInitScriptsById.get(pageId) ?? []) { + await page.addInitScript(source); + } + const headers = this.pageExtraHTTPHeadersById.get(pageId); + if (headers) await page.setExtraHTTPHeaders(headers); + const viewport = this.pageViewportById.get(pageId); + if (viewport) { + await page.setViewportSize(viewport.width, viewport.height, viewport.options); + } + } + } + async generateLlm(input: LLMGenerateParams): Promise { const state = this.state.getState(); const model = state.status === "initialized" ? state.initParams.model : undefined; @@ -379,6 +444,8 @@ export class StagehandRuntime { async contextAddInitScript(params: ContextAddInitScriptParams): Promise { await this.requireBrowserSession().addInitScript(params.source); + if (!this.contextInitScripts.includes(params.source)) + this.contextInitScripts.push(params.source); return { ok: true }; } @@ -386,6 +453,7 @@ export class StagehandRuntime { params: ContextSetExtraHTTPHeadersParams, ): Promise { await this.requireBrowserSession().setExtraHTTPHeaders(params.headers); + this.contextExtraHTTPHeaders = { ...params.headers }; return { ok: true }; } @@ -395,6 +463,7 @@ export class StagehandRuntime { async contextSetDomainPolicy(params: ContextSetDomainPolicyParams): Promise { await this.requireBrowserSession().setDomainPolicy(params.policy); + this.contextDomainPolicy = params.policy; return { ok: true }; } @@ -531,11 +600,15 @@ export class StagehandRuntime { async pageAddInitScript(params: PageAddInitScriptParams): Promise { await this.resolvePage(params.pageId).addInitScript(params.source); + const sources = this.pageInitScriptsById.get(params.pageId) ?? []; + if (!sources.includes(params.source)) sources.push(params.source); + this.pageInitScriptsById.set(params.pageId, sources); return { ok: true }; } async pageSetExtraHTTPHeaders(params: PageSetExtraHTTPHeadersParams): Promise { await this.resolvePage(params.pageId).setExtraHTTPHeaders(params.headers); + this.pageExtraHTTPHeadersById.set(params.pageId, { ...params.headers }); return { ok: true }; } @@ -545,6 +618,11 @@ export class StagehandRuntime { params.height, params.options, ); + this.pageViewportById.set(params.pageId, { + width: params.width, + height: params.height, + ...(params.options === undefined ? {} : { options: { ...params.options } }), + }); return { ok: true }; } @@ -637,6 +715,9 @@ export class StagehandRuntime { const page = this.resolvePage(params.pageId); await page.close(); this.pagesById.delete(params.pageId); + this.pageInitScriptsById.delete(params.pageId); + this.pageExtraHTTPHeadersById.delete(params.pageId); + this.pageViewportById.delete(params.pageId); return { closed: true }; } @@ -714,9 +795,13 @@ export class StagehandRuntime { } async close(): Promise { + ++this.browserSessionGeneration; const session = this.browserSession; this.browserSession = undefined; this.pagesById.clear(); + this.pageInitScriptsById.clear(); + this.pageExtraHTTPHeadersById.clear(); + this.pageViewportById.clear(); try { await session?.close(); } finally { diff --git a/packages/server/service-worker-lifecycle/heartbeat-manager.ts b/packages/server/service-worker-lifecycle/heartbeat-manager.ts index b439465e1..be9755712 100644 --- a/packages/server/service-worker-lifecycle/heartbeat-manager.ts +++ b/packages/server/service-worker-lifecycle/heartbeat-manager.ts @@ -47,7 +47,6 @@ export function createServiceWorkerHeartbeatManager( let creatingDocument: Promise | null = null; let heartbeatPort: RuntimePort | null = null; let installed = false; - async function ensure(): Promise { const offscreen = chromeApi.offscreen; if (offscreen == null) return; diff --git a/packages/server/service-worker-lifecycle/resident-runtime.ts b/packages/server/service-worker-lifecycle/resident-runtime.ts new file mode 100644 index 000000000..1bb9be4fe --- /dev/null +++ b/packages/server/service-worker-lifecycle/resident-runtime.ts @@ -0,0 +1,289 @@ +import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.js"; +import type { + RuntimeDescriptor, + StagehandInitParams, + StagehandInitResult, +} from "../../protocol/types.js"; +import type { StagehandRuntime } from "../runtime.js"; +import { STAGEHAND_RUNTIME_VERSION } from "../version.js"; + +export type ResidentRuntimeState = + | "unconfigured" + | "connecting" + | "bootstrapping" + | "ready" + | "reconnecting" + | "failed" + | "closed"; + +export type StagehandRuntimeMarker = RuntimeDescriptor & { + name: "stagehand"; + version: string; + state: ResidentRuntimeState; + connected: boolean; + timings: { + connectAndBootstrapMs?: number; + totalMs?: number; + }; + failure?: { + phase: "connecting" | "bootstrapping" | "reconnecting"; + message: string; + }; +}; + +type ResidentRuntimeFailurePhase = NonNullable["phase"]; + +export type ResidentRuntimeLifecycleOptions = { + resolveResidentWebSocketUrl?: () => Promise; + waitForRpcReceiver?: () => Promise; + now?: () => number; + startedAt?: number; + reconnectDelaysMs?: readonly number[]; +}; + +const DEFAULT_RECONNECT_DELAYS_MS = [100, 250, 500] as const; + +/** Coordinates one browser VM's resident connection and same-session reconnects. */ +export class ResidentRuntimeLifecycle { + readonly marker: StagehandRuntimeMarker; + private readonly now: () => number; + private readonly startedAt: number; + private readonly reconnectDelaysMs: readonly number[]; + private operationGeneration = 0; + private operationTail: Promise = Promise.resolve(); + private bootstrapPromise?: Promise; + private reconnectTimer?: ReturnType; + private reconnectAttempt = 0; + private closed = false; + private residentBootstrapEnabled = true; + + constructor( + private readonly runtime: StagehandRuntime, + private readonly options: ResidentRuntimeLifecycleOptions = {}, + ) { + this.now = options.now ?? (() => performance.now()); + this.startedAt = options.startedAt ?? this.now(); + this.reconnectDelaysMs = options.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS_MS; + this.marker = { + name: "stagehand", + version: STAGEHAND_RUNTIME_VERSION, + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + serverInfo: { + name: "stagehand", + version: STAGEHAND_RUNTIME_VERSION, + }, + state: options.resolveResidentWebSocketUrl ? "connecting" : "unconfigured", + connected: false, + timings: {}, + }; + } + + bootstrap(): Promise { + if (this.closed) return Promise.resolve(); + if (!this.residentBootstrapEnabled) { + return Promise.reject(new Error("Resident browser connection is disabled")); + } + if (!this.options.resolveResidentWebSocketUrl) { + return Promise.reject(new Error("Resident browser proxy is not configured")); + } + if (this.marker.state === "ready" && this.runtime.browserConnectionStatus().connected) { + return Promise.resolve(); + } + if (this.bootstrapPromise) return this.bootstrapPromise; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + this.operationGeneration += 1; + } + + const generation = this.operationGeneration; + const operation = this.enqueue(() => this.runResidentBootstrap(generation)); + const tracked = operation.finally(() => { + if (this.bootstrapPromise === tracked) this.bootstrapPromise = undefined; + }); + this.bootstrapPromise = tracked; + return tracked; + } + + initialize(params: StagehandInitParams): Promise { + if (this.runtime.state.getState().status !== "created") { + return Promise.reject(new Error("Stagehand has already been initialized")); + } + + const bootstrap = this.bootstrap(); + const generation = this.operationGeneration; + return this.enqueue(async () => { + await bootstrap; + if (generation !== this.operationGeneration || this.closed) { + throw new Error("Resident runtime bootstrap was superseded"); + } + if (this.marker.state !== "ready" || !this.runtime.browserConnectionStatus().connected) { + throw new Error("Resident runtime is not ready for stagehand.init"); + } + return await this.runtime.initialize(params); + }); + } + + async initializeWithBrowserCdpUrl( + params: StagehandInitParams & { browserCdpUrl: string }, + ): Promise { + if (this.runtime.state.getState().status !== "created") { + throw new Error("Stagehand has already been initialized"); + } + this.disableResidentBootstrap(); + await this.runtime.replaceBrowserConnection({ cdpUrl: params.browserCdpUrl }); + return await this.runtime.initialize(params); + } + + async close(): Promise { + this.cancelReconnects(); + this.closed = true; + this.residentBootstrapEnabled = false; + this.bootstrapPromise = undefined; + ++this.operationGeneration; + await this.runtime.close(); + this.publish("closed", false, {}); + } + + private enqueue(operation: () => Promise): Promise { + const result = this.operationTail.then(operation, operation); + this.operationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async runResidentBootstrap(generation: number): Promise { + if (generation !== this.operationGeneration || this.closed) return; + + const resolveResidentWebSocketUrl = this.options.resolveResidentWebSocketUrl; + if (!resolveResidentWebSocketUrl) throw new Error("Resident browser proxy is not configured"); + + const connectStartedAt = this.now(); + const reconnecting = this.reconnectAttempt > 0 || this.marker.state === "reconnecting"; + this.publish(reconnecting ? "reconnecting" : "connecting", false, {}); + + try { + const cdpUrl = await resolveResidentWebSocketUrl(); + if (!this.isCurrent(generation)) return; + + await this.runtime.replaceBrowserConnection( + { cdpUrl }, + { + bootstrapMode: "resident", + onConnected: () => { + if (this.isCurrent(generation)) { + this.publish("bootstrapping", true, {}); + } + }, + onDisconnected: () => { + if (this.isCurrent(generation)) this.handleResidentDisconnect(); + }, + }, + ); + + if (!this.isCurrent(generation)) return; + if (!this.runtime.browserConnectionStatus().connected) { + throw new Error("Resident runtime disconnected during bootstrap"); + } + + await this.runtime.restoreInitializedBrowserSession(); + this.assertConnected(generation); + await this.options.waitForRpcReceiver?.(); + this.assertConnected(generation); + + this.cancelReconnects(); + this.publish("ready", true, { + connectAndBootstrapMs: this.now() - connectStartedAt, + totalMs: this.now() - this.startedAt, + }); + } catch (error) { + if (this.isCurrent(generation) && !this.closed) { + const phase = reconnecting + ? "reconnecting" + : this.marker.state === "bootstrapping" + ? "bootstrapping" + : "connecting"; + if (this.scheduleReconnect()) { + this.publish("reconnecting", false, this.marker.timings); + } else if (this.marker.state !== "failed") { + this.publishFailure( + phase, + phase === "reconnecting" + ? "Resident runtime reconnect budget exhausted" + : `Resident runtime ${phase} failed`, + ); + } + } + throw error; + } + } + + private assertConnected(generation: number): void { + if (!this.isCurrent(generation)) { + throw new Error("Stagehand browser session bootstrap was superseded"); + } + if (!this.runtime.browserConnectionStatus().connected) { + throw new Error("Stagehand browser session disconnected during bootstrap"); + } + } + + private handleResidentDisconnect(): void { + this.publish("reconnecting", false, this.marker.timings); + this.scheduleReconnect(); + } + + private scheduleReconnect(): boolean { + if (this.reconnectTimer || this.closed || !this.residentBootstrapEnabled) { + return Boolean(this.reconnectTimer); + } + const delay = this.reconnectDelaysMs[this.reconnectAttempt]; + if (delay === undefined) return false; + + this.reconnectAttempt += 1; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + if (this.closed) return; + this.bootstrapPromise = undefined; + this.operationGeneration += 1; + void this.bootstrap().catch(() => {}); + }, delay); + return true; + } + + private cancelReconnects(): void { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + this.reconnectAttempt = 0; + } + + private isCurrent(generation: number): boolean { + return this.residentBootstrapEnabled && generation === this.operationGeneration; + } + + private disableResidentBootstrap(): void { + this.cancelReconnects(); + this.residentBootstrapEnabled = false; + this.bootstrapPromise = undefined; + ++this.operationGeneration; + this.publish("unconfigured", false, {}); + } + + private publish( + state: ResidentRuntimeState, + connected: boolean, + timings: StagehandRuntimeMarker["timings"], + ): void { + this.marker.state = state; + this.marker.connected = connected; + this.marker.timings = timings; + delete this.marker.failure; + } + + private publishFailure(phase: ResidentRuntimeFailurePhase, message: string): void { + this.marker.state = "failed"; + this.marker.connected = false; + this.marker.failure = { phase, message }; + } +} diff --git a/packages/server/service-worker.ts b/packages/server/service-worker.ts index 9b2446fef..13d0800c2 100644 --- a/packages/server/service-worker.ts +++ b/packages/server/service-worker.ts @@ -3,46 +3,74 @@ import { StagehandMethods, StagehandNotifications, } from "../protocol/schema-registry.js"; -import { STAGEHAND_PROTOCOL_VERSION } from "../protocol/schemas.js"; -import type { RuntimeDescriptor } from "../protocol/types.js"; import { ChromeRuntimeClient } from "./clients/chromeRuntimeClient.js"; import { RPCClient } from "./clients/rpcClient.js"; import { RPCRouter } from "./rpcRouter.js"; import { installServiceWorkerHeartbeat } from "./service-worker-lifecycle/heartbeat-manager.js"; +import { resolveResidentBrowserWebSocketUrl } from "./service-worker-lifecycle/resident-browser-proxy.js"; +import { + ResidentRuntimeLifecycle, + type StagehandRuntimeMarker, +} from "./service-worker-lifecycle/resident-runtime.js"; import { createStagehandRuntime, type StagehandRuntime } from "./runtime.js"; import { browserWebSocketFactory } from "./understudy/browserWebSocketTransport.js"; import { ChromeTabTargetAdapter } from "./understudy/chromeTabs.js"; import { BrowserContext } from "./understudy/context.js"; -import { STAGEHAND_RUNTIME_VERSION } from "./version.js"; + +declare global { + interface ImportMeta { + readonly env: { + readonly VITE_STAGEHAND_BROWSER_PROXY_URL?: string; + }; + } +} export type StagehandServiceWorkerScope = { - __stagehand_runtime?: RuntimeDescriptor; + __stagehand_runtime?: StagehandRuntimeMarker; __stagehandReceiveFromHost?: (raw: unknown) => Promise; }; +export type StartStagehandServiceWorkerOptions = { + autoBootstrap?: boolean; + browserProxyUrl?: string; + resolveResidentWebSocketUrl?: () => Promise; + startedAt?: number; +}; + export function startStagehandServiceWorker( scope: StagehandServiceWorkerScope = globalThis as typeof globalThis & StagehandServiceWorkerScope, runtime?: StagehandRuntime, + options: StartStagehandServiceWorkerOptions = {}, ): RPCClient { const chromeRuntimeClient = new ChromeRuntimeClient(scope, STAGEHAND_SEND_TO_HOST_BINDING); + const receiverReady = deferred(); + installServiceWorkerHeartbeat(); let rpcClient: RPCClient | undefined; const activeRuntime = runtime ?? createStagehandRuntime({ - browserSessionFactory: async (cdpUrl, logger) => { + browserSessionFactory: async (cdpUrl, logger, lifecycle) => { const locatorRuntimeResponse = await fetch(chrome.runtime.getURL("content-script.js")); if (!locatorRuntimeResponse.ok) { throw new Error( `Failed to load Stagehand locator runtime: ${locatorRuntimeResponse.status}`, ); } + const fallbackLocatorScriptSource = await locatorRuntimeResponse.text(); + const residentConnection = lifecycle?.bootstrapMode === "resident"; return BrowserContext.create(cdpUrl, { websocketFactory: browserWebSocketFactory, logger, blankPageUrl: chrome.runtime.getURL("blank.html"), - fallbackLocatorScriptSource: await locatorRuntimeResponse.text(), + fallbackLocatorScriptSource, chromeTabs: new ChromeTabTargetAdapter(chrome), + ensureInitialPage: !residentConnection, + deferPageInstrumentation: residentConnection, + ...(lifecycle?.onConnected ? { onConnected: () => lifecycle.onConnected?.() } : {}), + ...(lifecycle?.onDisconnected + ? { onDisconnected: () => lifecycle.onDisconnected?.() } + : {}), }); }, emitLog: (log) => { @@ -58,20 +86,58 @@ export function startStagehandServiceWorker( }, }); - rpcClient = new RPCClient(chromeRuntimeClient, new RPCRouter(activeRuntime)); - scope.__stagehand_runtime = { - protocolVersion: STAGEHAND_PROTOCOL_VERSION, - serverInfo: { - name: "stagehand", - version: STAGEHAND_RUNTIME_VERSION, - }, - }; + const configuredBrowserProxyUrl = + options.browserProxyUrl ?? import.meta.env.VITE_STAGEHAND_BROWSER_PROXY_URL?.trim(); + const resolveResidentWebSocketUrl = + options.resolveResidentWebSocketUrl ?? + (configuredBrowserProxyUrl + ? async () => await resolveResidentBrowserWebSocketUrl(configuredBrowserProxyUrl) + : undefined); + const residentRuntime = new ResidentRuntimeLifecycle(activeRuntime, { + ...(resolveResidentWebSocketUrl ? { resolveResidentWebSocketUrl } : {}), + waitForRpcReceiver: () => receiverReady.promise, + ...(options.startedAt === undefined ? {} : { startedAt: options.startedAt }), + }); + rpcClient = new RPCClient( + chromeRuntimeClient, + new RPCRouter(activeRuntime, { + initializeStagehand: async (params) => { + if (params.browserCdpUrl) { + return await residentRuntime.initializeWithBrowserCdpUrl({ + ...params, + browserCdpUrl: params.browserCdpUrl, + }); + } + return await residentRuntime.initialize(params); + }, + closeStagehand: () => residentRuntime.close(), + closeContext: () => residentRuntime.close(), + }), + ); + scope.__stagehand_runtime = residentRuntime.marker; scope.__stagehandReceiveFromHost = (raw) => chromeRuntimeClient.receive(raw); + receiverReady.resolve(); + + const autoBootstrap = options.autoBootstrap ?? resolveResidentWebSocketUrl !== undefined; + if (autoBootstrap) { + void residentRuntime.bootstrap().catch(() => { + // oxlint-disable-next-line no-console + console.error("[stagehand] Resident runtime bootstrap failed"); + }); + } return rpcClient; } +function deferred(): { promise: Promise; resolve(): void } { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + if (typeof chrome !== "undefined") { - installServiceWorkerHeartbeat(); - startStagehandServiceWorker(); + const startedAt = performance.now(); + startStagehandServiceWorker(undefined, undefined, { startedAt }); } diff --git a/packages/server/tests/resident-runtime.test.ts b/packages/server/tests/resident-runtime.test.ts new file mode 100644 index 000000000..c480b0236 --- /dev/null +++ b/packages/server/tests/resident-runtime.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, it, vi } from "vitest"; +import { StagehandRpcRequestSchema } from "../../protocol/schema-registry.js"; +import { STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.js"; +import type { + StagehandBrowserSession, + StagehandBrowserSessionFactory, + UnderstudyRuntimePage, +} from "../runtime.js"; +import { createStagehandRuntime } from "../runtime.js"; +import { RPCRouter } from "../rpcRouter.js"; +import { + ResidentRuntimeLifecycle, + type ResidentRuntimeLifecycleOptions, +} from "../service-worker-lifecycle/resident-runtime.js"; + +const RESIDENT_TEST_URL = "ws://browser-proxy.test/devtools/browser/session"; + +const initParams = { + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + clientInfo: { name: "stagehand-sdk-ts", version: "4.0.0" }, + logLevel: "info" as const, + telemetry: { + traces: { endpoint: "http://127.0.0.1:4318/v1/traces", headers: {} }, + }, +}; + +function residentOptions( + options: ResidentRuntimeLifecycleOptions = {}, +): ResidentRuntimeLifecycleOptions { + return { + resolveResidentWebSocketUrl: async () => RESIDENT_TEST_URL, + reconnectDelaysMs: [], + ...options, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function createPage(targetId: string) { + const addInitScript = vi.fn(async () => {}); + const setExtraHTTPHeaders = vi.fn(async () => {}); + const setViewportSize = vi.fn(async () => {}); + const page = { + targetId: () => targetId, + url: () => "https://example.com/", + addInitScript, + setExtraHTTPHeaders, + setViewportSize, + } as unknown as UnderstudyRuntimePage; + return { page, addInitScript, setExtraHTTPHeaders, setViewportSize }; +} + +function createSession(pages: UnderstudyRuntimePage[] = []) { + let connected = true; + const close = vi.fn(async () => { + connected = false; + }); + const prepareForInitialization = vi.fn(async () => {}); + const addInitScript = vi.fn(async () => {}); + const setExtraHTTPHeaders = vi.fn(async () => {}); + const setDomainPolicy = vi.fn(async () => {}); + const session = { + get connected() { + return connected; + }, + pages: () => pages, + prepareForInitialization, + addInitScript, + setExtraHTTPHeaders, + setDomainPolicy, + close, + } as unknown as StagehandBrowserSession; + return { + session, + close, + prepareForInitialization, + addInitScript, + setExtraHTTPHeaders, + setDomainPolicy, + disconnect: () => { + connected = false; + }, + }; +} + +function connectedFactory( + create: (url: string) => Promise, +): StagehandBrowserSessionFactory { + return async (url, _logger, lifecycle) => { + lifecycle?.onConnected?.(); + return await create(url); + }; +} + +describe("resident runtime lifecycle", () => { + it("does nothing when the resident browser proxy is unconfigured", async () => { + const browserSessionFactory = vi.fn(); + const runtime = createStagehandRuntime({ browserSessionFactory }); + const lifecycle = new ResidentRuntimeLifecycle(runtime); + + expect(lifecycle.marker).toMatchObject({ state: "unconfigured", connected: false }); + await expect(lifecycle.bootstrap()).rejects.toThrow("not configured"); + await expect(lifecycle.initialize(initParams)).rejects.toThrow("not configured"); + expect(browserSessionFactory).not.toHaveBeenCalled(); + }); + + it("coalesces startup and waits for V3Context bootstrap and the RPC receiver", async () => { + const created = deferred(); + const receiverReady = deferred(); + const { session } = createSession(); + const browserSessionFactory = vi.fn(connectedFactory(async () => await created.promise)); + const runtime = createStagehandRuntime({ browserSessionFactory }); + const lifecycle = new ResidentRuntimeLifecycle( + runtime, + residentOptions({ + waitForRpcReceiver: () => receiverReady.promise, + }), + ); + + const first = lifecycle.bootstrap(); + const second = lifecycle.bootstrap(); + expect(first).toBe(second); + await vi.waitFor(() => expect(lifecycle.marker.state).toBe("bootstrapping")); + expect(lifecycle.marker.connected).toBe(true); + + created.resolve(session); + await Promise.resolve(); + expect(lifecycle.marker.state).toBe("bootstrapping"); + receiverReady.resolve(); + await first; + + expect(browserSessionFactory).toHaveBeenCalledOnce(); + expect(browserSessionFactory).toHaveBeenCalledWith( + RESIDENT_TEST_URL, + runtime.logger, + expect.objectContaining({ bootstrapMode: "resident" }), + ); + expect(lifecycle.marker).toMatchObject({ + state: "ready", + connected: true, + timings: { + connectAndBootstrapMs: expect.any(Number), + totalMs: expect.any(Number), + }, + }); + }); + + it("queues the first stagehand.init behind an active resident bootstrap", async () => { + const created = deferred(); + const { session } = createSession(); + const runtime = createStagehandRuntime({ + browserSessionFactory: connectedFactory(async () => await created.promise), + }); + const lifecycle = new ResidentRuntimeLifecycle(runtime, residentOptions()); + + const bootstrap = lifecycle.bootstrap(); + await vi.waitFor(() => expect(lifecycle.marker.state).toBe("bootstrapping")); + const initialization = lifecycle.initialize(initParams); + expect(runtime.state.getState().status).toBe("created"); + + created.resolve(session); + await bootstrap; + await expect(initialization).resolves.toMatchObject({ initialized: true }); + expect(runtime.state.getState().status).toBe("initialized"); + }); + + it("disables pending resident work before initializing an explicit CDP connection", async () => { + const residentUrl = deferred(); + const custom = createSession(); + const browserSessionFactory = vi.fn(connectedFactory(async () => custom.session)); + const runtime = createStagehandRuntime({ browserSessionFactory }); + const lifecycle = new ResidentRuntimeLifecycle( + runtime, + residentOptions({ resolveResidentWebSocketUrl: () => residentUrl.promise }), + ); + + const bootstrap = lifecycle.bootstrap(); + await vi.waitFor(() => expect(lifecycle.marker.state).toBe("connecting")); + await lifecycle.initializeWithBrowserCdpUrl({ + ...initParams, + browserCdpUrl: "ws://custom-browser.test/devtools/browser/session", + }); + residentUrl.resolve(RESIDENT_TEST_URL); + await bootstrap; + + expect(browserSessionFactory).toHaveBeenCalledOnce(); + expect(browserSessionFactory).toHaveBeenCalledWith( + "ws://custom-browser.test/devtools/browser/session", + runtime.logger, + undefined, + ); + expect(lifecycle.marker).toMatchObject({ state: "unconfigured", connected: false }); + expect(runtime.state.getState().status).toBe("initialized"); + }); + + it("closes without waiting for a stalled resident bootstrap", async () => { + const receiverReady = deferred(); + const current = createSession(); + const runtime = createStagehandRuntime({ + browserSessionFactory: connectedFactory(async () => current.session), + }); + const lifecycle = new ResidentRuntimeLifecycle( + runtime, + residentOptions({ waitForRpcReceiver: () => receiverReady.promise }), + ); + + const bootstrap = lifecycle.bootstrap(); + await vi.waitFor(() => expect(lifecycle.marker.state).toBe("bootstrapping")); + await lifecycle.close(); + + expect(current.close).toHaveBeenCalledOnce(); + expect(lifecycle.marker).toMatchObject({ state: "closed", connected: false }); + receiverReady.resolve(); + await expect(bootstrap).rejects.toThrow("superseded"); + }); + + it("re-resolves and restores initialized instrumentation after socket loss", async () => { + const firstPage = createPage("page-a"); + const secondPage = createPage("page-a"); + const first = createSession([firstPage.page]); + const second = createSession([secondPage.page]); + const sessions = [first.session, second.session]; + const hooks: Array[2]> = []; + const resolveResidentWebSocketUrl = vi.fn(async () => RESIDENT_TEST_URL); + const runtime = createStagehandRuntime({ + browserSessionFactory: connectedFactory(async () => sessions.shift()!), + }); + const originalFactory = runtime.adapters.browserSessionFactory; + runtime.adapters.browserSessionFactory = async (url, logger, lifecycle) => { + hooks.push(lifecycle); + return await originalFactory(url, logger, lifecycle); + }; + const lifecycle = new ResidentRuntimeLifecycle( + runtime, + residentOptions({ resolveResidentWebSocketUrl, reconnectDelaysMs: [0] }), + ); + + await lifecycle.bootstrap(); + await lifecycle.initialize(initParams); + await runtime.contextAddInitScript({ source: "globalThis.__resident = true" }); + await runtime.contextSetExtraHTTPHeaders({ headers: { "x-stagehand": "resident" } }); + await runtime.contextSetDomainPolicy({ policy: { allowedDomains: ["example.com"] } }); + await runtime.pageAddInitScript({ + pageId: "page-a", + source: "globalThis.__pageResident = true", + }); + await runtime.pageSetExtraHTTPHeaders({ + pageId: "page-a", + headers: { "x-stagehand-page": "resident" }, + }); + await runtime.pageSetViewportSize({ pageId: "page-a", width: 800, height: 600 }); + + const restored = deferred(); + second.prepareForInitialization.mockImplementation(async () => await restored.promise); + first.disconnect(); + hooks[0]?.onDisconnected?.(); + expect(lifecycle.marker).toMatchObject({ state: "reconnecting", connected: false }); + await vi.waitFor(() => expect(hooks).toHaveLength(2)); + expect(lifecycle.marker).toMatchObject({ state: "bootstrapping", connected: true }); + + restored.resolve(); + await vi.waitFor(() => expect(lifecycle.marker.state).toBe("ready")); + expect(resolveResidentWebSocketUrl).toHaveBeenCalledTimes(2); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.addInitScript).toHaveBeenCalledWith("globalThis.__resident = true"); + expect(second.setExtraHTTPHeaders).toHaveBeenCalledWith({ "x-stagehand": "resident" }); + expect(second.setDomainPolicy).toHaveBeenCalledWith({ allowedDomains: ["example.com"] }); + expect(secondPage.addInitScript).toHaveBeenCalledWith("globalThis.__pageResident = true"); + expect(secondPage.setExtraHTTPHeaders).toHaveBeenCalledWith({ + "x-stagehand-page": "resident", + }); + expect(secondPage.setViewportSize).toHaveBeenCalledWith(800, 600, undefined); + expect(runtime.state.getState().status).toBe("initialized"); + }); + + it("does not publish ready after disconnecting while the receiver is pending", async () => { + const receiverReady = deferred(); + const current = createSession(); + let hooks: Parameters[2]; + const runtime = createStagehandRuntime({ + browserSessionFactory: connectedFactory(async () => current.session), + }); + const originalFactory = runtime.adapters.browserSessionFactory; + runtime.adapters.browserSessionFactory = async (url, logger, lifecycle) => { + hooks = lifecycle; + return await originalFactory(url, logger, lifecycle); + }; + const lifecycle = new ResidentRuntimeLifecycle( + runtime, + residentOptions({ + reconnectDelaysMs: [60_000], + waitForRpcReceiver: () => receiverReady.promise, + }), + ); + + const bootstrap = lifecycle.bootstrap(); + await vi.waitFor(() => expect(lifecycle.marker.state).toBe("bootstrapping")); + current.disconnect(); + hooks?.onDisconnected?.(); + receiverReady.resolve(); + await expect(bootstrap).rejects.toThrow("disconnected"); + expect(lifecycle.marker).toMatchObject({ state: "reconnecting", connected: false }); + await lifecycle.close(); + }); + + it("publishes a sanitized terminal failure after the reconnect budget is exhausted", async () => { + const runtime = createStagehandRuntime({ + browserSessionFactory: async () => { + throw new Error("failed to connect to ws://private.internal/session?secret=value"); + }, + }); + const lifecycle = new ResidentRuntimeLifecycle(runtime, residentOptions()); + + await expect(lifecycle.bootstrap()).rejects.toThrow("private.internal"); + expect(lifecycle.marker).toMatchObject({ + state: "failed", + connected: false, + failure: { phase: "connecting", message: "Resident runtime connecting failed" }, + }); + expect(JSON.stringify(lifecycle.marker)).not.toContain("private.internal"); + }); + + it("preserves the reconnect failure phase when the retry budget is exhausted", async () => { + const first = createSession(); + let attempts = 0; + let hooks: Parameters[2]; + const browserSessionFactory = vi.fn( + connectedFactory(async () => { + attempts += 1; + if (attempts === 1) return first.session; + throw new Error("replacement failed"); + }), + ); + const runtime = createStagehandRuntime({ browserSessionFactory }); + const originalFactory = runtime.adapters.browserSessionFactory; + runtime.adapters.browserSessionFactory = async (url, logger, lifecycle) => { + hooks = lifecycle; + return await originalFactory(url, logger, lifecycle); + }; + const lifecycle = new ResidentRuntimeLifecycle( + runtime, + residentOptions({ reconnectDelaysMs: [0] }), + ); + + await lifecycle.bootstrap(); + first.disconnect(); + hooks?.onDisconnected?.(); + await vi.waitFor(() => expect(lifecycle.marker.state).toBe("failed")); + + expect(lifecycle.marker.failure).toStrictEqual({ + phase: "reconnecting", + message: "Resident runtime reconnect budget exhausted", + }); + }); + + it("clears connected state and consumes a pending retry when bootstrap is retried", async () => { + const replacement = createSession(); + let attempts = 0; + const browserSessionFactory = vi.fn( + connectedFactory(async () => { + attempts += 1; + if (attempts === 1) throw new Error("context bootstrap failed"); + return replacement.session; + }), + ); + const runtime = createStagehandRuntime({ browserSessionFactory }); + const lifecycle = new ResidentRuntimeLifecycle( + runtime, + residentOptions({ reconnectDelaysMs: [60_000] }), + ); + + await expect(lifecycle.bootstrap()).rejects.toThrow("context bootstrap failed"); + expect(lifecycle.marker).toMatchObject({ state: "reconnecting", connected: false }); + + await lifecycle.bootstrap(); + expect(browserSessionFactory).toHaveBeenCalledTimes(2); + expect(lifecycle.marker).toMatchObject({ state: "ready", connected: true }); + await lifecycle.close(); + }); + + it("does not reconnect after an intentional close", async () => { + const current = createSession(); + let hooks: Parameters[2]; + const browserSessionFactory = vi.fn(connectedFactory(async () => current.session)); + const runtime = createStagehandRuntime({ browserSessionFactory }); + const originalFactory = runtime.adapters.browserSessionFactory; + runtime.adapters.browserSessionFactory = async (url, logger, lifecycle) => { + hooks = lifecycle; + return await originalFactory(url, logger, lifecycle); + }; + const lifecycle = new ResidentRuntimeLifecycle( + runtime, + residentOptions({ reconnectDelaysMs: [0] }), + ); + + await lifecycle.bootstrap(); + await lifecycle.close(); + hooks?.onDisconnected?.(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(browserSessionFactory).toHaveBeenCalledOnce(); + expect(current.close).toHaveBeenCalledOnce(); + expect(lifecycle.marker).toMatchObject({ state: "closed", connected: false }); + }); + + it("routes context.close through resident lifecycle teardown", async () => { + const current = createSession(); + const runtime = createStagehandRuntime({ + browserSessionFactory: connectedFactory(async () => current.session), + }); + const lifecycle = new ResidentRuntimeLifecycle(runtime, residentOptions()); + const router = new RPCRouter(runtime, { closeContext: () => lifecycle.close() }); + + await lifecycle.bootstrap(); + await router.handle( + StagehandRpcRequestSchema.parse({ + jsonrpc: "2.0", + id: 1, + method: "context.close", + params: {}, + }), + ); + + expect(current.close).toHaveBeenCalledOnce(); + expect(lifecycle.marker.state).toBe("closed"); + }); +}); diff --git a/packages/server/tests/runtime-descriptor.test.ts b/packages/server/tests/runtime-descriptor.test.ts index cab8270e1..5d9d8e942 100644 --- a/packages/server/tests/runtime-descriptor.test.ts +++ b/packages/server/tests/runtime-descriptor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { RuntimeDescriptorSchema } from "../../protocol/schemas.ts"; +import { RuntimeDescriptorSchema, STAGEHAND_PROTOCOL_VERSION } from "../../protocol/schemas.ts"; import serverPackageJson from "../package.json" with { type: "json" }; import { startStagehandServiceWorker, @@ -12,15 +12,28 @@ describe("runtime descriptor", () => { const scope: StagehandServiceWorkerScope = {}; startStagehandServiceWorker(scope); - expect(RuntimeDescriptorSchema.parse(scope.__stagehand_runtime)).toStrictEqual( - scope.__stagehand_runtime, - ); - expect(scope.__stagehand_runtime).toStrictEqual({ - protocolVersion: 1, + const descriptor = RuntimeDescriptorSchema.parse({ + protocolVersion: scope.__stagehand_runtime?.protocolVersion, + serverInfo: scope.__stagehand_runtime?.serverInfo, + }); + expect(descriptor).toStrictEqual({ + protocolVersion: STAGEHAND_PROTOCOL_VERSION, + serverInfo: { + name: "stagehand", + version: serverPackageJson.version, + }, + }); + expect(scope.__stagehand_runtime).toMatchObject({ + name: "stagehand", + version: STAGEHAND_RUNTIME_VERSION, + protocolVersion: STAGEHAND_PROTOCOL_VERSION, serverInfo: { name: "stagehand", version: serverPackageJson.version, }, + state: "unconfigured", + connected: false, + timings: {}, }); }); diff --git a/packages/server/tests/runtime-state.test.ts b/packages/server/tests/runtime-state.test.ts index 739468a6d..8d640f5e1 100644 --- a/packages/server/tests/runtime-state.test.ts +++ b/packages/server/tests/runtime-state.test.ts @@ -40,7 +40,36 @@ function createBrowserSession( }; } +function deferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe("Stagehand runtime state", () => { + it("does not start a superseded browser session after an earlier close settles", async () => { + const previousClosed = deferred(); + const createdUrls: string[] = []; + const runtime = createStagehandRuntime({ + browserSessionFactory: async (url) => { + createdUrls.push(url); + return createBrowserSession({ + close: url === "ws://initial" ? () => previousClosed.promise : async () => {}, + }); + }, + }); + + await runtime.replaceBrowserConnection({ cdpUrl: "ws://initial" }); + const stale = runtime.replaceBrowserConnection({ cdpUrl: "ws://stale" }); + await runtime.replaceBrowserConnection({ cdpUrl: "ws://current" }); + previousClosed.resolve(); + + await expect(stale).rejects.toThrow("superseded"); + expect(createdUrls).toStrictEqual(["ws://initial", "ws://current"]); + }); + it("stores the exact validated Stagehand init params after initialization", async () => { const runtime = createStagehandRuntime({ browserSessionFactory: async () => createBrowserSession(), diff --git a/packages/server/tests/stagehand-clients.test.ts b/packages/server/tests/stagehand-clients.test.ts index 272d38abb..4c0026f30 100644 --- a/packages/server/tests/stagehand-clients.test.ts +++ b/packages/server/tests/stagehand-clients.test.ts @@ -8,7 +8,10 @@ import { StagehandRpcNotificationSchema, StagehandSendToHostBindingSchema, } from "../../protocol/schema-registry.ts"; -import { startStagehandServiceWorker } from "../service-worker.ts"; +import { + startStagehandServiceWorker, + type StagehandServiceWorkerScope, +} from "../service-worker.ts"; import type { StagehandBrowserSession, UnderstudyRuntimeClipboardOptions, @@ -665,6 +668,88 @@ describe("Stagehand worker clients", () => { }); }); + it("does not auto-bootstrap a public build without resident configuration", async () => { + const browserSessionFactory = vi.fn(async () => new FakeBrowserSession()); + const runtime = createStagehandRuntime({ browserSessionFactory }, testTracing); + const scope: StagehandServiceWorkerScope & { + [STAGEHAND_SEND_TO_HOST_BINDING](payload: string): void; + } = { + [STAGEHAND_SEND_TO_HOST_BINDING]: () => {}, + }; + + startStagehandServiceWorker(scope, runtime); + await Promise.resolve(); + + expect(browserSessionFactory).not.toHaveBeenCalled(); + expect(scope.__stagehand_runtime?.failure).toBeUndefined(); + }); + + it("retries resident bootstrap after a bounded resolver failure", async () => { + const session = new FakeBrowserSession(); + const runtime = createStagehandRuntime( + { + browserSessionFactory: async (_url, _logger, lifecycle) => { + lifecycle?.onConnected?.(); + return session; + }, + }, + testTracing, + ); + const resolveResidentWebSocketUrl = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("resident browser proxy is still starting")) + .mockResolvedValueOnce("ws://browser-proxy.test/session"); + const scope: StagehandServiceWorkerScope & { + [STAGEHAND_SEND_TO_HOST_BINDING](payload: string): void; + } = { + [STAGEHAND_SEND_TO_HOST_BINDING]: () => {}, + }; + + startStagehandServiceWorker(scope, runtime, { + autoBootstrap: true, + resolveResidentWebSocketUrl, + }); + + await vi.waitFor(() => { + expect(scope.__stagehand_runtime).toMatchObject({ state: "ready", connected: true }); + }); + expect(resolveResidentWebSocketUrl).toHaveBeenCalledTimes(2); + }); + + it("does not publish ready while bootstrap is pending even though the receiver is installed", async () => { + const session = new FakeBrowserSession(); + let finishBootstrap: (() => void) | undefined; + const runtime = createStagehandRuntime( + { + browserSessionFactory: async (_url, _logger, lifecycle) => { + lifecycle?.onConnected?.(); + await new Promise((resolve) => { + finishBootstrap = resolve; + }); + return session; + }, + }, + testTracing, + ); + const scope: StagehandServiceWorkerScope & { + [STAGEHAND_SEND_TO_HOST_BINDING](payload: string): void; + } = { + [STAGEHAND_SEND_TO_HOST_BINDING]: () => {}, + }; + + startStagehandServiceWorker(scope, runtime, { + autoBootstrap: true, + resolveResidentWebSocketUrl: async () => "ws://browser-proxy.test/session", + }); + await vi.waitFor(() => expect(scope.__stagehand_runtime?.state).toBe("bootstrapping")); + + expect(scope.__stagehandReceiveFromHost).toEqual(expect.any(Function)); + expect(scope.__stagehand_runtime?.state).not.toBe("ready"); + + finishBootstrap?.(); + await vi.waitFor(() => expect(scope.__stagehand_runtime?.state).toBe("ready")); + }); + it("returns responses without streaming debug logs at the default info level", async () => { const messages: unknown[] = []; const scope: {