diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e25c1f..73ee0ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number. +## [Unreleased] + +### Fixed + +- Extension: input to a background Agent Window tab no longer keeps failing with + `input_not_ready` after Chrome drops the session's focus override without a detach + ([#355](https://github.com/Tencent/BrowserSkill/issues/355)). The session's + background-execution lease resends the override and waits for a rendered frame; the + input is sent only once the page reports `visible`, and otherwise still fails before + dispatch. + ## [0.3.2] - 2026-09-30 ### Changed diff --git a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts index 9e9250b7..32dfe6ab 100644 --- a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts +++ b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts @@ -1354,6 +1354,53 @@ describe("controlled background execution", () => { await cdp.detachSession("agent"); cdp.dispose(); }); + + it("resends an owned override that the applied cache still reports active", async () => { + const { api } = fakeApi(); + const cdp = new ChromiumCdp(api); + const toggles = () => + vi + .mocked(api.sendCommand) + .mock.calls.filter(([, method]) => method === "Emulation.setFocusEmulationEnabled") + .map(([, , params]) => (params as { enabled: boolean }).enabled); + await cdp.acquireBackgroundExecution("agent", 4); + await cdp.send(4, "Runtime.evaluate", {}); + expect(toggles()).toEqual([true]); + await cdp.restoreBackgroundExecution("agent", 4); + expect(toggles()).toEqual([true, true]); + await expect(cdp.restoreBackgroundExecution("other", 4)).rejects.toThrow("not owned"); + expect(toggles()).toEqual([true, true]); + await cdp.releaseSessionTab("agent", 4); + expect(toggles()).toEqual([true, true, false]); + await expect(cdp.restoreBackgroundExecution("agent", 4)).rejects.toThrow("not owned"); + expect(toggles()).toEqual([true, true, false]); + cdp.dispose(); + }); + + it("still disables on release after a failed resend", async () => { + const { api } = fakeApi(); + const cdp = new ChromiumCdp(api); + await cdp.acquireBackgroundExecution("agent", 4); + // A passive reader keeps the attachment, so only the disable command can restore focus. + cdp.trackSessionTab("reader", 4); + vi.mocked(api.sendCommand).mockImplementation(async (_target, method, params) => { + if ( + method === "Emulation.setFocusEmulationEnabled" && + (params as { enabled: boolean }).enabled + ) + throw new Error("resend failed"); + return {}; + }); + await expect(cdp.restoreBackgroundExecution("agent", 4)).rejects.toThrow("resend failed"); + await cdp.releaseSessionTab("agent", 4); + expect(api.sendCommand).toHaveBeenLastCalledWith( + { tabId: 4 }, + "Emulation.setFocusEmulationEnabled", + { enabled: false }, + ); + expect(api.detach).not.toHaveBeenCalled(); + cdp.dispose(); + }); }); it("detaches on failed policy release even when a passive reader remains", async () => { diff --git a/apps/extension/src/browser-driver/background-execution.ts b/apps/extension/src/browser-driver/background-execution.ts index ad26f62c..9daafe0a 100644 --- a/apps/extension/src/browser-driver/background-execution.ts +++ b/apps/extension/src/browser-driver/background-execution.ts @@ -40,7 +40,12 @@ export class BackgroundExecution { this.applied.clear(); } - async synchronize(tabId: number): Promise { + /** Resend an owned override that Chrome dropped without a detach. Never forces a disable. */ + reapply(tabId: number): Promise { + return this.synchronize(tabId, true); + } + + async synchronize(tabId: number, reapply = false): Promise { // Keep raw commands serialized across detach/reattach as well. Invalidating // applied state does not cancel an issued Chrome command. // Join the preceding toggle, but retry a failed toggle on a subsequent call. @@ -49,13 +54,21 @@ export class BackgroundExecution { const next = (async () => { await previous?.catch(() => {}); if (this.attachment(tabId) !== initialAttachment) return; + // Keep the applied record while reapplying: if the resend fails, release must still disable. + let force = reapply; for (;;) { const attachment = this.attachment(tabId); if (!attachment) return; const enabled = this.owners.has(tabId); const applied = this.applied.get(tabId); - if (applied?.attachment === attachment && applied.enabled === enabled) return; + if ( + applied?.attachment === attachment && + applied.enabled === enabled && + !(force && enabled) + ) + return; if (!enabled && applied?.attachment !== attachment) return; + force = false; await this.toggle(tabId, enabled); if (this.attachment(tabId) !== attachment) { throw new Error("Background execution attachment changed during setup"); diff --git a/apps/extension/src/browser-driver/chromium-cdp.ts b/apps/extension/src/browser-driver/chromium-cdp.ts index 28826047..fd9cc278 100644 --- a/apps/extension/src/browser-driver/chromium-cdp.ts +++ b/apps/extension/src/browser-driver/chromium-cdp.ts @@ -263,6 +263,16 @@ export class ChromiumCdp { return this.backgroundExecution.has(sessionId, tabId); } + /** Resend this session's override when Chrome lost it without clearing the applied-state cache. */ + async restoreBackgroundExecution(sessionId: string, tabId: number): Promise { + if (!this.backgroundExecution.has(sessionId, tabId)) + throw new Error("Background execution is not owned by this session"); + await this.ensureRawAttached(tabId); + await this.backgroundExecution.reapply(tabId); + if (!this.backgroundExecution.has(sessionId, tabId)) + throw new Error("Background execution was released during restore"); + } + private async ensureRawAttached(tabId: number): Promise { // Returning a tab clears the cache before Chrome finishes detaching. // New observers must wait before opening the next connection to that tab. diff --git a/apps/extension/src/tools/__tests__/click.browser.test.ts b/apps/extension/src/tools/__tests__/click.browser.test.ts index b640d8b7..74611818 100644 --- a/apps/extension/src/tools/__tests__/click.browser.test.ts +++ b/apps/extension/src/tools/__tests__/click.browser.test.ts @@ -334,6 +334,34 @@ describe.skipIf(!process.env.BSK_CLICK_CHROME)("real browser click readiness", ( ).toBe(false); expect(documentChanges).toBe(0); expect(ctx.refStore.resolve(ref!, { tabId: 4 })).not.toBeNull(); + + // Drop the override on the same debugger session, bypassing the applied-state + // cache as a service-worker command would. The lease owner must restore it. + await send("Emulation.setFocusEmulationEnabled", { enabled: false }, target.sessionId); + expect(await target.evaluate("document.visibilityState")).toBe("hidden"); + expect(await prepare("tool.click")).toBeUndefined(); + cdpCommands.length = 0; + const recovered = await handleClick( + manager, + { session_id: ctx.sessionId, tab_id: 4, ref: ref! }, + { cdp, tabsApi }, + ); + expect( + recovered, + JSON.stringify({ recovered, commands: cdpCommands }), + ).not.toHaveProperty("code"); + expect(await target.evaluate("window.clicks")).toEqual([true, true]); + expect( + cdpCommands.filter( + (command) => command.method === "Emulation.setFocusEmulationEnabled", + ), + ).toHaveLength(1); + expect(cdpCommands.some((command) => command.method === "Page.captureScreenshot")).toBe( + false, + ); + expect(await target.evaluate("document.visibilityState")).toBe("visible"); + expect(cdp.ownsBackgroundExecution(ctx.sessionId, 4)).toBe(true); + expect(await foreground.evaluate("document.visibilityState")).toBe("visible"); await cdp.send(4, "Page.navigate", { url: `${url}/next` }); await vi.waitFor(() => expect(documentUpdates).toBeGreaterThan(0), { timeout: 5000 }); expect(documentChanges).toBeGreaterThan(0); diff --git a/apps/extension/src/tools/__tests__/dispatcher.test.ts b/apps/extension/src/tools/__tests__/dispatcher.test.ts index 24bdf88a..b9758862 100644 --- a/apps/extension/src/tools/__tests__/dispatcher.test.ts +++ b/apps/extension/src/tools/__tests__/dispatcher.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp"; import { SessionManager } from "@/session-manager/manager"; import type { ConnectionStateHandler, FrameHandler, Transport } from "@/transport/transport"; import type { @@ -345,6 +346,102 @@ describe("ToolDispatcher", () => { dispatcher.stop(); }); + it("recovers a leased click after Chrome drops the applied focus override", async () => { + const tab = { id: 7, windowId: 4242, active: false, url: "https://example.test" }; + vi.stubGlobal("chrome", { + tabs: { + get: vi.fn(async () => tab), + query: vi.fn(async () => [tab]), + sendMessage: vi.fn(async () => undefined), + }, + }); + const sessions = new SessionManager({ + agentWindow: { + create: vi.fn(async () => ({ windowId: 4242, initialTabIds: [7] })), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => 7), + }, + }); + const ctx = await sessions.start("aa11"); + ctx.refStore.set("e1", 12, { tabId: 7 }); + // Chrome's actual override, independent of BackgroundExecution's applied-state cache. + let emulated = false; + const toggles: boolean[] = []; + let pointerEvents = 0; + const chromeEvent = { + addListener: () => {}, + removeListener: () => {}, + } as unknown as CdpDebuggerApi["onEvent"]; + const api: CdpDebuggerApi = { + attach: vi.fn(async () => {}), + detach: vi.fn(async () => {}), + sendCommand: vi.fn(async (_target, method, params) => { + const p = (params ?? {}) as { enabled?: boolean; expression?: string }; + if (method === "Emulation.setFocusEmulationEnabled") { + emulated = p.enabled === true; + toggles.push(emulated); + return {}; + } + if (method === "Runtime.evaluate" && p.expression === "document.visibilityState") + return { result: { value: emulated ? "visible" : "hidden" } }; + // Animation frames run only while the page is visible. + if (method === "Runtime.evaluate" && p.expression?.includes("requestAnimationFrame")) + return { result: { value: emulated } }; + if (method === "DOM.getContentQuads") return { quads: [[0, 0, 40, 0, 40, 20, 0, 20]] }; + if (method === "DOM.getBoxModel") + return { model: { content: [0, 0, 40, 0, 40, 20, 0, 20] } }; + if (method === "Page.getLayoutMetrics") + return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } }; + if (method === "Runtime.evaluate") + return { + result: { value: { overlayHostPresent: false, overlayHostConnected: false } }, + }; + if (method === "Input.dispatchMouseEvent") { + if (!emulated) throw new Error("input sent to a hidden page"); + pointerEvents++; + } + return {}; + }), + onEvent: chromeEvent, + onDetach: chromeEvent as unknown as CdpDebuggerApi["onDetach"], + }; + const cdp = new ChromiumCdp(api); + const { transport, sent, deliver } = fakeTransport(); + const dispatcher = new ToolDispatcher({ transport, sessions, cdp }); + dispatcher.start(); + const click = async (count: number) => { + deliver(makeRequest("tool.click", { session_id: "aa11", ref: "e1" })); + await vi.waitFor(() => expect(sent).toHaveLength(count)); + return sent[count - 1]; + }; + try { + expect(await click(1)).toMatchObject({ result: { tab_id: 7, used_ref: "e1" } }); + expect(toggles).toEqual([true]); + expect(pointerEvents).toBe(3); + + // Turned off outside the cache, e.g. from the service worker. + emulated = false; + expect(await click(2)).toMatchObject({ result: { tab_id: 7, used_ref: "e1" } }); + expect(toggles).toEqual([true, true]); + expect(pointerEvents).toBe(6); + expect(emulated).toBe(true); + expect(cdp.ownsBackgroundExecution("aa11", 7)).toBe(true); + expect( + vi + .mocked(api.sendCommand) + .mock.calls.some(([, method]) => method === "Page.captureScreenshot"), + ).toBe(false); + + // The restored override stays with the lease for the next tool. + expect(await click(3)).toMatchObject({ result: { tab_id: 7, used_ref: "e1" } }); + expect(toggles).toEqual([true, true]); + expect(pointerEvents).toBe(9); + } finally { + dispatcher.stop(); + cdp.dispose(); + } + }); + it("wires the production tab APIs into tool.session_stop so a surviving user tab releases the window", async () => { // Regression guard for the deps that session_stop reads directly: when // `tabManagement.tabs` / `tabsQuery` are not injected by the dispatcher, diff --git a/apps/extension/src/tools/__tests__/interaction.test.ts b/apps/extension/src/tools/__tests__/interaction.test.ts index 0dfdfdef..c6b4da47 100644 --- a/apps/extension/src/tools/__tests__/interaction.test.ts +++ b/apps/extension/src/tools/__tests__/interaction.test.ts @@ -48,6 +48,11 @@ function makeFakeCdp( ) return { result: { value: visibility() } }; if (method === "Page.captureScreenshot") return { data: (await rendered()) ? "pixel" : "" }; + if ( + method === "Runtime.evaluate" && + String((params as { expression?: string })?.expression).includes("requestAnimationFrame") + ) + return { result: { value: await rendered() } }; if ( method === "Runtime.evaluate" && String((params as { expression?: string })?.expression).includes('return "absent"') @@ -733,6 +738,7 @@ describe("click input readiness", () => { defaultTimeoutMs?: number; onCommand?: (method: string, params: Record) => void; ownsBackgroundExecution?: boolean; + restoreBackgroundExecution?: () => void | Promise; } = {}, ) { const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); @@ -758,6 +764,13 @@ describe("click input readiness", () => { if (options.ownsBackgroundExecution !== undefined) { fake.cdp.ownsBackgroundExecution = vi.fn(() => options.ownsBackgroundExecution as boolean); } + const restore = options.restoreBackgroundExecution; + if (restore) { + fake.cdp.restoreBackgroundExecution = vi.fn(async () => { + fake.sent.push({ tabId: 4, method: "restoreBackgroundExecution" }); + await restore(); + }); + } return { ...fake, ctx, @@ -819,6 +832,103 @@ describe("click input readiness", () => { expect(f.mouseCommands()).toEqual([]); }); + it("restores a hidden leased target through its owner and waits for a frame", async () => { + let visibility = "hidden"; + const f = await fixture({ + ownsBackgroundExecution: true, + visibility: () => visibility, + restoreBackgroundExecution: () => { + visibility = "visible"; + }, + }); + expect(await f.click()).not.toHaveProperty("code"); + expect(f.cdp.restoreBackgroundExecution).toHaveBeenCalledWith("aa11", 4); + expect(f.focusCommands()).toEqual([]); + const expression = (c: { params?: object }) => + String((c.params as { expression?: string } | undefined)?.expression); + const steps = f.sent.map((c) => + c.method === "Runtime.evaluate" && expression(c) === "document.visibilityState" + ? "visibility" + : c.method === "Runtime.evaluate" && expression(c).includes("requestAnimationFrame") + ? "frames" + : c.method, + ); + const restored = steps.indexOf("restoreBackgroundExecution"); + expect(steps.slice(0, restored)).toEqual(["visibility"]); + expect(steps.slice(restored, restored + 3)).toEqual([ + "restoreBackgroundExecution", + "visibility", + "frames", + ]); + expect(steps.indexOf("Input.dispatchMouseEvent")).toBeGreaterThan(restored + 2); + expect(steps).not.toContain("Page.captureScreenshot"); + expect(steps).not.toContain("Page.bringToFront"); + expect(f.mouseCommands()).toHaveLength(3); + }); + + it.each([ + ["stays hidden", () => {}, "not visible"], + [ + "fails", + () => { + throw new Error("resend failed"); + }, + "resend failed", + ], + ])("reports input_not_ready before dispatch when a restored lease %s", async (_case, restore, message) => { + const f = await fixture({ + ownsBackgroundExecution: true, + visibility: () => "hidden", + restoreBackgroundExecution: restore, + }); + const result = await f.click(); + expect(result).toMatchObject({ + code: "cdp_failed", + data: { reason: "input_not_ready", effect_state: "none" }, + }); + expect((result as { message: string }).message).toContain(message); + expect(f.cdp.restoreBackgroundExecution).toHaveBeenCalledOnce(); + expect(f.focusCommands()).toEqual([]); + expect(f.mouseCommands()).toEqual([]); + }); + + it("reports input_not_ready when a restored lease produces no frame", async () => { + let visibility = "hidden"; + const f = await fixture({ + ownsBackgroundExecution: true, + visibility: () => visibility, + rendered: () => false, + restoreBackgroundExecution: () => { + visibility = "visible"; + }, + }); + const result = await f.click(); + expect(result).toMatchObject({ + code: "cdp_failed", + data: { reason: "input_not_ready", effect_state: "none" }, + }); + expect((result as { message: string }).message).toContain("did not produce a frame"); + expect(f.focusCommands()).toEqual([]); + expect(f.mouseCommands()).toEqual([]); + }); + + it("cancels a pending lease restore without dispatching input", async () => { + const controller = new AbortController(); + const f = await fixture({ + ownsBackgroundExecution: true, + visibility: () => "hidden", + restoreBackgroundExecution: () => { + controller.abort(); + return new Promise(() => {}); + }, + }); + const result = await f.click(controller.signal); + expect(result).toMatchObject({ code: "cancelled", data: { effect_state: "none" } }); + expect(result).not.toHaveProperty("data.reason"); + expect(f.focusCommands()).toEqual([]); + expect(f.mouseCommands()).toEqual([]); + }); + it("wakes even an active-but-hidden page before geometry, then restores after release", async () => { const f = await fixture(); expect(await f.click()).not.toHaveProperty("code"); diff --git a/apps/extension/src/tools/input-readiness.ts b/apps/extension/src/tools/input-readiness.ts index e2ecea37..39443c5f 100644 --- a/apps/extension/src/tools/input-readiness.ts +++ b/apps/extension/src/tools/input-readiness.ts @@ -90,6 +90,34 @@ export async function flushInputRendering( if (!shot.data) throw new Error("Renderer did not produce an input readiness frame"); } +/** Two animation frames on a visible page: the renderer completed a rendering update. */ +async function waitForRendererFrames( + cdp: CdpRunner, + tabId: number, + failure: string, + signal?: AbortSignal, + deadline?: number, +): Promise { + if (signal?.aborted) throw new DOMException("input aborted", "AbortError"); + const reply = await waitForInputReply( + cdp.send<{ result?: { value?: boolean } }>(tabId, "Runtime.evaluate", { + expression: `new Promise(resolve => { + let frame; + const timer = setTimeout(() => { cancelAnimationFrame(frame); resolve(false); }, 4000); + frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => { + clearTimeout(timer); resolve(true); + }); }); + })`, + awaitPromise: true, + returnByValue: true, + }), + signal, + 5000, + deadline, + ); + if (reply.result?.value !== true) throw new Error(failure); +} + /** After waking, wait for wheel scrolling to reach the renderer before hiding it again. */ export async function waitForInputPaint( cdp: CdpRunner, @@ -98,30 +126,71 @@ export async function waitForInputPaint( deadline?: number, ): Promise { try { - if (signal?.aborted) throw new DOMException("input aborted", "AbortError"); - const reply = await waitForInputReply( - cdp.send<{ result?: { value?: boolean } }>(tabId, "Runtime.evaluate", { - expression: `new Promise(resolve => { - let frame; - const timer = setTimeout(() => { cancelAnimationFrame(frame); resolve(false); }, 4000); - frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => { - clearTimeout(timer); resolve(true); - }); }); - })`, - awaitPromise: true, - returnByValue: true, - }), + await waitForRendererFrames( + cdp, + tabId, + "Renderer did not finish painting input", signal, - 5000, deadline, ); - if (reply.result?.value !== true) throw new Error("Renderer did not finish painting input"); } catch (error) { // This helper runs only after the wheel dispatch was acknowledged. throw new InputPaintUnconfirmedError(error); } } +function sampleVisibility(tabId: number, deps: InputReadinessDeps): Promise { + return waitForInputReply( + deps.cdp.send<{ result: { value?: string } }>(tabId, "Runtime.evaluate", { + expression: "document.visibilityState", + returnByValue: true, + }), + deps.signal, + 5000, + deps.deadline, + ).then((reply) => reply.result.value); +} + +/** Recover an owned lease whose override Chrome dropped outside the applied-state cache. + * Only the lease owner changes focus emulation; its override stays enabled for later tools. + * Wait on animation frames, not a surface read: after a re-show, Chrome can leave + * `Page.captureScreenshot({ fromSurface })` pending while frames already run. */ +async function restoreLeasedInput( + ctx: SessionContext, + tabId: number, + deps: InputReadinessDeps, +): Promise { + const notReady = (message: string): RpcError => ({ + code: "cdp_failed", + message, + data: { reason: "input_not_ready", effect_state: "none" }, + }); + const restore = deps.cdp.restoreBackgroundExecution?.bind(deps.cdp); + if (!restore) + return notReady("Input target is hidden despite an owned background-execution lease"); + try { + await waitForInputReply(restore(ctx.sessionId, tabId), deps.signal, 5000, deps.deadline); + // Frames stay suspended while hidden, so check visibility before waiting on them. + if ((await sampleVisibility(tabId, deps)) !== "visible") + return notReady("Input target is not visible after restoring its background-execution lease"); + await waitForRendererFrames( + deps.cdp, + tabId, + "Renderer did not produce a frame after restoring its background-execution lease", + deps.signal, + deps.deadline, + ); + return; + } catch (error) { + // Cancellation and deadlines keep their own codes in the caller. + if (isAbortError(error) || (error instanceof Error && error.name === "TimeoutError")) + throw error; + return notReady( + `Could not restore the owned background-execution lease: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + /** Prepare hidden native input without activating the tab or retrying the action. * Focus ownership relies on the daemon's per-session queue and exclusive tab ownership. */ export async function withInputReady( @@ -150,25 +219,15 @@ export async function withInputReady( // A persistent lease owns focus emulation for dispatcher-controlled tools; // unowned direct-handler paths retain the bounded 0.3.0 fallback below. const persistentLease = deps.cdp.ownsBackgroundExecution?.(ctx.sessionId, tabId) === true; - const visibility = await waitForInputReply( - deps.cdp.send<{ result: { value?: string } }>(tabId, "Runtime.evaluate", { - expression: "document.visibilityState", - returnByValue: true, - }), - deps.signal, - 5000, - deps.deadline, - ); + const visibility = await sampleVisibility(tabId, deps); const cancelled = abortError(deps.signal); if (cancelled) return cancelled; checkActive(); - if (visibility.result.value === "hidden") { - if (persistentLease) - return { - code: "cdp_failed", - message: "Input target is hidden despite an owned background-execution lease", - data: { reason: "input_not_ready", effect_state: "none" }, - }; + if (visibility === "hidden" && persistentLease) { + const notReady = await restoreLeasedInput(ctx, tabId, deps); + if (notReady) return notReady; + checkActive(); + } else if (visibility === "hidden") { attachmentId = deps.cdp.getAttachmentId?.(tabId); // Mark ownership before awaiting: a failed reply may still have enabled it. restoreFocus = true; @@ -180,7 +239,7 @@ export async function withInputReady( ); checkActive(); await flushInputRendering(deps.cdp, tabId, deps.signal, deps.deadline); - } else if (visibility.result.value !== "visible") { + } else if (visibility !== "visible") { throw new Error("Could not determine input target visibility"); } const cancelledAfterEnable = abortError(deps.signal); diff --git a/apps/extension/src/tools/shared.ts b/apps/extension/src/tools/shared.ts index 1e06a3ca..88fa4647 100644 --- a/apps/extension/src/tools/shared.ts +++ b/apps/extension/src/tools/shared.ts @@ -72,6 +72,8 @@ export interface CdpRunner { acquireBackgroundExecution?(sessionId: string, tabId: number): Promise; /** Whether this session requests the persistent override, regardless of applied CDP state. */ ownsBackgroundExecution?(sessionId: string, tabId: number): boolean; + /** Resend this session's owned override, even when the applied-state cache reports it active. */ + restoreBackgroundExecution?(sessionId: string, tabId: number): Promise; trackSessionTab?(sessionId: string, tabId: number): void; releaseSessionTab?(sessionId: string, tabId: number): Promise; onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): { diff --git a/crates/bsk-cli/tests/idle_exit.rs b/crates/bsk-cli/tests/idle_exit.rs index 6fe91905..a39c2f0c 100644 --- a/crates/bsk-cli/tests/idle_exit.rs +++ b/crates/bsk-cli/tests/idle_exit.rs @@ -33,6 +33,9 @@ fn daemon_self_exits_after_idle_timeout() { let out = Command::new(bsk_bin()) .env("BSK_HOME", &home) + // Release checks and daemon replacement are outside this idle-lifecycle test. + .env("BSK_AUTO_UPDATE", "off") + .env("BSK_UPDATE_MANIFEST_URL", "http://127.0.0.1:1/disabled") .env("RUST_LOG", "warn") .args(["daemon", "start", "--port", "0", "--daemon-idle", "2s"]) .output() @@ -67,6 +70,9 @@ fn daemon_stays_alive_while_ipc_connection_is_open() { let out = Command::new(bsk_bin()) .env("BSK_HOME", &home) + // Release checks and daemon replacement are outside this idle-lifecycle test. + .env("BSK_AUTO_UPDATE", "off") + .env("BSK_UPDATE_MANIFEST_URL", "http://127.0.0.1:1/disabled") .env("RUST_LOG", "warn") .args(["daemon", "start", "--port", "0", "--daemon-idle", "2s"]) .output() diff --git a/crates/bsk-cli/tests/remote_server.rs b/crates/bsk-cli/tests/remote_server.rs index fddd2e52..d0eaeb9e 100644 --- a/crates/bsk-cli/tests/remote_server.rs +++ b/crates/bsk-cli/tests/remote_server.rs @@ -178,6 +178,9 @@ async fn browser_capacity_does_not_block_renewal_or_replacement() { let (mut ws, _) = tokio_tungstenite::connect_async(server.request(&first, ORIGIN)) .await .unwrap(); + // HTTP upgrade completes before the server records the authenticated device. + // Establish the first owner before testing replacement at capacity. + handshake(&mut ws, "initial").await; // An authenticated socket stalled before the native handshake must also // be replaceable, without retaining a second device capacity slot. let (mut stalled, _) = tokio_tungstenite::connect_async(server.request(&first, ORIGIN))