Compare commits

...
Author SHA1 Message Date
Zhang GH cb5dc148d5 fix(dsh-plugin): remove obsolete client runtime host dependency
Keep dsh-client-runtime as a development-only type dependency. Remove its peer metadata, client injection entry, and runtime external allowance so newer hosts do not preflight a package they no longer ship.

Fixes #193
2026-09-08 16:58:20 +08:00
Zhang GH 3c5f838c56 Merge pull request #192 from Tencent/fix/windows-update-reliability
fix(update): make Windows self-updates reliable
2026-09-07 20:37:32 +08:00
iuyo5678 9337bd557b test(update): use blocking connections in Windows release server 2026-09-07 20:27:04 +08:00
iuyo5678 162a31ae92 fix(update): make Windows self-updates reliable 2026-09-07 20:04:15 +08:00
Zhang GH 2f84234a31 Merge pull request #191 from Tencent/fix/windows-ipc-process-liveness
fix(windows): preserve pipe continuity and verify process liveness
2026-09-07 19:06:38 +08:00
iuyo5678 72c10aab75 fix(windows): preserve pipe continuity and verify process liveness 2026-09-07 18:25:32 +08:00
Zhang GH 1eeb55af02 Merge pull request #190 from Tencent/fix/dsh-observation-sync-and-capture
fix(dsh): synchronize observation state and manage thumbnail capture
2026-09-07 17:46:31 +08:00
drakezhang 4992a54864 docs(dsh): clarify observation subscription and snapshot contracts 2026-09-07 17:38:35 +08:00
drakezhang 8b195eb0df test(extension): return tab id from navigation recovery mock 2026-09-07 17:26:01 +08:00
drakezhang eae4db1729 Merge main for navigation recovery test compatibility 2026-09-07 17:25:22 +08:00
drakezhang e961395be9 fix(dsh): synchronize observation state and manage thumbnail capture 2026-09-07 16:24:04 +08:00
Zhang GH a6d1d02acc Merge pull request #179 from Tencent/fix/cdp-extension-frame-recovery
fix: recover navigation from cross-extension CDP restrictions
2026-09-07 16:05:46 +08:00
Zhang GH 5ebe609c40 Merge pull request #189 from Tencent/fix/verify-fill-before-success
fix(extension): verify fill results before reporting success
2026-09-07 15:59:21 +08:00
drakezhang 321494d93f test(fill): cover caret positioning and clarify append behavior 2026-09-07 15:56:13 +08:00
drakezhang f2184e74c5 fix(fill): support background input and automatic append 2026-09-07 15:28:21 +08:00
drakezhang eeb633d68e fix(extension): verify fill results before reporting success 2026-09-07 13:25:54 +08:00
Zhang GH 6d181105f2 Merge pull request #188 from Tencent/fix/match-form-state-by-element
fix(extension): match form state by element identity
2026-09-07 12:57:42 +08:00
drakezhang ebad0ed1b5 fix(extension): match form state by element identity 2026-09-07 12:21:01 +08:00
Zhang GH 498f8fb4a7 Merge pull request #187 from Tencent/fix/release-automation-state-on-tab-return
Fix/release automation state on tab return
2026-09-07 12:12:41 +08:00
drakezhang cef59137e4 fix(extension): handle tab return cleanup edge cases 2026-09-07 11:44:11 +08:00
drakezhang f70978bfcf fix(extension): release automation state when returning borrowed tabs 2026-09-07 11:30:01 +08:00
Zhang GH 1498cd425c Merge pull request #186 from Tencent/fix/closed-borrowed-tabs-block-session-stop
fix(extension): prevent closed borrowed tabs from blocking session stop
2026-09-07 11:19:12 +08:00
drakezhang fd3ce202a1 fix: recover navigation from cross-extension CDP restrictions
Classify Chrome extension-frame access failures and provide an actionable CLI hint. Recover navigate and reload only when their initial CDP setup is denied, using browser navigation events and verifying that CDP access returns.

Preserve normal navigation, Agent Window checks, transfer error details, and network-idle semantics. Never replay an already-dispatched navigation.
2026-09-03 18:21:21 +08:00
49 changed files with 4407 additions and 353 deletions
+21
View File
@@ -37,6 +37,27 @@ jobs:
- name: Run Rust tests - name: Run Rust tests
run: cargo test --workspace --locked run: cargo test --workspace --locked
windows-daemon:
name: Windows daemon and update regression tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Run Windows named-pipe tests
run: cargo test -p bsk --lib --locked daemon::ipc::windows::tests
- name: Run Windows process liveness tests
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
- name: Run Windows update tests
run: cargo test -p bsk --lib --locked cli::update::tests
- name: Run Windows update process tests
run: cargo test -p bsk --test windows_update --locked
frontend: frontend:
name: Frontend lint, typecheck, tests, build name: Frontend lint, typecheck, tests, build
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -254,6 +254,69 @@ describe("ChromiumCdp", () => {
expect(cdp.isAttached(7)).toBe(false); expect(cdp.isAttached(7)).toBe(false);
}); });
it.each([
false,
true,
])("waits for detach before reconnecting a returned tab (detach rejects: %s)", async (rejectDetach) => {
const { api } = fakeApi();
const attached = new Set<number>();
let finishDetach!: () => void;
vi.mocked(api.attach).mockImplementation(async ({ tabId }) => {
if (attached.has(tabId!)) throw new Error("Another debugger is already attached");
attached.add(tabId!);
});
vi.mocked(api.detach).mockImplementation(async ({ tabId }) => {
attached.delete(tabId!);
});
vi.mocked(api.detach).mockImplementationOnce(
({ tabId }) =>
new Promise<void>((resolve, reject) => {
finishDetach = () => {
attached.delete(tabId!);
if (rejectDetach) reject(new Error("tab already detached"));
else resolve();
};
}),
);
const cdp = new ChromiumCdp(api);
cdp.trackSessionTab("aa11", 7);
await cdp.ensureAttached(7);
const returning = cdp.releaseSessionTab("aa11", 7);
await vi.waitFor(() => expect(api.detach).toHaveBeenCalledOnce());
let duplicateDetachFinished = false;
const duplicateDetach = cdp.detach(7).then(() => {
duplicateDetachFinished = true;
});
cdp.trackSessionTab("bb22", 7);
// Register rejection handlers immediately; the pre-fix driver rejects
// both commands while the browser still has the previous attachment.
const pending = Promise.allSettled([
cdp.send(7, "Runtime.evaluate", { expression: "document.title" }),
cdp.send(7, "DOM.getDocument"),
]);
await cdp.send(8, "DOM.getDocument");
const finishedEarly = duplicateDetachFinished;
finishDetach();
await Promise.all([returning, duplicateDetach]);
expect(await pending).toEqual([
{ status: "fulfilled", value: { ok: true } },
{ status: "fulfilled", value: { ok: true } },
]);
expect(finishedEarly).toBe(false);
expect(vi.mocked(api.attach).mock.calls.filter(([target]) => target.tabId === 7)).toHaveLength(
2,
);
expect(cdp.isAttached(7)).toBe(true);
expect(cdp.isAttached(8)).toBe(true);
await cdp.detachSession("bb22");
expect(api.detach).toHaveBeenCalledTimes(2);
expect(cdp.isAttached(7)).toBe(false);
expect(attached.has(7)).toBe(false);
expect(cdp.isAttached(8)).toBe(true);
});
it("detachAll() iterates every cached tab", async () => { it("detachAll() iterates every cached tab", async () => {
const { api } = fakeApi(); const { api } = fakeApi();
const cdp = new ChromiumCdp(api); const cdp = new ChromiumCdp(api);
@@ -651,6 +714,155 @@ describe("ChromiumCdp", () => {
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined(); expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
}); });
it("releases only the returning session's claim on a tab", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(1);
await cdp.ensureAttached(2);
cdp.trackSessionTab("aa11", 1);
cdp.trackSessionTab("bb22", 1);
cdp.trackSessionTab("aa11", 2);
await cdp.releaseSessionTab("unknown", 1);
await cdp.releaseSessionTab("aa11", 1);
expect(api.detach).not.toHaveBeenCalled();
await cdp.releaseSessionTab("bb22", 1);
await cdp.releaseSessionTab("bb22", 1);
expect(api.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 1 });
expect(cdp.isAttached(2)).toBe(true);
await cdp.detachSession("aa11");
expect(api.detach).toHaveBeenCalledTimes(2);
expect(api.detach).toHaveBeenLastCalledWith({ tabId: 2 });
});
it.each([
false,
true,
])("releases a tab with an attachment in flight (new owner: %s)", async (newOwner) => {
const { api } = fakeApi();
let finishAttach!: () => void;
vi.mocked(api.attach).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishAttach = resolve;
}),
);
const cdp = new ChromiumCdp(api);
cdp.trackSessionTab("aa11", 1);
const attaching = cdp.ensureAttached(1);
const releasing = cdp.releaseSessionTab("aa11", 1);
if (newOwner) cdp.trackSessionTab("bb22", 1);
finishAttach();
await Promise.all([attaching, releasing]);
expect(cdp.isAttached(1)).toBe(newOwner);
expect(api.detach).toHaveBeenCalledTimes(newOwner ? 0 : 1);
});
it("stops accepting dialogs on a returned tab even when another session keeps CDP attached", async () => {
const { api, onEvent } = fakeApi();
let controlled = true;
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog: () => controlled });
cdp.trackSessionTab("aa11", 1);
cdp.trackSessionTab("bb22", 1);
await cdp.ensureAttached(1);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "confirm",
message: "before return",
});
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(1));
vi.mocked(api.sendCommand).mockClear();
controlled = false;
await cdp.releaseSessionTab("aa11", 1);
expect(cdp.isAttached(1)).toBe(true);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "confirm",
message: "after return",
});
await Promise.resolve();
expect(api.sendCommand).not.toHaveBeenCalled();
expect(cdp.dialogsSince(1, 0)).toHaveLength(1);
controlled = true;
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "alert",
message: "borrowed again",
});
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(2));
expect(api.sendCommand).toHaveBeenCalledExactlyOnceWith(
{ tabId: 1 },
"Page.handleJavaScriptDialog",
{ accept: true },
);
});
it("ignores dialog events after detach, including a pending eligibility check", async () => {
const { api, onEvent } = fakeApi();
let resolveEligibility!: (value: boolean) => void;
const shouldAutoAcceptDialog = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveEligibility = resolve;
}),
);
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
await cdp.ensureAttached(1);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
await cdp.detach(1);
resolveEligibility(true);
await Promise.resolve();
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
expect(shouldAutoAcceptDialog).toHaveBeenCalledOnce();
expect(api.sendCommand).not.toHaveBeenCalledWith(
expect.anything(),
"Page.handleJavaScriptDialog",
expect.anything(),
);
expect(cdp.dialogsSince(1, 0)).toEqual([]);
});
it("does not accept dialogs when the tab's current scope cannot be determined", async () => {
const { api, onEvent } = fakeApi();
const shouldAutoAcceptDialog = vi.fn(async () => {
throw new Error("tab is gone");
});
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
await cdp.ensureAttached(1);
vi.mocked(api.sendCommand).mockClear();
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "confirm" });
await Promise.resolve();
expect(shouldAutoAcceptDialog).toHaveBeenCalledWith(1);
expect(api.sendCommand).not.toHaveBeenCalled();
expect(cdp.dialogsSince(1, 0)).toEqual([]);
});
it("does not restore dialog state when an acceptance completes after detach", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(1);
let finishAccept!: () => void;
vi.mocked(api.sendCommand).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishAccept = resolve;
}),
);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
await cdp.detach(1);
finishAccept();
await Promise.resolve();
expect(cdp.dialogsSince(1, 0)).toEqual([]);
expect(cdp.dialogCursor(1)).toBe(0);
});
it("detachSession only detaches tabs no other session owns", async () => { it("detachSession only detaches tabs no other session owns", async () => {
const { api } = fakeApi(); const { api } = fakeApi();
const cdp = new ChromiumCdp(api); const cdp = new ChromiumCdp(api);
@@ -165,6 +165,7 @@ export class ChromiumCdp {
private readonly api: CdpDebuggerApi; private readonly api: CdpDebuggerApi;
private readonly attachedTabs = new Set<number>(); private readonly attachedTabs = new Set<number>();
private readonly attachInFlight = new Map<number, Promise<void>>(); private readonly attachInFlight = new Map<number, Promise<void>>();
private readonly detachInFlight = new Map<number, Promise<void>>();
private readonly tabOwners = new Map<number, Set<string>>(); private readonly tabOwners = new Map<number, Set<string>>();
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>(); private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
private readonly dialogSequences = new Map<number, number>(); private readonly dialogSequences = new Map<number, number>();
@@ -182,7 +183,13 @@ export class ChromiumCdp {
private networkSubscription: { dispose(): void } | null = null; private networkSubscription: { dispose(): void } | null = null;
private frameTargetSubscription: { dispose(): void } | null = null; private frameTargetSubscription: { dispose(): void } | null = null;
constructor(api: CdpDebuggerApi = chromeDebuggerApi) { constructor(
api: CdpDebuggerApi = chromeDebuggerApi,
private readonly options: {
/** CDP observation alone does not authorize dismissing user dialogs. */
shouldAutoAcceptDialog?: (tabId: number) => boolean | Promise<boolean>;
} = {},
) {
this.api = api; this.api = api;
this.bindAutoDetach(); this.bindAutoDetach();
this.bindDialogHandler(); this.bindDialogHandler();
@@ -193,6 +200,10 @@ export class ChromiumCdp {
/** Attach to `tabId` if we haven't already in this driver. */ /** Attach to `tabId` if we haven't already in this driver. */
async ensureAttached(tabId: number): Promise<void> { async ensureAttached(tabId: number): Promise<void> {
// Returning a tab clears the cache before Chrome finishes detaching.
// New observers must wait before opening the next connection to that tab.
const detaching = this.detachInFlight.get(tabId);
if (detaching) await detaching;
if (this.attachedTabs.has(tabId)) return; if (this.attachedTabs.has(tabId)) return;
const existing = this.attachInFlight.get(tabId); const existing = this.attachInFlight.get(tabId);
if (existing) { if (existing) {
@@ -425,6 +436,11 @@ export class ChromiumCdp {
/** Detach if attached; never throws. */ /** Detach if attached; never throws. */
async detach(tabId: number): Promise<void> { async detach(tabId: number): Promise<void> {
const existing = this.detachInFlight.get(tabId);
if (existing) {
await existing;
return;
}
this.attachInFlight.delete(tabId); this.attachInFlight.delete(tabId);
if (!this.attachedTabs.has(tabId)) return; if (!this.attachedTabs.has(tabId)) return;
this.attachedTabs.delete(tabId); this.attachedTabs.delete(tabId);
@@ -432,13 +448,19 @@ export class ChromiumCdp {
this.clearConsoleState(tabId); this.clearConsoleState(tabId);
this.clearNetworkState(tabId); this.clearNetworkState(tabId);
this.clearFrameState(tabId); this.clearFrameState(tabId);
try { const detach = (async () => {
await this.api.detach({ tabId }); try {
} catch (err) { await this.api.detach({ tabId });
// Tab may already be gone — Chrome auto-detaches on close. Log } catch (err) {
// at debug so production builds aren't noisy. // Tab may already be gone — Chrome auto-detaches on close. Log
console.debug("[bsk cdp] detach failed (likely tab already closed)", err); // at debug so production builds aren't noisy.
} console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
}
})().finally(() => {
this.detachInFlight.delete(tabId);
});
this.detachInFlight.set(tabId, detach);
await detach;
} }
/** True iff `ensureAttached(tabId)` has succeeded since the last detach. */ /** True iff `ensureAttached(tabId)` has succeeded since the last detach. */
@@ -453,6 +475,17 @@ export class ChromiumCdp {
this.tabOwners.set(tabId, owners); this.tabOwners.set(tabId, owners);
} }
/** Release one session's claim, preserving attachments still used by another. */
async releaseSessionTab(sessionId: string, tabId: number): Promise<void> {
const owners = this.tabOwners.get(tabId);
if (!owners?.delete(sessionId) || owners.size > 0) return;
this.tabOwners.delete(tabId);
// An observation may still be attaching when the tab is returned. Wait
// for it so detach cannot miss the attachment or remove a new owner's claim.
await this.attachInFlight.get(tabId)?.catch(() => {});
if (!this.tabOwners.has(tabId)) await this.detach(tabId);
}
/** Subscribe to all CDP events. Returned disposable removes the listener. */ /** Subscribe to all CDP events. Returned disposable removes the listener. */
onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): { onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
dispose(): void; dispose(): void;
@@ -639,13 +672,23 @@ export class ChromiumCdp {
} }
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> { private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
const parsed = parseDialogOpeningParams(params); const parsed = parseDialogOpeningParams(params);
try { try {
if (
this.options.shouldAutoAcceptDialog &&
!(await this.options.shouldAutoAcceptDialog(tabId))
) {
return;
}
// The tab may have been returned while its current scope was checked.
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
const handleParams: { accept: boolean; promptText?: string } = { accept: true }; const handleParams: { accept: boolean; promptText?: string } = { accept: true };
if (parsed.type === "prompt") { if (parsed.type === "prompt") {
handleParams.promptText = parsed.defaultPrompt ?? ""; handleParams.promptText = parsed.defaultPrompt ?? "";
} }
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams); await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1; const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
this.dialogSequences.set(tabId, sequence); this.dialogSequences.set(tabId, sequence);
this.appendDialog(tabId, { this.appendDialog(tabId, {
@@ -835,15 +878,9 @@ export class ChromiumCdp {
/** Detach tabs only when no other live session has claimed them. */ /** Detach tabs only when no other live session has claimed them. */
async detachSession(sessionId: string): Promise<void> { async detachSession(sessionId: string): Promise<void> {
const tabsToDetach: number[] = []; await Promise.all(
for (const [tabId, owners] of this.tabOwners) { Array.from(this.tabOwners.keys(), (tabId) => this.releaseSessionTab(sessionId, tabId)),
owners.delete(sessionId); );
if (owners.size === 0) {
this.tabOwners.delete(tabId);
tabsToDetach.push(tabId);
}
}
await Promise.all(tabsToDetach.map((tabId) => this.detach(tabId)));
} }
} }
+6 -1
View File
@@ -51,7 +51,12 @@ export default defineBackground(() => {
const controller = new ConnectionController(); const controller = new ConnectionController();
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ }); const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
const sessions = new SessionManager(); const sessions = new SessionManager();
const cdp = new ChromiumCdp(); const cdp = new ChromiumCdp(undefined, {
shouldAutoAcceptDialog: async (tabId) => {
const tab = await chrome.tabs.get(tabId);
return sessions.findByWindowId(tab.windowId) !== null;
},
});
const sessionsLive = attachSessionsLiveFlag({ manager: sessions }); const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
let overlayGeneration = 0; let overlayGeneration = 0;
const controlModes = new Map<string, OverlayMode>(); const controlModes = new Map<string, OverlayMode>();
@@ -49,6 +49,47 @@ describe("ToolDispatcher", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it.each([
"tool.snapshot",
"tool.evaluate",
"tool.get_html",
"tool.console",
])("annotates Chrome extension-access failures from %s", async (method) => {
const tab = { id: 7, windowId: 4242, active: true, url: "https://example.test" };
vi.stubGlobal("chrome", {
tabs: {
get: vi.fn(async () => tab),
query: vi.fn(async () => [tab]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(),
},
});
await sessions.start("test");
const error = new Error("Cannot access a chrome-extension:// URL of different extension");
const cdp = {
send: vi.fn().mockRejectedValue(error),
ensureConsoleCapture: vi.fn().mockRejectedValue(error),
consoleEntriesSince: vi.fn(),
} as unknown as TestDispatcherCdp;
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
deliver(makeRequest(method, { session_id: "test", expression: "document.title" }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({
error: {
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
},
});
dispatcher.stop();
});
it("uses the configured recording runtime for start and stop", async () => { it("uses the configured recording runtime for start and stop", async () => {
const { transport, sent, deliver } = fakeTransport(); const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({ const sessions = new SessionManager({
@@ -747,6 +788,135 @@ describe("ToolDispatcher", () => {
); );
}); });
it.each([
{ tab_id: 7, code: "not_found" },
{ tab_id: undefined, code: "invalid_params" },
])("keeps hover when tab_return is rejected with $code", async ({ tab_id, code }) => {
const sendMessage = vi.fn(async () => undefined);
vi.stubGlobal("chrome", {
tabs: {
sendMessage,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: true })),
query: vi.fn(async () => [{ id: 7, windowId: 100, active: true }]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
const cdp = {
send: vi.fn(async <T>() => ({}) as T),
detachSession: vi.fn(async () => {}),
releaseSessionTab: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({
transport,
sessions,
cdp: cdp as unknown as TestDispatcherCdp,
});
const helpers = dispatcher as unknown as {
rememberHover: (sessionId: string, result: unknown) => unknown;
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
) => Promise<T>;
};
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
await helpers.setHoverBypass("aa11", 7, true);
dispatcher.start();
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({ error: { code } });
expect(cdp.releaseSessionTab).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
expect(cdp.send).toHaveBeenCalledExactlyOnceWith(7, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: 10,
y: 20,
});
dispatcher.stop();
});
it("releases the returned tab's hover before moving it and leaves other tabs alone", async () => {
const sendMessage = vi.fn(async () => undefined);
const move = vi.fn(async () => {
expect(sendMessage).toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
return { id: 7, windowId: 200, index: 4 };
});
vi.stubGlobal("chrome", {
tabs: { sendMessage, move },
windows: { get: vi.fn(async () => ({ id: 200 })) },
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const ctx = await sessions.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const cdp = {
send: vi.fn(async <T>() => ({}) as T),
detachSession: vi.fn(async () => {}),
releaseSessionTab: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({
transport,
sessions,
cdp: cdp as unknown as TestDispatcherCdp,
});
const helpers = dispatcher as unknown as {
rememberHover: (sessionId: string, result: unknown) => unknown;
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
) => Promise<T>;
};
for (const [sessionId, tabId] of [
["aa11", 7],
["aa11", 8],
["bb22", 9],
] as const) {
helpers.rememberHover(sessionId, { tab_id: tabId, x: 10, y: 20 });
await helpers.setHoverBypass(sessionId, tabId, true);
}
dispatcher.start();
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id: 7 }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({ result: { tab_id: 7, returned_to_window_id: 200 } });
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 7);
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
expect(cdp.send).not.toHaveBeenCalled();
for (const [sessionId, tabId] of [
["aa11", 8],
["bb22", 9],
] as const) {
expect(sendMessage).not.toHaveBeenCalledWith(
tabId,
expect.objectContaining({ enabled: false }),
);
await helpers.withHoverReassert({ session_id: sessionId, tab_id: tabId }, async () => ({}));
expect(cdp.send).toHaveBeenLastCalledWith(tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: 10,
y: 20,
});
}
dispatcher.stop();
});
it("transfers hover overlay bypass ownership between sessions", async () => { it("transfers hover overlay bypass ownership between sessions", async () => {
vi.stubGlobal("chrome", { vi.stubGlobal("chrome", {
tabs: { tabs: {
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import type { RpcError } from "@/transport/types";
import { cdpError, classifyCdpError } from "../errors";
const denied = "Cannot access a chrome-extension:// URL of different extension";
describe("CDP extension access errors", () => {
it("classifies both Chrome Error instances and string failures", () => {
for (const error of [new Error(denied), denied]) {
expect(cdpError(error)).toEqual({
code: "cdp_failed",
message: denied,
data: { reason: "cdp_extension_access_denied" },
});
}
});
it("preserves transfer outcome and cleanup instructions when CDP was denied", () => {
const error: RpcError = {
code: "cdp_failed",
message: denied,
data: {
reason: "transfer_outcome_unknown",
effect_state: "unknown",
cleanup_state: "failed",
},
};
expect(classifyCdpError(error)).toBe(error);
});
it("leaves other error codes and unrelated debugger failures unchanged", () => {
for (const error of [
{ code: "permission_denied", message: denied },
{ code: "cdp_failed", message: "Another debugger is already attached" },
] satisfies RpcError[]) {
expect(classifyCdpError(error)).toBe(error);
}
});
});
@@ -0,0 +1,573 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import { handleFill } from "../interaction";
interface ScriptParams {
functionDeclaration: string;
arguments?: Array<{ value: unknown }>;
}
async function setup(markup = '<input value="old">') {
document.body.innerHTML = markup;
const element = document.body.firstElementChild as HTMLInputElement;
const manager = new SessionManager({
agentWindow: {
create: async () => 100,
remove: async () => {},
ensureActiveTab: async () => 4,
},
});
const ctx = await manager.start("aa11");
ctx.refStore.set("e1", 12, { tabId: 4 });
const script = vi.fn(async (params: ScriptParams): Promise<unknown> => {
try {
const fn = new Function(`return (${params.functionDeclaration})`)();
return {
result: {
value: await fn.apply(
element,
(params.arguments ?? []).map((arg) => arg.value),
),
},
};
} catch {
return { exceptionDetails: { text: "test page script failed" } };
}
});
// Number/email controls have a browser caret even though their selection
// getters return null. Model that caret instead of assuming it is at the end.
const hiddenCaret = { start: 0, end: 0 };
const moveToEnd = vi.fn(async () => {
if (typeof element.selectionStart === "number") {
element.setSelectionRange(element.value.length, element.value.length);
} else {
hiddenCaret.start = hiddenCaret.end = element.value.length;
}
});
const insert = vi.fn(async (text: string) => {
const focused = document.activeElement as HTMLInputElement;
if (focused.readOnly || focused.disabled) return;
const start = focused.selectionStart ?? hiddenCaret.start;
const end = focused.selectionEnd ?? hiddenCaret.end;
const value = focused.value.slice(0, start) + text + focused.value.slice(end);
focused.value = focused.maxLength >= 0 ? value.slice(0, focused.maxLength) : value;
hiddenCaret.start = hiddenCaret.end = Math.min(start + text.length, focused.value.length);
focused.dispatchEvent(new Event("input", { bubbles: true }));
});
const release = vi.fn(async () => ({}));
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
switch (method) {
case "DOM.describeNode":
return {
node: {
nodeName: element.tagName,
attributes: Array.from(element.attributes).flatMap((attr) => [attr.name, attr.value]),
},
};
case "DOM.scrollIntoViewIfNeeded":
return {};
case "DOM.focus":
element.focus();
return {};
case "DOM.resolveNode":
return { object: { objectId: "fill-target" } };
case "Runtime.callFunctionOn":
return script(params as ScriptParams);
case "Input.insertText":
await insert((params as { text: string }).text);
return {};
case "Input.dispatchKeyEvent":
if ((params as { commands?: string[] }).commands?.includes("moveToEndOfDocument")) {
await moveToEnd();
}
return {};
case "Runtime.releaseObject":
return release();
default:
throw new Error(`unexpected ${method}`);
}
});
return {
element,
hiddenCaret,
moveToEnd,
script,
insert,
release,
send,
fill: (value = "hello", clearBefore = true, signal?: AbortSignal) =>
handleFill(
manager,
{ session_id: "aa11", ref: "e1", value, clear_before: clearBefore },
{
cdp: { send: send as CdpRunner["send"] },
tabsApi: {
get: async (id) => ({ id, windowId: 100, active: true }) as chrome.tabs.Tab,
query: async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab],
},
signal,
},
),
};
}
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});
describe("fill result verification", () => {
it.each([
"readonly",
"disabled",
])("preserves a %s field before rejecting it", async (attribute) => {
const h = await setup(`<input ${attribute} value="old">`);
expect(await h.fill()).toMatchObject({
code: "invalid_params",
data: { reason: "target_not_fillable" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it.each([
"checkbox",
"radio",
"range",
"date",
"file",
])("rejects an unsupported %s input before clearing", async (type) => {
const h = await setup(`<input type="${type}">`);
const before = h.element.value;
expect(await h.fill()).toMatchObject({
code: "invalid_params",
data: { reason: "target_not_fillable" },
});
expect(h.element.value).toBe(before);
expect(h.insert).not.toHaveBeenCalled();
});
it("rejects an overlong value before clearing", async () => {
const h = await setup('<input maxlength="3" value="old">');
expect(await h.fill()).toMatchObject({
code: "invalid_params",
message: /maxlength/,
data: { reason: "fill_value_invalid" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
});
it.each([
true,
false,
])("classifies a DOM.focus rejection on a disabled=%s target", async (disabled) => {
const h = await setup(`<input ${disabled ? "disabled" : ""} value="old">`);
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
if (method === "DOM.focus") throw new Error("Element is not focusable");
return send(tabId, method, params);
});
expect(await h.fill()).toMatchObject({
code: disabled ? "invalid_params" : "cdp_failed",
data: { reason: disabled ? "target_not_fillable" : "fill_failed" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it("fills a background document without requiring foreground browser focus", async () => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
expect(await h.fill()).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("hello");
});
it("restores focus once after clearing without typing into another field", async () => {
const h = await setup();
const other = document.createElement("input");
document.body.append(other);
h.element.addEventListener("input", () => other.focus());
expect(await h.fill()).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("hello");
expect(other.value).toBe("");
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
});
it.each([
"focus",
"value",
])("rechecks %s after restoring focus, without retrying again", async (action) => {
const h = await setup();
const other = document.createElement("input");
document.body.append(other);
h.element.addEventListener(
"input",
() => {
other.focus();
h.element.addEventListener("focus", () => {
if (action === "focus") queueMicrotask(() => other.focus());
else h.element.value = "changed";
});
},
{ once: true },
);
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: action === "focus" ? "fill_focus_lost" : "fill_target_changed" },
});
expect(other.value).toBe("");
expect(h.insert).not.toHaveBeenCalled();
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
});
it.each([
"remove",
"readonly",
"restore",
"microtask-restore",
])("stops when clearing causes the page to %s the target", async (action) => {
const h = await setup();
h.element.addEventListener("input", () => {
if (action === "remove") h.element.remove();
if (action === "readonly") h.element.readOnly = true;
if (action === "restore") h.element.value = "restored";
if (action === "microtask-restore")
queueMicrotask(() => {
h.element.value = "restored";
});
});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_target_changed" },
});
expect(h.insert).not.toHaveBeenCalled();
});
it("fails when insertText is acknowledged without changing the value", async () => {
const h = await setup();
h.insert.mockImplementation(async () => {});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.release).toHaveBeenCalledOnce();
});
it("detects truncation when maxlength changes after preparation", async () => {
const h = await setup();
h.element.addEventListener(
"input",
() => {
h.element.maxLength = 3;
},
{ once: true },
);
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("hel");
});
it.each([
"input",
"change",
"microtask-change",
])("detects a value reverted by the %s handler", async (event) => {
const h = await setup();
h.element.addEventListener(event === "microtask-change" ? "change" : event, () => {
if (event === "microtask-change")
queueMicrotask(() => {
h.element.value = "";
});
else h.element.value = "";
});
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ });
});
it("compares content rather than length without including field values in the error", async () => {
const h = await setup('<input type="password">');
h.element.addEventListener("change", () => {
h.element.value = "other-secret";
});
const result = await h.fill("given-secret");
expect(result).toMatchObject({ code: "cdp_failed", message: /expected value/ });
expect(JSON.stringify(result)).not.toMatch(/given-secret|other-secret/);
});
it("does not report success for a target replaced by a change handler", async () => {
const h = await setup();
h.element.addEventListener("change", () => h.element.replaceWith(h.element.cloneNode()));
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_target_changed" },
});
});
it.each([
1, 2, 3, 4, 5,
])("rejects script exceptions at phase %s and releases the target", async (phase) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const run = h.script.getMockImplementation()!;
let count = 0;
h.script.mockImplementation(async (params) =>
++count === phase ? { exceptionDetails: { text: "page secret" } } : run(params),
);
const result = await h.fill();
expect(result).toMatchObject({ code: "cdp_failed", data: { reason: "fill_failed" } });
expect(JSON.stringify(result)).not.toContain("page secret");
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it.each([1, 2, 3, 5])("rejects missing results at phase %s", async (phase) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const run = h.script.getMockImplementation()!;
let count = 0;
h.script.mockImplementation(async (params) =>
++count === phase ? { result: { type: "undefined" } } : run(params),
);
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
});
it.each(["input", "change"])("checks state after nested microtasks from %s", async (event) => {
const h = await setup();
h.element.addEventListener(event, () => {
queueMicrotask(() =>
queueMicrotask(() => {
h.element.value = "reverted";
}),
);
});
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
if (event === "input") expect(h.insert).not.toHaveBeenCalled();
});
it.each([
"<input>",
'<input type="password">',
"<textarea></textarea>",
])("verifies successful text in %s", async (markup) => {
const h = await setup(markup);
expect(await h.fill("你好🙂")).toMatchObject({ value_length: 4 });
expect(h.element.value).toBe("你好🙂");
expect(h.release).toHaveBeenCalledOnce();
});
it("verifies append against the old value plus the requested text", async () => {
const h = await setup();
h.element.focus();
h.element.setSelectionRange(3, 3);
expect(await h.fill("!", false)).toMatchObject({ value_length: 4 });
expect(h.element.value).toBe("old!");
});
it.each([
["<textarea></textarea>", "a\r\nb", "a\nb"],
["<input>", "a\nb", "a b"],
["<input>", "a\r\nb", "a b"],
[
'<input type="email" multiple>',
" a@example.com , b@example.com ",
"a@example.com,b@example.com",
],
])("accepts native normalization for %s", async (markup, requested, normalized) => {
if (markup.startsWith("<textarea")) {
// Happy DOM does not implement Chrome's textarea line-ending normalization.
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
vi.spyOn(HTMLTextAreaElement.prototype, "value", "set").mockImplementation(function (
this: HTMLTextAreaElement,
value,
) {
setter.call(this, value.replace(/\r\n?/g, "\n"));
});
}
const h = await setup(markup);
h.insert.mockImplementation(async () => {
h.element.value = normalized;
});
expect(await h.fill(requested)).toMatchObject({ value_length: normalized.length });
});
it("does not count an editable padding break as an extra typed character", async () => {
const h = await setup('<div contenteditable="true" tabindex="0"></div>');
// These two layout/editability properties are missing in Happy DOM.
let innerText = "";
Object.defineProperty(h.element, "isContentEditable", { get: () => true });
Object.defineProperty(h.element, "innerText", { get: () => innerText });
h.insert.mockImplementation(async () => {
h.element.innerHTML = "one<div><br></div>";
innerText = "one\n\n";
});
expect(await h.fill("one\n")).toMatchObject({ value_length: 4 });
});
it.each([
[0, 0],
[1, 1],
[0, 3],
])("appends after moving selection %s:%s to the end", async (start, end) => {
const h = await setup();
h.element.focus();
h.element.setSelectionRange(start, end);
expect(await h.fill("🙂", false)).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("old🙂");
});
it.each(
["number", "email"].flatMap((type) =>
[
[0, 0],
[1, 1],
[0, 2],
].map(([start, end]) => ({ type, start, end })),
),
)("appends to $type from selection $start:$end without using its unsupported API", async ({
type,
start,
end,
}) => {
const h = await setup(`<input type="${type}" value="12">`);
Object.assign(h.hiddenCaret, { start, end });
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
const selection = vi.spyOn(h.element, "setSelectionRange").mockImplementation(() => {
throw new DOMException("unsupported", "InvalidStateError");
});
expect(await h.fill("3", false)).toMatchObject({ value_length: 3 });
expect(h.element.value).toBe("123");
expect(selection).not.toHaveBeenCalled();
});
it.each([
"number",
"email",
])("does not report successful %s append when the caret command is ignored", async (type) => {
const h = await setup(`<input type="${type}" value="12">`);
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
h.moveToEnd.mockResolvedValueOnce(undefined);
expect(await h.fill("3", false)).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("312");
expect(h.insert).toHaveBeenCalledOnce();
});
it("rejects invalid number text before clearing", async () => {
const h = await setup('<input type="number" value="12">');
expect(await h.fill("abc")).toMatchObject({
code: "invalid_params",
data: { reason: "fill_value_invalid" },
});
expect(h.element.value).toBe("12");
expect(h.insert).not.toHaveBeenCalled();
});
it("reports page formatting as unconfirmed without retrying the write", async () => {
const h = await setup();
h.element.addEventListener("change", () => {
h.element.value = h.element.value.toUpperCase();
});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("HELLO");
expect(h.insert).toHaveBeenCalledOnce();
});
it.each([
["focus", false],
["focus", true],
["value", false],
["value", true],
["cancel", false],
["cancel", true],
["throw", false],
["throw", true],
] as const)("stops when positioning triggers %s (clear=%s) and releases the key", async (action, clearBefore) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const controller = new AbortController();
const other = document.createElement("input");
document.body.append(other);
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
const reply = await send(tabId, method, params);
if (
method === "Input.dispatchKeyEvent" &&
(params as { type: string }).type === "rawKeyDown"
) {
if (action === "focus") other.focus();
if (action === "value") h.element.value = "changed";
if (action === "cancel") controller.abort();
if (action === "throw") throw new Error("dispatch failed");
}
return reply;
});
const result = await h.fill("!", clearBefore, controller.signal);
expect(result).toMatchObject({ code: action === "cancel" ? "cancelled" : "cdp_failed" });
expect(h.insert).not.toHaveBeenCalled();
expect(other.value).toBe("");
expect(h.send).toHaveBeenCalledWith(
4,
"Input.dispatchKeyEvent",
expect.objectContaining({ type: "keyUp" }),
);
expect(h.release).toHaveBeenCalledOnce();
});
it.each([true, false])("verifies an empty request with clear_before=%s", async (clearBefore) => {
const h = await setup();
expect(await h.fill("", clearBefore)).toMatchObject({ value_length: clearBefore ? 0 : 3 });
expect(h.element.value).toBe(clearBefore ? "" : "old");
expect(h.insert).not.toHaveBeenCalled();
});
it("cancels after insertion without sending change events", async () => {
const h = await setup();
const controller = new AbortController();
h.insert.mockImplementation(async () => controller.abort());
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.script).toHaveBeenCalledTimes(2);
expect(h.release).toHaveBeenCalledOnce();
});
it("cancels after resolving the remote object without clearing the field", async () => {
const h = await setup();
const controller = new AbortController();
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
const result = await send(tabId, method, params);
if (method === "DOM.resolveNode") controller.abort();
return result;
});
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.element.value).toBe("old");
expect(h.script).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it("honors cancellation during verification", async () => {
const h = await setup();
const controller = new AbortController();
h.element.addEventListener("change", () => controller.abort());
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.release).toHaveBeenCalledOnce();
});
it("keeps the verified outcome when releasing the remote object fails", async () => {
const h = await setup();
h.release.mockRejectedValueOnce(new Error("target closed"));
expect(await h.fill()).toMatchObject({ value_length: 5 });
});
});
@@ -540,6 +540,21 @@ describe("handleHover", () => {
}); });
}); });
function successfulFillScript(params: unknown) {
const script = params as { arguments?: Array<{ value: unknown }>; functionDeclaration: string };
const args = script.arguments ?? [];
return {
result: {
value:
args.length === 2
? { before: "", expected: args[0].value }
: script.functionDeclaration.startsWith("function(expected)")
? { connected: true, matches: true, valueLength: String(args[0].value).length }
: "ready",
},
};
}
describe("handleFill", () => { describe("handleFill", () => {
it("returns not_found for unknown ref", async () => { it("returns not_found for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
@@ -586,7 +601,8 @@ describe("handleFill", () => {
"DOM.scrollIntoViewIfNeeded": () => ({}), "DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}), "DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }), "DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }), "Runtime.callFunctionOn": successfulFillScript,
"Runtime.releaseObject": () => ({}),
"Input.insertText": () => ({}), "Input.insertText": () => ({}),
}); });
const res = await handleFill( const res = await handleFill(
@@ -600,13 +616,12 @@ describe("handleFill", () => {
expect(res.used_ref).toBe("e1"); expect(res.used_ref).toBe("e1");
const insert = fake.sent.find((c) => c.method === "Input.insertText"); const insert = fake.sent.find((c) => c.method === "Input.insertText");
expect(insert?.params).toEqual({ text: "hello" }); expect(insert?.params).toEqual({ text: "hello" });
// clear_before defaults to true → callFunctionOn invoked once to // Foreground replacement needs no extra caret-positioning round trip.
// clear, once to fire input/change after typing.
const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn"); const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn");
expect(callFns.length).toBeGreaterThanOrEqual(2); expect(callFns).toHaveLength(4);
}); });
it("clear_before=false skips the wipe call", async () => { it("passes clear_before=false to preparation and verifies the result", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11"); const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 1, { tabId: 4 }); ctx.refStore.set("e1", 1, { tabId: 4 });
@@ -618,12 +633,12 @@ describe("handleFill", () => {
"DOM.focus": () => ({}), "DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }), "DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
"Runtime.callFunctionOn": (p) => { "Runtime.callFunctionOn": (p) => {
const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? ""; const args = (p as { arguments?: Array<{ value: unknown }> }).arguments ?? [];
// No "this.value = ''" clearing on a no-clear path; only the if (args.length === 2) expect(args[1].value).toBe(false);
// post-input dispatchEvent. return successfulFillScript(p);
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
return { result: { type: "undefined" } };
}, },
"Input.dispatchKeyEvent": () => ({}),
"Runtime.releaseObject": () => ({}),
"Input.insertText": () => ({}), "Input.insertText": () => ({}),
}); });
const res = await handleFill( const res = await handleFill(
@@ -650,7 +665,8 @@ describe("handleFill", () => {
"DOM.scrollIntoViewIfNeeded": () => ({}), "DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}), "DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }), "DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }), "Runtime.callFunctionOn": successfulFillScript,
"Runtime.releaseObject": () => ({}),
"Input.insertText": () => ({}), "Input.insertText": () => ({}),
}); });
const res = await handleFill( const res = await handleFill(
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { BrowserNavigationApi } from "../browser-navigation";
import { handleNavigate, handleReload } from "../navigation";
import type { CdpRunner } from "../shared";
const denied = "Cannot access a chrome-extension:// URL of different extension";
const url = "https://example.test/recovered";
function event<T extends (...args: never[]) => void>() {
const listeners = new Set<T>();
return {
listeners,
addListener: (listener: T) => listeners.add(listener),
removeListener: (listener: T) => listeners.delete(listener),
fire: (...args: Parameters<T>) => {
for (const listener of [...listeners]) listener(...args);
},
};
}
async function fixture() {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 4),
},
});
await manager.start("test");
type Listener = Parameters<BrowserNavigationApi["onCommitted"]["addListener"]>[0];
const events = {
onCommitted: event<Listener>(),
onDOMContentLoaded: event<Listener>(),
onCompleted: event<Listener>(),
onErrorOccurred: event<Listener>(),
};
const cdpEvents = event<Parameters<NonNullable<CdpRunner["onEvent"]>>[0]>();
const state = { blocked: true, destinationBlocked: false, emitIdle: true };
const send = vi.fn(async (_tabId: number, method: string) => {
if (state.blocked) throw new Error(denied);
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "main", loaderId: "new-loader" } } };
}
if (method === "Runtime.evaluate") return { result: { value: "complete" } };
if (method === "Page.setLifecycleEventsEnabled" && state.emitIdle) {
cdpEvents.fire({ tabId: 4 }, "Page.lifecycleEvent", {
frameId: "main",
loaderId: "new-loader",
name: "networkIdle",
});
}
if (method === "Page.navigate") return { frameId: "main", loaderId: "new-loader" };
return {};
});
const cdp: CdpRunner = {
send: send as CdpRunner["send"],
onEvent: (listener) => {
cdpEvents.addListener(listener);
return { dispose: () => cdpEvents.removeListener(listener) };
},
};
const main = { tabId: 4, frameId: 0, documentId: "new-document" };
const navigate = async () => {
state.blocked = state.destinationBlocked;
events.onCommitted.fire(main);
events.onDOMContentLoaded.fire(main);
events.onCompleted.fire(main);
};
const browserNavigation = { ...events, update: vi.fn(navigate), reload: vi.fn(navigate) };
const tab = { id: 4, windowId: 100, active: true, url } as chrome.tabs.Tab;
const tabsApi = { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) };
const deps = { cdp, tabsApi, browserNavigation, defaultTimeoutMs: 1000 };
const expectCleanedUp = () => {
for (const entry of Object.values(events)) expect(entry.listeners.size).toBe(0);
expect(cdpEvents.listeners.size).toBe(0);
};
return { manager, deps, state, send, events, main, cdpEvents, expectCleanedUp };
}
describe("navigation after Chrome denies extension-frame access", () => {
it.each([
"load",
"commit",
"domcontentloaded",
"networkidle",
] as const)("recovers the same tab with the requested %s checkpoint", async (waitUntil) => {
const f = await fixture();
const result = await handleNavigate(
f.manager,
{
session_id: "test",
url,
wait_until: waitUntil,
},
f.deps,
);
expect(result).toMatchObject({ tab_id: 4, final_url: url, reached: waitUntil });
expect(f.deps.browserNavigation.update).toHaveBeenCalledExactlyOnceWith(4, { url });
expect(f.send.mock.calls.some(([, method]) => method === "Page.navigate")).toBe(false);
expect(f.send).toHaveBeenCalledWith(4, "Page.enable", {});
f.expectCleanedUp();
});
it("recovers reload through tabs.reload and preserves hard reload", async () => {
const f = await fixture();
const result = await handleReload(f.manager, { session_id: "test", hard: true }, f.deps);
expect(result).toMatchObject({ tab_id: 4, reached: "load" });
expect(f.deps.browserNavigation.reload).toHaveBeenCalledExactlyOnceWith(4, {
bypassCache: true,
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("reports the restriction when reload leaves the conflicting frame present", async () => {
const f = await fixture();
f.state.destinationBlocked = true;
const result = await handleReload(f.manager, { session_id: "test" }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
});
expect(f.deps.browserNavigation.reload).toHaveBeenCalledOnce();
f.expectCleanedUp();
});
it("does not fall back for unrelated debugger failures", async () => {
const f = await fixture();
f.send.mockRejectedValue(new Error("Another debugger is already attached"));
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
message: "Another debugger is already attached",
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("does not replay navigation when a dispatched CDP command fails", async () => {
const f = await fixture();
f.state.blocked = false;
const send = f.send.getMockImplementation()!;
f.send.mockImplementation(async (tabId, method) => {
if (method === "Page.navigate") throw new Error(denied);
return send(tabId, method);
});
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("keeps the Agent Window guard in front of the recovery", async () => {
const f = await fixture();
f.deps.tabsApi.get.mockResolvedValue({
id: 4,
windowId: 200,
active: true,
url,
} as chrome.tabs.Tab);
const result = await handleNavigate(f.manager, { session_id: "test", tab_id: 4, url }, f.deps);
expect(result).toMatchObject({ code: "permission_denied" });
expect(f.send).not.toHaveBeenCalled();
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
});
it("ignores old-document, subframe, and other-tab completion events", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {});
let settled = false;
const work = handleNavigate(f.manager, { session_id: "test", url }, f.deps).then((result) => {
settled = true;
return result;
});
await vi.waitFor(() => expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce());
f.events.onCompleted.fire(f.main);
f.events.onCommitted.fire({ ...f.main, tabId: 9 });
f.events.onCommitted.fire({ ...f.main, frameId: 1 });
f.events.onCompleted.fire(f.main);
await Promise.resolve();
expect(settled).toBe(false);
f.events.onCommitted.fire(f.main);
f.events.onCompleted.fire({ ...f.main, documentId: "old-document" });
await Promise.resolve();
expect(settled).toBe(false);
f.state.blocked = false;
f.events.onCompleted.fire(f.main);
expect(await work).toMatchObject({ reached: "load" });
f.expectCleanedUp();
});
it("does not infer network idle from a browser load event", async () => {
const f = await fixture();
f.state.emitIdle = false;
const result = await handleNavigate(
f.manager,
{
session_id: "test",
url,
wait_until: "networkidle",
timeout_ms: 10,
},
f.deps,
);
expect(result).toMatchObject({ reached: "timeout" });
f.expectCleanedUp();
});
it("cleans up browser listeners on timeout", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {});
const result = await handleNavigate(
f.manager,
{ session_id: "test", url, timeout_ms: 10 },
f.deps,
);
expect(result).toMatchObject({ reached: "timeout" });
f.expectCleanedUp();
});
it("does not navigate an already-cancelled request", async () => {
const f = await fixture();
const result = await handleNavigate(
f.manager,
{ session_id: "test", url },
{
...f.deps,
signal: AbortSignal.abort(),
},
);
expect(result).toMatchObject({ code: "cancelled" });
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("cleans up browser listeners when cancelled after dispatch", async () => {
const f = await fixture();
const ac = new AbortController();
f.deps.browserNavigation.update.mockImplementation(async () => {
ac.abort();
});
const result = await handleNavigate(
f.manager,
{ session_id: "test", url },
{ ...f.deps, signal: ac.signal },
);
expect(result).toMatchObject({ code: "cancelled" });
expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce();
f.expectCleanedUp();
});
it("reports browser navigation errors and releases listeners", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {
f.events.onErrorOccurred.fire({ ...f.main, error: "net::ERR_NAME_NOT_RESOLVED" });
});
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({ code: "cdp_failed", message: "net::ERR_NAME_NOT_RESOLVED" });
f.expectCleanedUp();
});
});
@@ -257,11 +257,15 @@ describe("handleSessionStop with auto-return", () => {
moves: [], moves: [],
}; };
const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) }); const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) });
const cdp = {
detachSession: vi.fn(async () => {}),
releaseSessionTab: vi.fn(async () => {}),
};
const res = await handleSessionStop( const res = await handleSessionStop(
sm, sm,
{ session_id: "aa11" }, { session_id: "aa11" },
{ tabManagement: { tabs, windows } }, { cdp, tabManagement: { tabs, windows } },
); );
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`); if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]); expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]);
@@ -269,6 +273,8 @@ describe("handleSessionStop with auto-return", () => {
expect(sm.has("aa11")).toBe(true); expect(sm.has("aa11")).toBe(true);
expect(ctx.borrowedTabs.has(1)).toBe(true); expect(ctx.borrowedTabs.has(1)).toBe(true);
expect(ctx.borrowedTabs.has(2)).toBe(false); expect(ctx.borrowedTabs.has(2)).toBe(false);
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 2);
expect(cdp.detachSession).not.toHaveBeenCalled();
expect(aw.remove).not.toHaveBeenCalled(); expect(aw.remove).not.toHaveBeenCalled();
}); });
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp";
import { SessionManager } from "@/session-manager/manager"; import { SessionManager } from "@/session-manager/manager";
import { handleHover } from "../interaction";
import { import {
type AgentOverlayResetApi, type AgentOverlayResetApi,
type ChromeWindowsApi, type ChromeWindowsApi,
@@ -547,6 +549,148 @@ describe("handleTabBorrow", () => {
}); });
describe("handleTabReturn", () => { describe("handleTabReturn", () => {
it.each([
"not_found",
"cdp_failed",
])("detaches after selector resolution fails with %s", async (code) => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const { api: windows } = makeWindowsApi(state);
const debuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async () => {}),
sendCommand: vi.fn(async (_target: unknown, method: string) => {
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
if (method === "DOM.querySelector") {
if (code === "cdp_failed") throw new Error("selector query failed");
return { nodeId: 0 };
}
return {};
}),
onEvent: { addListener: vi.fn(), removeListener: vi.fn() },
onDetach: { addListener: vi.fn(), removeListener: vi.fn() },
};
const cdp = new ChromiumCdp(debuggerApi as unknown as CdpDebuggerApi);
const hover = await handleHover(
sm,
{ session_id: "aa11", tab_id: 7, selector: "#missing" },
{
cdp,
tabsApi: { get: api.get, query: vi.fn(async () => []) },
},
);
expect(hover).toMatchObject({ code });
expect(cdp.isAttached(7)).toBe(true);
const returned = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{
tabs: api,
windows,
cdp,
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
},
);
expect(returned).toMatchObject({ returned_to_window_id: 200 });
expect(ctx.borrowedTabs.has(7)).toBe(false);
expect(cdp.isAttached(7)).toBe(false);
expect(debuggerApi.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 7 });
cdp.dispose();
});
it("honours cancellation while preparing a validated return", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windows } = makeWindowsApi(state);
const controller = new AbortController();
const beforeReturn = vi.fn(async () => {
controller.abort();
});
const result = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{
tabs: api,
windows,
signal: controller.signal,
beforeReturn,
},
);
expect(result).toMatchObject({ code: "cancelled" });
expect(beforeReturn).toHaveBeenCalledExactlyOnceWith("aa11", 7);
expect(spies.move).not.toHaveBeenCalled();
expect(ctx.borrowedTabs.has(7)).toBe(true);
});
it.each([
"original",
"fallback",
"cleanup_failed",
"failed",
"cancelled",
])("releases CDP only after a successful return (%s)", async (mode) => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi } = makeWindowsApi(state, { lastFocused: 500 });
const success = mode !== "failed" && mode !== "cancelled";
if (mode === "fallback") spies.move.mockRejectedValueOnce(new Error("original move failed"));
if (mode === "failed") spies.move.mockRejectedValue(new Error("move failed"));
let windowAtRelease: number | undefined;
const cdp = {
releaseSessionTab: vi.fn(async () => {
windowAtRelease = state.tabs.get(7)?.windowId;
if (mode === "cleanup_failed") throw new Error("debugger release failed");
}),
};
const controller = new AbortController();
if (mode === "cancelled") controller.abort();
const result = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{
tabs: api,
windows: windowsApi,
cdp,
signal: controller.signal,
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
},
);
expect("code" in result).toBe(!success);
expect(ctx.borrowedTabs.has(7)).toBe(!success);
expect(cdp.releaseSessionTab).toHaveBeenCalledTimes(success ? 1 : 0);
if (success) {
expect(cdp.releaseSessionTab).toHaveBeenCalledWith("aa11", 7);
expect(windowAtRelease).toBe(mode === "fallback" ? 500 : 200);
expect(spies.move).toHaveBeenCalledTimes(mode === "fallback" ? 2 : 1);
}
});
it("returns the tab to its original window/index and clears borrowedTabs", async () => { it("returns the tab to its original window/index and clears borrowedTabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11"); const ctx = await sm.start("aa11");
@@ -0,0 +1,110 @@
import type { RpcError, WaitUntil } from "@/transport/types";
import { cdpError } from "./errors";
interface NavigationEvent {
tabId: number;
frameId: number;
documentId?: string;
error?: string;
}
interface NavigationEvents {
addListener(listener: (details: NavigationEvent) => void): void;
removeListener(listener: (details: NavigationEvent) => void): void;
}
/** Normal browser navigation remains available when chrome.debugger is denied. */
export interface BrowserNavigationApi {
update(tabId: number, props: { url: string }): Promise<unknown>;
reload(tabId: number, props: { bypassCache: boolean }): Promise<void>;
onCommitted: NavigationEvents;
onDOMContentLoaded: NavigationEvents;
onCompleted: NavigationEvents;
onErrorOccurred: NavigationEvents;
}
export const chromeBrowserNavigationApi: BrowserNavigationApi = {
update: (tabId, props) => chrome.tabs.update(tabId, props),
reload: (tabId, props) => chrome.tabs.reload(tabId, props),
get onCommitted() {
return chrome.webNavigation.onCommitted;
},
get onDOMContentLoaded() {
return chrome.webNavigation.onDOMContentLoaded;
},
get onCompleted() {
return chrome.webNavigation.onCompleted;
},
get onErrorOccurred() {
return chrome.webNavigation.onErrorOccurred;
},
};
export type NavigationOutcome =
| { reached: "match" | "timeout" | "cancelled"; lastLifecycle?: string }
| { reached: "failed"; error: RpcError };
/** Subscribe before dispatch and ignore the departing document's load events. */
export async function navigateWithBrowserApi(
api: BrowserNavigationApi,
tabId: number,
action: () => Promise<unknown>,
waitUntil: Exclude<WaitUntil, "networkidle">,
timeoutMs: number,
signal?: AbortSignal,
): Promise<NavigationOutcome> {
if (signal?.aborted) return { reached: "cancelled" };
if (timeoutMs <= 0) return { reached: "timeout" };
let committed = false;
let documentId: string | undefined;
let lastLifecycle: string | undefined;
let finish!: (outcome: NavigationOutcome) => void;
const outcome = new Promise<NavigationOutcome>((resolve) => {
finish = resolve;
});
const isMainFrame = (details: NavigationEvent) =>
details.tabId === tabId && details.frameId === 0;
const onCommitted = (details: NavigationEvent) => {
if (!isMainFrame(details)) return;
committed = true;
documentId = details.documentId;
lastLifecycle = "commit";
if (waitUntil === "commit") finish({ reached: "match", lastLifecycle });
};
const onDOMContentLoaded = (details: NavigationEvent) => {
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
return;
lastLifecycle = "DOMContentLoaded";
if (waitUntil === "domcontentloaded") finish({ reached: "match", lastLifecycle });
};
const onCompleted = (details: NavigationEvent) => {
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
return;
finish({ reached: "match", lastLifecycle: "load" });
};
const onError = (details: NavigationEvent) => {
if (!isMainFrame(details)) return;
finish({ reached: "failed", error: cdpError(details.error ?? "browser navigation failed") });
};
const onAbort = () => finish({ reached: "cancelled", lastLifecycle });
const subscriptions = [
[api.onCommitted, onCommitted],
[api.onDOMContentLoaded, onDOMContentLoaded],
[api.onCompleted, onCompleted],
[api.onErrorOccurred, onError],
] as const;
for (const [event, listener] of subscriptions) event.addListener(listener);
signal?.addEventListener("abort", onAbort, { once: true });
const timer = setTimeout(() => finish({ reached: "timeout", lastLifecycle }), timeoutMs);
try {
if (signal?.aborted) return { reached: "cancelled" };
await action();
return await outcome;
} catch (error) {
return { reached: "failed", error: cdpError(error) };
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
for (const [event, listener] of subscriptions) event.removeListener(listener);
}
}
+7 -2
View File
@@ -36,6 +36,7 @@ import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console"; import { handleConsole } from "./console";
import { handleDownload } from "./download"; import { handleDownload } from "./download";
import { type EmulateCdpRunner, handleEmulate } from "./emulate"; import { type EmulateCdpRunner, handleEmulate } from "./emulate";
import { classifyCdpError } from "./errors";
import { handleEvaluate } from "./evaluate"; import { handleEvaluate } from "./evaluate";
import { handleRequestHelp } from "./human-loop"; import { handleRequestHelp } from "./human-loop";
import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction"; import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction";
@@ -239,7 +240,7 @@ export class ToolDispatcher {
if (sessionId) this.onBrowserControlResumed?.(sessionId); if (sessionId) this.onBrowserControlResumed?.(sessionId);
const result = await this.invoke(req, ac.signal); const result = await this.invoke(req, ac.signal);
if (isRpcError(result)) { if (isRpcError(result)) {
body = { id: req.id, error: result }; body = { id: req.id, error: classifyCdpError(result) };
} else { } else {
body = { id: req.id, result }; body = { id: req.id, result };
if (req.method === "tool.session_start") { if (req.method === "tool.session_start") {
@@ -345,7 +346,11 @@ export class ToolDispatcher {
return result; return result;
} }
case "tool.tab_return": case "tool.tab_return":
return handleTabReturn(this.sessions, req.params as TabReturnParams, { signal }); return handleTabReturn(this.sessions, req.params as TabReturnParams, {
signal,
cdp: this.cdp,
beforeReturn: (sessionId, tabId) => this.releaseHoverLatch(sessionId, tabId),
});
case "tool.window_resize": case "tool.window_resize":
return handleWindowResize( return handleWindowResize(
this.sessions, this.sessions,
+28
View File
@@ -13,6 +13,34 @@ import type {
export type { RpcErrorData, RpcErrorReason }; export type { RpcErrorData, RpcErrorReason };
/** Chrome checks the whole tab's frame tree, including other extensions' frames. */
export function isCdpExtensionAccessDenied(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Cannot access a chrome-extension:// URL of different extension");
}
/** Preserve more specific reasons, especially file-transfer outcome/cleanup details. */
export function classifyCdpError(error: RpcError): RpcError {
if (
error.code !== "cdp_failed" ||
error.data?.reason ||
!isCdpExtensionAccessDenied(error.message)
) {
return error;
}
return {
...error,
data: { ...error.data, reason: "cdp_extension_access_denied" },
};
}
export function cdpError(error: unknown): RpcError {
return classifyCdpError({
code: "cdp_failed",
message: error instanceof Error ? error.message : String(error),
});
}
export interface TransferErrorOptions { export interface TransferErrorOptions {
effectState: TransferEffectState; effectState: TransferEffectState;
phase: string; phase: string;
+282 -42
View File
@@ -183,6 +183,8 @@ export async function resolveBackendNode(
} }
// selector path // selector path
try { try {
// Selector lookup itself attaches CDP, even when no element is found.
cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const doc = await cdp.send<{ root?: { nodeId?: number } }>(target.tabId, "DOM.getDocument", { const doc = await cdp.send<{ root?: { nodeId?: number } }>(target.tabId, "DOM.getDocument", {
depth: 0, depth: 0,
}); });
@@ -607,6 +609,53 @@ type SelectMutationResult =
// tool.fill // tool.fill
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type FillFailureReason =
| "target_not_fillable"
| "fill_value_invalid"
| "fill_target_changed"
| "fill_focus_lost"
| "fill_value_mismatch"
| "fill_failed";
type FillPreparation =
| { before: string; expected: string }
| { reason: FillFailureReason; error: string };
type FillReadiness = "ready" | "background" | "fill_target_changed" | "fill_focus_lost";
function fillError(reason: FillFailureReason, message: string): RpcError {
return rpcError(
reason === "target_not_fillable" || reason === "fill_value_invalid"
? "invalid_params"
: "cdp_failed",
reason,
message,
);
}
interface FillScriptReply<T> {
result?: { value?: T };
exceptionDetails?: unknown;
}
const FILL_EDITABLE_FUNCTION = `function() {
const tag = this.tagName.toLowerCase();
const supported = tag === 'input'
? ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(this.type)
: tag === 'textarea' || this.isContentEditable;
return this.isConnected && supported && !this.readOnly && !this.matches(':disabled');
}`;
// Chrome renders a trailing editable newline with an empty <div><br></div>.
// innerText includes the padding break; it is not an extra typed character.
const FILL_VALUE_FUNCTION = `function() {
if (!this.isContentEditable) return this.value;
const value = this.innerText;
const tail = this.lastChild;
const padding = tail && tail.nodeName === 'DIV' && tail.childNodes.length === 1 && tail.firstChild.nodeName === 'BR';
return padding && value.endsWith('\\n\\n') ? value.slice(0, -1) : value;
}`;
export async function handleFill( export async function handleFill(
manager: SessionManager, manager: SessionManager,
params: FillParams, params: FillParams,
@@ -658,12 +707,8 @@ export async function handleFill(
if (throwIfAborted(deps.signal)) { if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" }; return { code: "cancelled", message: "fill aborted" };
} }
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (err) { } catch (err) {
return { return fillError("fill_failed", err instanceof Error ? err.message : String(err));
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
} }
if (throwIfAborted(deps.signal)) { if (throwIfAborted(deps.signal)) {
@@ -676,57 +721,252 @@ export async function handleFill(
const clearBefore = params.clear_before ?? true; const clearBefore = params.clear_before ?? true;
try { try {
if (clearBefore) { if (throwIfAborted(deps.signal)) {
// Clear input/textarea value or wipe contenteditable innerText, return { code: "cancelled", message: "fill aborted" };
// then fire `input` so frameworks observe the empty state. }
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", { try {
objectId, await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
functionDeclaration: `function() { } catch (error) {
if (this.isContentEditable) { this.textContent = ''; } // Chrome rejects DOM.focus for disabled controls, including inherited
else { // fieldset state. Classify the live target before treating this as a
const proto = this instanceof HTMLTextAreaElement // browser failure; the normal path needs no extra round trip.
? HTMLTextAreaElement.prototype const editable = await nodeCdp.send<FillScriptReply<boolean>>(
: HTMLInputElement.prototype; target.tabId,
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value'); "Runtime.callFunctionOn",
if (descriptor && descriptor.set) descriptor.set.call(this, ''); { objectId, functionDeclaration: FILL_EDITABLE_FUNCTION, returnByValue: true },
else this.value = ''; );
} if (throwIfAborted(deps.signal)) {
this.dispatchEvent(new Event('input', { bubbles: true })); return { code: "cancelled", message: "fill aborted" };
}`, }
returnByValue: true, if (!editable.exceptionDetails && editable.result?.value === false) {
}); return fillError(
"target_not_fillable",
"fill target is not editable or its input type is unsupported",
);
}
throw error;
} }
if (throwIfAborted(deps.signal)) { if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" }; return { code: "cancelled", message: "fill aborted" };
} }
// Check editability before clearing. Keep the expected result tied to
// this object, including the existing value on the append path.
const prepared = await nodeCdp.send<FillScriptReply<FillPreparation>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function(value, clearBefore) {
const tag = this.tagName.toLowerCase();
const native = tag === 'input' || tag === 'textarea';
if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return { reason: 'target_not_fillable', error: 'fill target is not editable or its input type is unsupported' };
if (this.getRootNode().activeElement !== this) return { reason: 'fill_focus_lost', error: 'fill target does not have focus' };
const before = clearBefore ? '' : (${FILL_VALUE_FUNCTION}).call(this);
let expected = before + value;
if (native) {
// Use the browser's own value sanitization (e.g. textarea
// line endings) without changing the live control.
const normalizer = this.ownerDocument.createElement(tag);
if (tag === 'input') {
normalizer.type = this.type;
normalizer.multiple = this.multiple;
}
// insertText treats line breaks in a single-line input as spaces.
if (tag === 'input') expected = expected.replace(/\\r\\n|\\r|\\n/g, ' ');
normalizer.value = expected;
if (expected !== '' && normalizer.value === '' && this.type === 'number') {
return { reason: 'fill_value_invalid', error: 'fill value is not valid for a number input' };
}
expected = normalizer.value;
if (this.type !== 'number' && this.maxLength >= 0 && expected.length > this.maxLength) {
return { reason: 'fill_value_invalid', error: 'fill value exceeds the target maxlength' };
}
} else {
expected = expected.replace(/\\r\\n?/g, '\\n');
}
if (clearBefore) {
if (native) {
const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value').set.call(this, '');
} else {
this.textContent = '';
}
this.dispatchEvent(new Event('input', { bubbles: true }));
}
return { before, expected };
}`,
arguments: [{ value: params.value }, { value: clearBefore }],
returnByValue: true,
},
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (prepared.exceptionDetails) {
return fillError("fill_failed", "fill preparation script failed");
}
const preparation = prepared.result?.value;
if (preparation && "error" in preparation) {
return fillError(preparation.reason, preparation.error);
}
if (typeof preparation?.before !== "string" || typeof preparation?.expected !== "string") {
return fillError("fill_failed", "fill preparation returned an unexpected result");
}
// A separate call runs after the clearing event's microtasks drain.
// DOM focus works in background tabs. Use document.hasFocus() only to
// detect deferred focus events, never to reject background input.
const checkReady = async (): Promise<FillReadiness | RpcError> => {
const reply = await nodeCdp.send<FillScriptReply<FillReadiness>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function(before) {
if (!(${FILL_EDITABLE_FUNCTION}).call(this) ||
(${FILL_VALUE_FUNCTION}).call(this) !== before) return 'fill_target_changed';
if (this.getRootNode().activeElement !== this) return 'fill_focus_lost';
return this.ownerDocument.hasFocus() ? 'ready' : 'background';
}`,
arguments: [{ value: preparation.before }],
returnByValue: true,
},
);
const state = reply.result?.value;
if (
reply.exceptionDetails ||
(state !== "ready" &&
state !== "background" &&
state !== "fill_target_changed" &&
state !== "fill_focus_lost")
) {
return fillError(
"fill_failed",
"fill readiness script failed or returned an unexpected result",
);
}
return state;
};
let ready = await checkReady();
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Restore focus once only if clearing left this same target editable
// and unchanged. Recheck after focus handlers have run.
if (clearBefore && ready === "fill_focus_lost") {
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
ready = await checkReady();
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (typeof ready !== "string") return ready;
if (ready !== "ready" && ready !== "background") {
return fillError(ready, "fill target changed or lost focus before typing");
}
if (params.value !== "" && (!clearBefore || ready === "background")) {
// Native editing commands also support number/email inputs, whose
// selection APIs cannot set a caret, and multiline contenteditables.
// Also do this for background replacement: CDP input focuses the
// renderer, delivering deferred focus events before it inserts text.
// Use a command-only event so this does not invoke an End shortcut.
try {
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
type: "rawKeyDown",
commands: ["moveToEndOfDocument"],
});
} finally {
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" });
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Focus and key handlers can change the target or move focus too.
ready = await checkReady();
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (typeof ready !== "string") return ready;
if (ready !== "ready" && ready !== "background") {
return fillError(ready, "fill target changed or lost focus while positioning the caret");
}
}
// CDP `Input.insertText` handles IME / multi-byte input out of the // CDP `Input.insertText` handles IME / multi-byte input out of the
// box, much more reliably than per-key `dispatchKeyEvent`. // box, much more reliably than per-key `dispatchKeyEvent`.
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value }); if (params.value !== "") {
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
}
if (throwIfAborted(deps.signal)) { if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" }; return { code: "cancelled", message: "fill aborted" };
} }
// Fire `input` + `change` so React / Vue controlled inputs commit. // Keep the existing notifications, then read in a separate call so
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", { // nested microtasks queued by the page cannot race the verification.
const notified = await nodeCdp.send<FillScriptReply<unknown>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function() {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}`,
returnByValue: true,
},
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (notified.exceptionDetails) {
return fillError("fill_failed", "fill notification script failed");
}
const verified = await nodeCdp.send<
FillScriptReply<{ connected: boolean; matches: boolean; valueLength: number }>
>(target.tabId, "Runtime.callFunctionOn", {
objectId, objectId,
functionDeclaration: `function() { functionDeclaration: `function(expected) {
this.dispatchEvent(new Event('input', { bubbles: true })); const value = (${FILL_VALUE_FUNCTION}).call(this);
this.dispatchEvent(new Event('change', { bubbles: true })); return { connected: this.isConnected, matches: value === expected, valueLength: value.length };
}`, }`,
arguments: [{ value: preparation.expected }],
returnByValue: true, returnByValue: true,
}); });
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
const verification = verified.result?.value;
if (
verified.exceptionDetails ||
typeof verification?.connected !== "boolean" ||
typeof verification?.matches !== "boolean" ||
typeof verification?.valueLength !== "number"
) {
return fillError(
"fill_failed",
"fill verification script failed or returned an unexpected result",
);
}
if (!verification.connected) {
return fillError("fill_target_changed", "fill target was removed or replaced during input");
}
if (!verification.matches) {
return fillError(
"fill_value_mismatch",
"fill could not verify the expected value; observe the page before deciding whether to retry",
);
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
value_length: verification.valueLength,
});
} catch (err) { } catch (err) {
return { return fillError("fill_failed", err instanceof Error ? err.message : String(err));
code: "cdp_failed", } finally {
message: err instanceof Error ? err.message : String(err), await nodeCdp.send(target.tabId, "Runtime.releaseObject", { objectId }).catch(() => undefined);
};
} }
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
value_length: params.value.length,
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+116 -10
View File
@@ -28,7 +28,13 @@ import type {
RpcError, RpcError,
WaitUntil, WaitUntil,
} from "@/transport/types"; } from "@/transport/types";
import {
type BrowserNavigationApi,
chromeBrowserNavigationApi,
navigateWithBrowserApi,
} from "./browser-navigation";
import { attachDialogs, markDialogCursor } from "./dialogs"; import { attachDialogs, markDialogCursor } from "./dialogs";
import { cdpError, isCdpExtensionAccessDenied } from "./errors";
import { import {
type CdpRunner, type CdpRunner,
type ChromeTabsApi, type ChromeTabsApi,
@@ -42,6 +48,7 @@ import {
export interface NavigationDeps { export interface NavigationDeps {
cdp: CdpRunner; cdp: CdpRunner;
tabsApi: ChromeTabsApi; tabsApi: ChromeTabsApi;
browserNavigation?: BrowserNavigationApi;
/** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */ /** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */
signal?: AbortSignal; signal?: AbortSignal;
/** Override default timeout when the caller omits `timeout_ms`. */ /** Override default timeout when the caller omits `timeout_ms`. */
@@ -479,6 +486,81 @@ async function readTabUrl(api: ChromeTabsApi, tabId: number): Promise<string | u
} }
} }
/** Recover only a failed preflight; never replay an already-dispatched navigation. */
async function recoverBrowserNavigation(
deps: NavigationDeps,
tabId: number,
action: (api: BrowserNavigationApi) => Promise<unknown>,
waitUntil: WaitUntil,
timeoutMs: number,
): Promise<ReloadResult | RpcError> {
const abort = linkedAbortSignal(deps.signal);
const deadline = Date.now() + timeoutMs;
const api = deps.browserNavigation ?? chromeBrowserNavigationApi;
try {
let outcome = await navigateWithBrowserApi(
api,
tabId,
() => action(api),
waitUntil === "networkidle" ? "commit" : waitUntil,
timeoutMs,
abort.signal,
);
if (outcome.reached === "failed") return outcome.error;
if (outcome.reached === "cancelled" || abort.signal.aborted) {
return { code: "cancelled", message: "navigation recovery aborted" };
}
if (outcome.reached === "match") {
// A completed reload is not a recovery if the restricted frame remains.
await deps.cdp.send(tabId, "Page.enable", {});
if (abort.signal.aborted)
return { code: "cancelled", message: "navigation recovery aborted" };
if (waitUntil === "networkidle") {
// webNavigation cannot prove network idle. Subscribe on the new
// document before enabling CDP lifecycle events after its commit.
const frame = await readMainFrameInfo(deps.cdp, tabId);
if (!frame.frameId) return cdpError("no main frame after navigation");
const remaining = deadline - Date.now();
if (remaining <= 0) {
outcome = { reached: "timeout", lastLifecycle: "commit" };
} else {
const wait = startLifecycleWait(
deps.cdp,
tabId,
frame.frameId,
"networkIdle",
remaining,
abort.signal,
{ loaderId: frame.loaderId ?? undefined },
);
await deps.cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true });
outcome = await wait.promise;
}
}
}
if (outcome.reached === "cancelled" || abort.signal.aborted) {
return { code: "cancelled", message: "navigation recovery aborted" };
}
return {
tab_id: tabId,
final_url: await readTabUrl(deps.tabsApi, tabId),
reached: outcome.reached === "match" ? waitUntil : "timeout",
...(outcome.reached === "timeout"
? {
error_text: `timed out waiting for ${waitUntil} during navigation recovery after ${timeoutMs}ms`,
}
: {}),
};
} catch (error) {
return abort.signal.aborted
? { code: "cancelled", message: "navigation recovery aborted" }
: cdpError(error);
} finally {
abort.abort();
abort.cleanup();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tool.navigate // tool.navigate
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -505,7 +587,20 @@ export async function handleNavigate(
try { try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId); try {
await ensureCdpReady(deps.cdp, target.tabId);
} catch (error) {
if (!isCdpExtensionAccessDenied(error)) throw error;
const recovered = await recoverBrowserNavigation(
deps,
target.tabId,
(api) => api.update(target.tabId, { url: params.url }),
waitUntil,
timeoutMs,
);
if (isRpcError(recovered)) return recovered;
return attachDialogs(deps.cdp, target.tabId, dialogCursor, { ...recovered, url: params.url });
}
const expected = cdpLifecycleName(waitUntil); const expected = cdpLifecycleName(waitUntil);
const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId); const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId); const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
@@ -574,10 +669,7 @@ export async function handleNavigate(
}`, }`,
}); });
} catch (err) { } catch (err) {
return { return cdpError(err);
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
} }
} }
@@ -727,7 +819,24 @@ export async function handleReload(
try { try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId); try {
await ensureCdpReady(deps.cdp, target.tabId);
} catch (error) {
if (!isCdpExtensionAccessDenied(error)) throw error;
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const recovered = await recoverBrowserNavigation(
deps,
target.tabId,
(api) => api.reload(target.tabId, { bypassCache: ignoreCache }),
waitUntil,
timeoutMs,
);
if (isRpcError(recovered)) return recovered;
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
...recovered,
previous_url: previousUrl,
});
}
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId); const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId); const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
const expected = cdpLifecycleName(waitUntil); const expected = cdpLifecycleName(waitUntil);
@@ -776,10 +885,7 @@ export async function handleReload(
}`, }`,
}); });
} catch (err) { } catch (err) {
return { return cdpError(err);
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
} }
} }
+3 -2
View File
@@ -2,7 +2,7 @@ import { type SessionManager, SessionStartCleanupError } from "@/session-manager
import type { RpcError } from "@/transport/types"; import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors"; import { rpcError } from "./errors";
import { clearRecordingForSession } from "./record"; import { clearRecordingForSession } from "./record";
import type { ChromeTabsApi } from "./shared"; import type { CdpRunner, ChromeTabsApi } from "./shared";
import { isRpcError } from "./shared"; import { isRpcError } from "./shared";
import { returnBorrowedTab, type TabManagementDeps } from "./tabs"; import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
@@ -84,7 +84,7 @@ export interface SessionStopResult {
export interface SessionStopDeps { export interface SessionStopDeps {
signal?: AbortSignal; signal?: AbortSignal;
cdp?: { cdp?: Pick<CdpRunner, "releaseSessionTab"> & {
detachSession(sessionId: string): Promise<void>; detachSession(sessionId: string): Promise<void>;
}; };
/** /**
@@ -209,6 +209,7 @@ export async function handleSessionStop(
try { try {
const tabManagement = { const tabManagement = {
...(deps.tabManagement ?? {}), ...(deps.tabManagement ?? {}),
cdp: deps.cdp ?? deps.tabManagement?.cdp,
signal: deps.signal, signal: deps.signal,
isAgentWindowId: isAgentWindowId:
deps.tabManagement?.isAgentWindowId ?? deps.tabManagement?.isAgentWindowId ??
+1
View File
@@ -57,6 +57,7 @@ export interface CdpRunner {
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>; getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>; ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
trackSessionTab?(sessionId: string, tabId: number): void; trackSessionTab?(sessionId: string, tabId: number): void;
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): { onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
dispose(): void; dispose(): void;
}; };
+16 -5
View File
@@ -10,7 +10,7 @@ import {
import type { SessionContext, SessionManager } from "@/session-manager/manager"; import type { SessionContext, SessionManager } from "@/session-manager/manager";
import type { RpcError } from "@/transport/types"; import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors"; import { rpcError } from "./errors";
import { isRpcError, lookupSession } from "./shared"; import { type CdpRunner, isRpcError, lookupSession } from "./shared";
export type TabScope = "user" | "agent" | "all"; export type TabScope = "user" | "agent" | "all";
@@ -287,6 +287,10 @@ export interface TabManagementDeps {
approveBorrow?: BorrowConfirmationApprover; approveBorrow?: BorrowConfirmationApprover;
/** Clears Agent-scoped overlays after a borrowed tab is returned. */ /** Clears Agent-scoped overlays after a borrowed tab is returned. */
agentOverlayReset?: AgentOverlayResetApi; agentOverlayReset?: AgentOverlayResetApi;
/** Releases this session's CDP claim after a borrowed tab is returned. */
cdp?: Pick<CdpRunner, "releaseSessionTab">;
/** Runs after tab_return validation, before moving the borrowed tab. */
beforeReturn?: (sessionId: string, tabId: number) => Promise<void>;
/** /**
* Reports whether `windowId` is any live session's Agent Window. * Reports whether `windowId` is any live session's Agent Window.
* `tab_return`'s fallback window picker uses this to avoid moving a * `tab_return`'s fallback window picker uses this to avoid moving a
@@ -945,16 +949,22 @@ async function cleanupUnusedFallbackWindow(
} }
} }
function resetAgentOverlaysInReturnedTab( async function releaseReturnedTabState(
ctx: SessionContext, ctx: SessionContext,
tabId: number, tabId: number,
deps: TabManagementDeps, deps: TabManagementDeps,
): void { ): Promise<void> {
void getAgentOverlayResetApi(deps) void getAgentOverlayResetApi(deps)
.resetAgentOverlays(tabId, ctx.sessionId) .resetAgentOverlays(tabId, ctx.sessionId)
.catch((err) => { .catch((err) => {
console.debug("[bsk tab_return] agent overlay reset failed", err); console.debug("[bsk tab_return] agent overlay reset failed", err);
}); });
try {
await deps.cdp?.releaseSessionTab?.(ctx.sessionId, tabId);
} catch (err) {
// A successful move must not be retried because debugger cleanup failed.
console.debug("[bsk tab_return] CDP release failed", err);
}
} }
/** /**
@@ -1028,7 +1038,7 @@ export async function returnBorrowedTab(
}); });
const movedTab = Array.isArray(moved) ? moved[0] : moved; const movedTab = Array.isArray(moved) ? moved[0] : moved;
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : targetIndex; const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : targetIndex;
resetAgentOverlaysInReturnedTab(ctx, tabId, deps); await releaseReturnedTabState(ctx, tabId, deps);
return { return {
tabId, tabId,
toWindowId: targetWindowId, toWindowId: targetWindowId,
@@ -1070,7 +1080,7 @@ export async function returnBorrowedTab(
}); });
const movedTab = Array.isArray(moved) ? moved[0] : moved; const movedTab = Array.isArray(moved) ? moved[0] : moved;
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : target.index; const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : target.index;
resetAgentOverlaysInReturnedTab(ctx, tabId, deps); await releaseReturnedTabState(ctx, tabId, deps);
return { return {
tabId, tabId,
toWindowId: target.windowId, toWindowId: target.windowId,
@@ -1116,6 +1126,7 @@ export async function handleTabReturn(
message: `tab_return: tab ${params.tab_id} is not borrowed by this session`, message: `tab_return: tab ${params.tab_id} is not borrowed by this session`,
}; };
} }
await deps.beforeReturn?.(ctx.sessionId, params.tab_id);
const outcome = await returnBorrowedTab(ctx, params.tab_id, { const outcome = await returnBorrowedTab(ctx, params.tab_id, {
...deps, ...deps,
isAgentWindowId: isAgentWindowId:
@@ -215,21 +215,29 @@ function makeCdp(snapshot: unknown) {
if (method === "Runtime.evaluate") { if (method === "Runtime.evaluate") {
return { return {
result: { result: {
value: { deepSerializedValue: {
controls: [ type: "array",
value: [
{ {
state: "filled", type: "array",
sensitive: true, value: [
defaultValue: "hunter2", { type: "node", value: { backendNodeId: 13 } },
value: "hunter2", {
placeholder: "secret", type: "string",
value: JSON.stringify({
state: "filled",
sensitive: true,
placeholder: "secret",
}),
},
],
}, },
], ],
childFrames: [],
}, },
}, },
}; };
} }
if (method === "Runtime.releaseObjectGroup") return {};
throw new Error(`unexpected ${method}`); throw new Error(`unexpected ${method}`);
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>, }) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
}; };
@@ -273,8 +281,11 @@ describe("captureViewModel", () => {
expect(cdp.send).toHaveBeenCalledWith( expect(cdp.send).toHaveBeenCalledWith(
4, 4,
"Runtime.evaluate", "Runtime.evaluate",
expect.objectContaining({ returnByValue: true }), expect.objectContaining({
serializationOptions: expect.objectContaining({ serialization: "deep" }),
}),
); );
expect(cdp.send).toHaveBeenCalledWith(4, "Runtime.releaseObjectGroup", expect.anything());
expect(cdp.send).not.toHaveBeenCalledWith(4, "DOM.resolveNode", expect.anything()); expect(cdp.send).not.toHaveBeenCalledWith(4, "DOM.resolveNode", expect.anything());
expect(cdp.send).not.toHaveBeenCalledWith(4, "Runtime.callFunctionOn", expect.anything()); expect(cdp.send).not.toHaveBeenCalledWith(4, "Runtime.callFunctionOn", expect.anything());
}); });
@@ -0,0 +1,270 @@
import { describe, expect, it, vi } from "vitest";
import { type CdpRunner, cdpRunnerForTarget } from "../../shared";
import { captureViewModel } from "../capture";
interface Control {
id: number;
value: string;
type?: string;
}
function snapshot(groups: Control[][]) {
const strings: string[] = [];
const str = (value: string) => {
let index = strings.indexOf(value);
if (index < 0) index = strings.push(value) - 1;
return index;
};
const documents = groups.map((controls, groupIndex) => ({
frameId: `frame-${groupIndex}`,
nodes: {
parentIndex: [-1, 0, ...controls.map(() => 1)],
nodeName: [str("html"), str("body"), ...controls.map(() => str("input"))],
backendNodeId: [8000 + groupIndex * 2, 8001 + groupIndex * 2, ...controls.map((c) => c.id)],
attributes: [
[],
[],
...controls.map((c) => [
str("type"),
str(c.type ?? "text"),
str("placeholder"),
str("snapshot placeholder"),
]),
],
inputValue: {
index: controls.map((_, i) => i + 2),
value: controls.map((c) => str(c.value)),
},
contentDocumentIndex: { index: [] as number[], value: [] as number[] },
},
}));
const root = documents[0].nodes;
for (let i = 1; i < documents.length; i++) {
root.contentDocumentIndex.index.push(root.backendNodeId.length);
root.contentDocumentIndex.value.push(i);
root.parentIndex.push(1);
root.nodeName.push(str("iframe"));
root.backendNodeId.push(9000 + i);
root.attributes.push([]);
}
return { strings, documents };
}
function state(value: string) {
return {
value,
defaultValue: "",
placeholder: "live placeholder",
state: value ? "filled" : "empty",
sensitive: false,
};
}
function reply(entries: Array<[number, unknown]>) {
return {
result: {
deepSerializedValue: {
type: "array",
value: entries.map(([backendNodeId, fields]) => ({
type: "array",
value: [
{ type: "node", value: { backendNodeId } },
{ type: "string", value: JSON.stringify(fields) },
],
})),
},
},
};
}
function fakeCdp(groups: Control[][], evaluate: (params: Record<string, unknown>) => unknown) {
const snap = snapshot(groups);
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
if (method === "DOMSnapshot.captureSnapshot") return snap;
if (method === "Runtime.evaluate") return evaluate(params as Record<string, unknown>);
return {};
});
return { send, cdp: { send: send as CdpRunner["send"] } };
}
describe("form state identity", () => {
it("matches reordered controls by identity and preserves controls missing from the batch", async () => {
// The snapshot includes a shadow input. The runtime query skips it,
// encounters a new input, and sees the remaining inputs in a new order.
const { cdp } = fakeCdp(
[
[
{ id: 10, value: "" },
{ id: 11, value: "old email" },
{ id: 12, value: "old city" },
],
],
() =>
reply([
[99, state("new control")],
[12, state("上海")],
[11, state("alice@example.com")],
]),
);
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 10)).toMatchObject({
formValue: "",
formState: "empty",
formPlaceholder: "snapshot placeholder",
});
expect(captured.nodes.find((n) => n.backendNodeId === 11)).toMatchObject({
formValue: "alice@example.com",
});
expect(captured.nodes.find((n) => n.backendNodeId === 12)).toMatchObject({ formValue: "上海" });
expect(captured.nodes.some((n) => n.backendNodeId === 99)).toBe(false);
});
it("does not shift state between frames when one frame is absent from the runtime batch", async () => {
const { cdp } = fakeCdp(
[
[],
[{ id: 11, value: "inaccessible frame" }],
[{ id: 21, value: "old second" }],
[{ id: 31, value: "old third" }],
],
() =>
reply([
[31, state("third frame")],
[21, state("second frame")],
]),
);
const captured = await captureViewModel(cdp, 7);
const controls = [...captured.iframeNodes.values()].flat();
expect(controls.find((n) => n.backendNodeId === 11)?.formValue).toBe("inaccessible frame");
expect(controls.find((n) => n.backendNodeId === 21)?.formValue).toBe("second frame");
expect(controls.find((n) => n.backendNodeId === 31)?.formValue).toBe("third frame");
});
it("keeps identical backend ids in different OOPIF targets separate", async () => {
const sendToTarget = vi.fn(async (target, method) => {
if (method === "DOMSnapshot.captureSnapshot") return snapshot([[{ id: 11, value: "old" }]]);
if (method === "Runtime.evaluate") return reply([[11, state(target.sessionId)]]);
return {};
});
const send = vi.fn(async () => {
throw new Error("unexpected top-level target");
});
const cdp = { send, sendToTarget } as unknown as CdpRunner;
for (const sessionId of ["left-frame", "right-frame"]) {
const captured = await captureViewModel(cdpRunnerForTarget(cdp, { tabId: 7, sessionId }), 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)?.formValue).toBe(sessionId);
expect(sendToTarget).toHaveBeenCalledWith(
{ tabId: 7, sessionId },
"Runtime.releaseObjectGroup",
expect.anything(),
);
}
expect(send).not.toHaveBeenCalled();
});
it.each([
"unsupported",
"legacy",
"malformed",
"invalid-state",
])("preserves snapshot data when enrichment is %s", async (mode) => {
const { cdp, send } = fakeCdp([[{ id: 11, value: "snapshot value" }]], () => {
if (mode === "unsupported") throw new Error("serialization unsupported");
if (mode === "legacy") return { result: { value: { controls: [state("wrong value")] } } };
const result = reply([
[11, mode === "invalid-state" ? { value: "wrong value" } : state("wrong value")],
]);
if (mode === "malformed")
result.result.deepSerializedValue.value[0].value[1].value = "not json";
return result;
});
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)).toMatchObject({
formValue: "snapshot value",
formPlaceholder: "snapshot placeholder",
});
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
});
it("releases the batch's remote objects when capture is cancelled", async () => {
const controller = new AbortController();
const { cdp, send } = fakeCdp([[{ id: 11, value: "old" }]], () => {
controller.abort();
return reply([[11, state("new")]]);
});
await expect(captureViewModel(cdp, 7, { signal: controller.signal })).rejects.toMatchObject({
name: "AbortError",
});
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
});
it("skips entries without node identity while still applying valid entries", async () => {
const { cdp } = fakeCdp(
[
[
{ id: 11, value: "snapshot value" },
{ id: 12, value: "old value" },
],
],
() => {
const result = reply([
[11, state("wrong value")],
[12, state("live value")],
]);
const element = result.result.deepSerializedValue.value[0].value[0];
delete (element.value as { backendNodeId?: number }).backendNodeId;
return result;
},
);
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)?.formValue).toBe("snapshot value");
expect(captured.nodes.find((n) => n.backendNodeId === 12)?.formValue).toBe("live value");
});
it.each([
"password",
"text",
])("redacts a sensitive %s control after identity matching", async (type) => {
const { cdp } = fakeCdp([[{ id: 11, value: "secret", type }]], () =>
reply([[11, { ...state("secret"), sensitive: true }]]),
);
const captured = await captureViewModel(cdp, 7);
const control = captured.nodes.find((n) => n.backendNodeId === 11);
expect(control?.formState).toBe("filled");
expect(control?.formValue).toBeUndefined();
expect(control?.formDefaultValue).toBeUndefined();
expect(control?.attrs.value).toBeUndefined();
});
it("keeps one bounded batch without per-control CDP queries", async () => {
const controls = Array.from({ length: 251 }, (_, i) => ({ id: i + 1, value: "old" }));
const elements = controls.map((c) => ({
backendNodeId: c.id,
tagName: "INPUT",
type: "text",
value: `live ${c.id}`,
defaultValue: "",
placeholder: "",
}));
const { cdp, send } = fakeCdp([controls], (params) => {
const doc = {
querySelectorAll: (selector: string) =>
selector === "input,textarea,select" ? elements : [],
};
const entries = new Function("document", `return ${params.expression}`)(doc) as Array<
[{ backendNodeId: number }, string]
>;
expect(entries).toHaveLength(250);
return reply(entries.map(([element, json]) => [element.backendNodeId, JSON.parse(json)]));
});
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 250)?.formValue).toBe("live 250");
expect(captured.nodes.find((n) => n.backendNodeId === 251)?.formValue).toBe("old");
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
expect(
send.mock.calls.filter(([, method]) => method === "Runtime.releaseObjectGroup"),
).toHaveLength(1);
expect(send).not.toHaveBeenCalledWith(7, "DOM.resolveNode", expect.anything());
expect(send).not.toHaveBeenCalledWith(7, "Runtime.callFunctionOn", expect.anything());
});
});
+57 -26
View File
@@ -190,9 +190,9 @@ interface CapturedFormState {
sensitive?: boolean; sensitive?: boolean;
} }
interface CapturedFrameFormState { interface DeepSerializedValue {
controls: CapturedFormState[]; type: string;
childFrames: CapturedFrameFormState[]; value?: unknown;
} }
function formStateBatchExpression(maxControls: number): string { function formStateBatchExpression(maxControls: number): string {
@@ -215,43 +215,63 @@ function formStateBatchExpression(maxControls: number): string {
...(sensitive ? {} : { value: rawValue, defaultValue }), ...(sensitive ? {} : { value: rawValue, defaultValue }),
}; };
}; };
const controls = [];
const collect = (doc) => { const collect = (doc) => {
const controls = [];
for (const el of Array.from(doc.querySelectorAll(controlSelector))) { for (const el of Array.from(doc.querySelectorAll(controlSelector))) {
if (remaining <= 0) break; if (remaining <= 0) break;
controls.push(controlState(el)); // Deep serialization supplies the node's backend id. Keep the
// state as JSON so decoding needs no general-purpose V8 deserializer.
controls.push([el, JSON.stringify(controlState(el))]);
remaining -= 1; remaining -= 1;
} }
const childFrames = [];
for (const frame of Array.from(doc.querySelectorAll("iframe"))) { for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
if (remaining <= 0) break;
let childDoc = null; let childDoc = null;
try { childDoc = frame.contentDocument; } catch { childDoc = null; } try { childDoc = frame.contentDocument; } catch { childDoc = null; }
childFrames.push(childDoc && remaining > 0 ? collect(childDoc) : { controls: [], childFrames: [] }); if (childDoc) collect(childDoc);
} }
return { controls, childFrames };
}; };
return collect(document); collect(document);
return controls;
})()`; })()`;
} }
function flattenFrameFormStates( function formStatesByBackendId(result: RuntimeEvaluateReply): Map<number, CapturedFormState> {
frame: CapturedFrameFormState | undefined, const states = new Map<number, CapturedFormState>();
out: CapturedFormState[][] = [], const serialized = result.result?.deepSerializedValue;
): CapturedFormState[][] { if (serialized?.type !== "array" || !Array.isArray(serialized.value)) return states;
if (!frame) return out; for (const entry of serialized.value as DeepSerializedValue[]) {
out.push(Array.isArray(frame.controls) ? frame.controls : []); if (entry?.type !== "array" || !Array.isArray(entry.value)) continue;
for (const child of Array.isArray(frame.childFrames) ? frame.childFrames : []) { const [element, json] = entry.value as DeepSerializedValue[];
flattenFrameFormStates(child, out); if (element?.type !== "node" || json?.type !== "string" || typeof json.value !== "string") {
continue;
}
const backendNodeId = (element.value as { backendNodeId?: number } | undefined)?.backendNodeId;
if (typeof backendNodeId !== "number" || !Number.isSafeInteger(backendNodeId)) continue;
try {
const state = JSON.parse(json.value) as CapturedFormState | null;
if (
!state ||
!["empty", "filled", "default"].includes(state.state ?? "") ||
typeof state.sensitive !== "boolean" ||
typeof state.placeholder !== "string" ||
(!state.sensitive &&
(typeof state.value !== "string" || typeof state.defaultValue !== "string"))
) {
continue;
}
states.set(backendNodeId, state);
} catch {
// A malformed entry must not overwrite the snapshot's own state.
}
} }
return out; return states;
} }
function applyFormStates(nodes: CapturedNode[], states: CapturedFormState[]): void { function applyFormStates(nodes: CapturedNode[], states: Map<number, CapturedFormState>): void {
let index = 0;
for (const node of nodes) { for (const node of nodes) {
if (!isFormControlTag(node.tag)) continue; if (!isFormControlTag(node.tag)) continue;
const state = states[index]; const state = states.get(node.backendNodeId);
index += 1;
if (!state) continue; if (!state) continue;
node.formState = state.state; node.formState = state.state;
node.formPlaceholder = state.placeholder ?? ""; node.formPlaceholder = state.placeholder ?? "";
@@ -276,26 +296,37 @@ async function enrichFormControlStates(
nodes.some((node) => isFormControlTag(node.tag)), nodes.some((node) => isFormControlTag(node.tag)),
); );
if (!hasControls) return; if (!hasControls) return;
const objectGroup = `bsk-vom-forms-${crypto.randomUUID()}`;
try { try {
throwIfAborted(signal); throwIfAborted(signal);
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", { const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS), expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
returnByValue: true, objectGroup,
serializationOptions: {
serialization: "deep",
maxDepth: 3,
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
},
}); });
throwIfAborted(signal); throwIfAborted(signal);
const frameStates = flattenFrameFormStates(runtimeValue<CapturedFrameFormState>(result)); // Backend ids are scoped to this capture's CDP target. OOPIF targets
for (let i = 0; i < frameNodeGroups.length; i += 1) { // use their own captureViewModel call and never share this lookup.
applyFormStates(frameNodeGroups[i], frameStates[i] ?? []); const states = formStatesByBackendId(result);
for (const nodes of frameNodeGroups) {
applyFormStates(nodes, states);
} }
} catch (error) { } catch (error) {
if (isAbortError(error)) throw error; if (isAbortError(error)) throw error;
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes. // Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
} finally {
await cdp.send(tabId, "Runtime.releaseObjectGroup", { objectGroup }).catch(() => undefined);
} }
} }
interface RuntimeEvaluateReply { interface RuntimeEvaluateReply {
result?: { result?: {
value?: unknown; value?: unknown;
deepSerializedValue?: DeepSerializedValue;
}; };
} }
+6
View File
@@ -26,11 +26,17 @@ export type RpcErrorReason =
| "ref_not_found" | "ref_not_found"
| "selector_not_found" | "selector_not_found"
| "target_not_fillable" | "target_not_fillable"
| "fill_value_invalid"
| "fill_target_changed"
| "fill_focus_lost"
| "fill_value_mismatch"
| "fill_failed"
| "target_not_select" | "target_not_select"
| "option_not_found" | "option_not_found"
| "single_select_value_count" | "single_select_value_count"
| "tab_not_active" | "tab_not_active"
| "restricted_tab_url" | "restricted_tab_url"
| "cdp_extension_access_denied"
| "borrow_conflict" | "borrow_conflict"
| "screenshot_capture_failed" | "screenshot_capture_failed"
| "file_input_probe_failed" | "file_input_probe_failed"
+4
View File
@@ -172,6 +172,10 @@ intended. Read `bsk upload --help` and `bsk download --help` for all flags and e
- Stale ref: observe again and retry the intended action once. - Stale ref: observe again and retry the intended action once.
- Unknown tab or session: list current tabs/sessions; never guess identifiers. - Unknown tab or session: list current tabs/sessions; never guess identifiers.
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful. - Timeout: inspect current page state before deciding whether one longer purposeful wait is useful.
- Fill result unconfirmed (`fill_value_mismatch`): observe the field first; the page may have
formatted the value. Continue if the visible result satisfies the user's intent. Otherwise correct
the remaining difference; do not blindly repeat fill or immediately request human help. For other
fill errors, follow the returned hint and inspect current state before retrying.
- Unsupported command: continue with available capabilities; suggest updating only when the missing - Unsupported command: continue with available capabilities; suggest updating only when the missing
command is necessary. command is necessary.
- Unrecoverable failure: report the blocker and stop the session in a finally-style path. - Unrecoverable failure: report the blocker and stop the session in a finally-style path.
+111 -1
View File
@@ -43,10 +43,16 @@ pub mod reason {
pub const REF_NOT_FOUND: &str = "ref_not_found"; pub const REF_NOT_FOUND: &str = "ref_not_found";
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found"; pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable"; pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
pub const FILL_VALUE_INVALID: &str = "fill_value_invalid";
pub const FILL_TARGET_CHANGED: &str = "fill_target_changed";
pub const FILL_FOCUS_LOST: &str = "fill_focus_lost";
pub const FILL_VALUE_MISMATCH: &str = "fill_value_mismatch";
pub const FILL_FAILED: &str = "fill_failed";
pub const TARGET_NOT_SELECT: &str = "target_not_select"; pub const TARGET_NOT_SELECT: &str = "target_not_select";
pub const OPTION_NOT_FOUND: &str = "option_not_found"; pub const OPTION_NOT_FOUND: &str = "option_not_found";
pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count"; pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count";
pub const TAB_NOT_ACTIVE: &str = "tab_not_active"; pub const TAB_NOT_ACTIVE: &str = "tab_not_active";
pub const CDP_EXTENSION_ACCESS_DENIED: &str = "cdp_extension_access_denied";
pub const BORROW_CONFLICT: &str = "borrow_conflict"; pub const BORROW_CONFLICT: &str = "borrow_conflict";
pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed"; pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed";
pub const FILE_INPUT_PROBE_FAILED: &str = "file_input_probe_failed"; pub const FILE_INPUT_PROBE_FAILED: &str = "file_input_probe_failed";
@@ -206,6 +212,13 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
return base; return base;
}; };
match (code, reason) { match (code, reason) {
(ErrorCode::CdpFailed, reason::CDP_EXTENSION_ACCESS_DENIED) => RenderInfo {
summary: "Chrome blocked CDP access to another extension's content in this tab",
hint: Some(
"a web page can contain a restricted extension frame; disable the conflicting extension and reload, or use `bsk navigate <url>` to leave this page; reconnecting BrowserSkill alone does not remove the restriction",
),
exit_code: base.exit_code,
},
(_, reason::TRANSFER_OUTCOME_UNKNOWN) => RenderInfo { (_, reason::TRANSFER_OUTCOME_UNKNOWN) => RenderInfo {
summary: "the file transfer outcome could not be confirmed", summary: "the file transfer outcome could not be confirmed",
hint: Some( hint: Some(
@@ -247,7 +260,42 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
(ErrorCode::InvalidParams, reason::TARGET_NOT_FILLABLE) => RenderInfo { (ErrorCode::InvalidParams, reason::TARGET_NOT_FILLABLE) => RenderInfo {
summary: "target element is not fillable", summary: "target element is not fillable",
hint: Some( hint: Some(
"choose an input, textarea, or contenteditable element from the latest snapshot", "observe the page and choose an enabled, editable text input, textarea, or contenteditable; use the appropriate interaction for other control types",
),
exit_code: base.exit_code,
},
(ErrorCode::InvalidParams, reason::FILL_VALUE_INVALID) => RenderInfo {
summary: "the requested text does not fit the field constraints",
hint: Some(
"check the field's input type and maxlength; use a compatible value without silently truncating or changing the user's intended data",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_TARGET_CHANGED) => RenderInfo {
summary: "the fill target changed during the action",
hint: Some(
"observe the current page and field value; if the intended result is missing, use a fresh editable target before retrying",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_FOCUS_LOST) => RenderInfo {
summary: "the page moved focus away from the fill target",
hint: Some(
"observe the page for a dialog or rerender and resolve it before retrying; fill manages field focus and does not require bringing the browser to the foreground",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_VALUE_MISMATCH) => RenderInfo {
summary: "the fill result could not be confirmed",
hint: Some(
"observe the field before retrying; the page may have formatted the value. Continue if the visible result satisfies the user's intent; otherwise correct the remaining difference. Do not blindly repeat fill or immediately request human help",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_FAILED) => RenderInfo {
summary: "fill encountered a browser or page-script error",
hint: Some(
"observe the page before retrying because the field may already have changed; retry only if the intended result is missing and the target is still available",
), ),
exit_code: base.exit_code, exit_code: base.exit_code,
}, },
@@ -442,6 +490,56 @@ mod tests {
ErrorCode::NoBrowserConnected, ErrorCode::NoBrowserConnected,
]; ];
#[test]
fn fill_reasons_provide_recovery_without_changing_exit_codes() {
for (code, reason, hint_fragment) in [
(
ErrorCode::InvalidParams,
reason::TARGET_NOT_FILLABLE,
"enabled, editable",
),
(
ErrorCode::InvalidParams,
reason::FILL_VALUE_INVALID,
"maxlength",
),
(
ErrorCode::CdpFailed,
reason::FILL_TARGET_CHANGED,
"fresh editable target",
),
(
ErrorCode::CdpFailed,
reason::FILL_FOCUS_LOST,
"does not require bringing the browser",
),
(
ErrorCode::CdpFailed,
reason::FILL_VALUE_MISMATCH,
"Continue if the visible result satisfies",
),
(
ErrorCode::CdpFailed,
reason::FILL_FAILED,
"may already have changed",
),
] {
let data = serde_json::json!({ "reason": reason });
let info = info_for_error(code, Some(&data));
assert!(info.hint.unwrap().contains(hint_fragment), "{reason}");
assert_eq!(info.exit_code, info_for(code).exit_code);
}
let mismatch = serde_json::json!({ "reason": reason::FILL_VALUE_MISMATCH });
let info = info_for_error(ErrorCode::CdpFailed, Some(&mismatch));
assert_eq!(info.summary, "the fill result could not be confirmed");
assert!(info.hint.unwrap().contains("Do not blindly repeat fill"));
let unknown = serde_json::json!({ "reason": "future_fill_reason" });
assert_eq!(
info_for_error(ErrorCode::CdpFailed, Some(&unknown)),
info_for(ErrorCode::CdpFailed)
);
}
#[test] #[test]
fn element_not_visible_overrides_permission_denied_copy() { fn element_not_visible_overrides_permission_denied_copy() {
let data = serde_json::json!({ "reason": reason::ELEMENT_NOT_VISIBLE }); let data = serde_json::json!({ "reason": reason::ELEMENT_NOT_VISIBLE });
@@ -454,6 +552,18 @@ mod tests {
assert!(!info.summary.contains("sandbox")); assert!(!info.summary.contains("sandbox"));
} }
#[test]
fn extension_access_denied_explains_the_frame_restriction_and_navigation_recovery() {
let data = serde_json::json!({ "reason": reason::CDP_EXTENSION_ACCESS_DENIED });
let info = info_for_error(ErrorCode::CdpFailed, Some(&data));
assert!(info.summary.contains("another extension"));
let hint = info.hint.unwrap();
assert!(hint.contains("restricted extension frame"));
assert!(hint.contains("bsk navigate <url>"));
assert!(hint.contains("disable the conflicting extension"));
assert_eq!(info.exit_code, 3);
}
#[test] #[test]
fn session_busy_overrides_timeout_copy() { fn session_busy_overrides_timeout_copy() {
let data = serde_json::json!({ "reason": reason::SESSION_BUSY }); let data = serde_json::json!({ "reason": reason::SESSION_BUSY });
+238 -76
View File
@@ -6,9 +6,7 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
#[cfg(windows)] #[cfg(windows)]
use std::os::windows::process::CommandExt; mod windows;
#[cfg(windows)]
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use clap::Args; use clap::Args;
@@ -363,7 +361,8 @@ fn install_candidate_with_client(
crate::daemon::start::run_stop().context("stop bsk daemon before update")?; crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
} }
let action = replace_binary_for_update(&target, &binary, daemon_was_running)?; let restart_args = daemon_was_running.then(StartArgs::default);
let action = replace_binary_for_update(&target, &binary, restart_args.as_ref())?;
if daemon_was_running && matches!(action, InstallAction::Replaced) { if daemon_was_running && matches!(action, InstallAction::Replaced) {
crate::daemon::start::run_start(StartArgs::default()) crate::daemon::start::run_start(StartArgs::default())
@@ -394,8 +393,8 @@ pub(crate) fn download_candidate_binary(
/// Daemon-side install: download, verify, and replace the executable at /// Daemon-side install: download, verify, and replace the executable at
/// `target` (on Windows: stage the replacement next to it). Unlike the /// `target` (on Windows: stage the replacement next to it). Unlike the
/// CLI path this never stops or starts the daemon — the daemon drives /// CLI path this never stops the daemon. On Windows the helper starts
/// its own restart once the binary is replaced. /// its replacement after exit; elsewhere the daemon drives its own restart.
/// ///
/// `target` must be captured *before* any replacement happens: on Linux /// `target` must be captured *before* any replacement happens: on Linux
/// `std::env::current_exe` starts returning a ` (deleted)`-suffixed /// `std::env::current_exe` starts returning a ` (deleted)`-suffixed
@@ -403,10 +402,11 @@ pub(crate) fn download_candidate_binary(
pub(crate) fn self_install_candidate( pub(crate) fn self_install_candidate(
candidate: &UpdateCandidate, candidate: &UpdateCandidate,
target: &Path, target: &Path,
restart_args: &StartArgs,
) -> Result<InstallAction> { ) -> Result<InstallAction> {
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?; let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
let binary = download_candidate_binary(candidate, &client)?; let binary = download_candidate_binary(candidate, &client)?;
replace_binary_at_path(target, &binary) replace_binary_for_update(target, &binary, Some(restart_args))
} }
/// Outcome of one daemon auto-update step (see [`auto_update_step`]). /// Outcome of one daemon auto-update step (see [`auto_update_step`]).
@@ -423,8 +423,8 @@ pub(crate) enum AutoUpdateOutcome {
/// The new binary replaced the old one; the daemon should now /// The new binary replaced the old one; the daemon should now
/// restart into it. /// restart into it.
Replaced { latest: String }, Replaced { latest: String },
/// Windows staged the replacement next to the running binary; a /// Windows started a helper that will replace the executable and
/// daemon/terminal restart finishes it. /// restart the daemon once the current process exits.
Staged { latest: String }, Staged { latest: String },
} }
@@ -668,6 +668,7 @@ fn render_report(format: Format, report: &UpdateReport) -> Result<()> {
} }
if matches!(report.install_action, Some("staged")) { if matches!(report.install_action, Some("staged")) {
println!("the detached update helper will apply the replacement after exit"); println!("the detached update helper will apply the replacement after exit");
println!("if it fails, see the *.update-*.log file next to the executable");
} }
} }
Format::Json => { Format::Json => {
@@ -688,22 +689,22 @@ pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result<Vec
} }
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> { pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
replace_binary_for_update(target, binary, false) replace_binary_for_update(target, binary, None)
} }
fn replace_binary_for_update( fn replace_binary_for_update(
target: &Path, target: &Path,
binary: &[u8], binary: &[u8],
restart_daemon: bool, restart_args: Option<&StartArgs>,
) -> Result<InstallAction> { ) -> Result<InstallAction> {
#[cfg(windows)] #[cfg(windows)]
{ {
stage_windows_replacement(target, binary, restart_daemon) stage_windows_replacement(target, binary, restart_args)
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
let _ = restart_daemon; let _ = restart_args;
replace_binary_atomically(target, binary) replace_binary_atomically(target, binary)
} }
} }
@@ -749,34 +750,69 @@ fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result<InstallActi
fn stage_windows_replacement( fn stage_windows_replacement(
target: &Path, target: &Path,
binary: &[u8], binary: &[u8],
restart_daemon: bool, restart_args: Option<&StartArgs>,
) -> Result<InstallAction> { ) -> Result<InstallAction> {
let paths = staged_replacement_paths(target, std::process::id())?; // The helper owns replacement/restart after this process exits. Do not
std::fs::write(&paths.binary_path, binary) // report Staged until it has actually begun executing the script.
.with_context(|| format!("write {}", paths.binary_path.display()))?; let _child = spawn_windows_replacement(target, binary, restart_args, 120)?;
std::fs::write( Ok(InstallAction::Staged)
&paths.script_path, }
windows_replacement_script(target, &paths.binary_path, restart_daemon),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
const CREATE_NO_WINDOW: u32 = 0x0800_0000; #[cfg(windows)]
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; fn spawn_windows_replacement(
let command = format!("\"{}\"", paths.script_path.display()); target: &Path,
if let Err(err) = Command::new("cmd.exe") binary: &[u8],
.args(["/D", "/S", "/C"]) restart_args: Option<&StartArgs>,
.arg(command) attempts: u32,
.stdin(Stdio::null()) ) -> Result<windows::Helper> {
.stdout(Stdio::null()) let paths = staged_replacement_paths(target, std::process::id())?;
.stderr(Stdio::null()) let ready_path = paths.script_path.with_extension("ready");
.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP) let log_path = paths.script_path.with_extension("log");
.spawn() let result = (|| {
{ // A previous failed attempt by this process must not acknowledge a
// new helper. All paths are private to this target and process id.
match std::fs::remove_file(&ready_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
std::fs::write(&paths.binary_path, binary)
.with_context(|| format!("write {}", paths.binary_path.display()))?;
std::fs::write(
&paths.script_path,
windows_replacement_script(restart_args, attempts),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
let mut child = windows::spawn(
&paths.script_path,
&paths.binary_path,
target,
&ready_path,
&log_path,
)
.context("launch detached Windows update helper")?;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if std::fs::remove_file(&ready_path).is_ok() {
return Ok(child);
}
if let Some(status) = child.try_wait()? {
bail!("Windows update helper exited before becoming ready: {status}");
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
bail!("Windows update helper did not become ready within 5 seconds");
}
std::thread::sleep(Duration::from_millis(20));
}
})();
if result.is_err() {
let _ = std::fs::remove_file(&paths.binary_path); let _ = std::fs::remove_file(&paths.binary_path);
let _ = std::fs::remove_file(&paths.script_path); let _ = std::fs::remove_file(&paths.script_path);
return Err(err).context("launch detached Windows update helper"); let _ = std::fs::remove_file(&ready_path);
} }
Ok(InstallAction::Staged) result.with_context(|| format!("Windows update helper failed; see {}", log_path.display()))
} }
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> { pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
@@ -793,25 +829,43 @@ pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplace
} }
#[cfg(any(windows, test))] #[cfg(any(windows, test))]
fn windows_replacement_script(target: &Path, staged_binary: &Path, restart_daemon: bool) -> String { fn windows_replacement_script(restart_args: Option<&StartArgs>, attempts: u32) -> String {
let target = target.display().to_string().replace('%', "%%"); // Only fixed switches and numeric values go into the ASCII script. Paths
let staged_binary = staged_binary.display().to_string().replace('%', "%%"); // stay in Unicode environment variables, including %, ! and shell symbols.
let restart = if restart_daemon { let restart = restart_args.map_or_else(String::new, |args| {
format!("start \"\" /B \"{target}\" daemon start >nul 2>nul\r\n") format!(
} else { "\"%BSK_UPDATE_TARGET%\" daemon start --port {} --session-idle {}ms --daemon-idle {}ms\r\nif errorlevel 1 goto failed_restart\r\n",
String::new() args.resolved_port(),
}; args.resolved_session_idle().as_millis(),
args.resolved_daemon_idle().as_millis(),
)
});
format!( format!(
"@echo off\r\n\ "@echo off\r\n\
setlocal\r\n\ setlocal DisableDelayedExpansion\r\n\
> \"%BSK_UPDATE_READY%\" echo ready\r\n\
if errorlevel 1 exit /b 1\r\n\
set /a attempts=0\r\n\
:retry\r\n\ :retry\r\n\
move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\ move /Y \"%BSK_UPDATE_SOURCE%\" \"%BSK_UPDATE_TARGET%\" >nul\r\n\
if errorlevel 1 (\r\n\ if not errorlevel 1 goto replaced\r\n\
ping -n 2 127.0.0.1 >nul\r\n\ set /a attempts+=1\r\n\
goto retry\r\n\ if %attempts% geq {attempts} goto failed_replace\r\n\
)\r\n\ if not exist \"%BSK_UPDATE_SOURCE%\" goto failed_replace\r\n\
ping -n 2 127.0.0.1 >nul\r\n\
goto retry\r\n\
:failed_replace\r\n\
echo Failed to replace the executable after %attempts% attempts.\r\n\
goto failed\r\n\
:replaced\r\n\
{restart}\ {restart}\
del /F /Q \"%~f0\" >nul 2>nul\r\n" del /F /Q \"%BSK_UPDATE_LOG%\" >nul 2>nul\r\n\
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 0)\r\n\
:failed_restart\r\n\
echo Replaced the executable but failed to restart the daemon.\r\n\
:failed\r\n\
del /F /Q \"%BSK_UPDATE_SOURCE%\" >nul 2>nul\r\n\
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 1)\r\n"
) )
} }
@@ -965,6 +1019,7 @@ mod tests {
assert_eq!(extracted, b"windows binary"); assert_eq!(extracted, b"windows binary");
} }
#[cfg(not(windows))]
#[test] #[test]
fn replaces_binary_at_path() { fn replaces_binary_at_path() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
@@ -999,31 +1054,139 @@ mod tests {
} }
#[test] #[test]
fn windows_replacement_script_retries_restarts_and_cleans_itself() { fn windows_replacement_script_preserves_restart_config() {
let script = windows_replacement_script( let args = StartArgs {
Path::new(r"C:\Program Files\bsk.exe"), port: Some(54321),
Path::new(r"C:\Program Files\bsk.exe.update-42"), session_idle: Some(Duration::from_millis(1234)),
true, daemon_idle: Some(Duration::from_secs(75)),
..Default::default()
};
let script = windows_replacement_script(Some(&args), 120);
assert!(script.is_ascii());
assert!(
script
.contains("daemon start --port 54321 --session-idle 1234ms --daemon-idle 75000ms")
); );
assert!(!windows_replacement_script(None, 120).contains("daemon start"));
assert!(script.contains(":retry"));
assert!(script.contains("move /Y"));
assert!(script.contains("ping -n 2 127.0.0.1"));
assert!(!script.contains("timeout"));
assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#));
assert!(script.contains(r#"del /F /Q "%~f0""#));
} }
#[test] #[cfg(windows)]
fn windows_replacement_script_omits_restart_when_daemon_was_not_running() { fn wait_for_helper(mut child: windows::Helper) -> std::process::ExitStatus {
let script = windows_replacement_script( let deadline = std::time::Instant::now() + Duration::from_secs(10);
Path::new(r"C:\bsk.exe"), loop {
Path::new(r"C:\bsk.exe.update-42"), if let Some(status) = child.try_wait().unwrap() {
false, return status;
); }
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("Windows update helper did not exit");
}
std::thread::sleep(Duration::from_millis(20));
}
}
assert!(!script.contains("daemon start")); #[cfg(windows)]
assert!(script.contains(r#"del /F /Q "%~f0""#)); #[test]
fn windows_helper_replaces_in_unicode_spaces_and_shell_symbol_paths() {
let tmp = tempfile::TempDir::new().unwrap();
for name in [
"ascii",
"with space",
"中文目录",
"literal %PATH% ! & (folder)",
] {
let dir = tmp.path().join(name);
std::fs::create_dir(&dir).unwrap();
let target = dir.join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
let child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
let status = wait_for_helper(child);
assert!(
status.success(),
"{name}: {:?}",
std::fs::read_to_string(paths.script_path.with_extension("log"))
);
assert_eq!(std::fs::read(&target).unwrap(), b"new binary", "{name}");
// Successful updates leave no helper, staged binary, ready file or log.
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "{name}");
}
}
#[cfg(windows)]
#[test]
fn windows_helper_waits_for_unlock_then_replaces() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
// Deny delete sharing, like a running Windows executable.
let lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&target)
.unwrap();
let mut child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
std::thread::sleep(Duration::from_millis(150));
assert!(child.try_wait().unwrap().is_none());
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
drop(lock);
assert!(wait_for_helper(child).success());
assert_eq!(std::fs::read(&target).unwrap(), b"new binary");
}
#[cfg(windows)]
#[test]
fn windows_helper_bounds_retries_and_preserves_old_binary_on_failure() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
let _lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&target)
.unwrap();
let child = spawn_windows_replacement(&target, b"new binary", None, 2).unwrap();
assert!(!wait_for_helper(child).success());
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
assert!(!paths.binary_path.exists());
assert!(!paths.script_path.exists());
let log_bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
let log = String::from_utf8_lossy(&log_bytes);
assert!(
log.contains("Failed to replace the executable after 2 attempts."),
"{log}"
);
}
#[cfg(windows)]
#[test]
fn windows_helper_reports_restart_failure_without_losing_replacement() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
// Not a valid PE executable: replacement succeeds but restart fails.
let child = spawn_windows_replacement(
&target,
b"invalid executable",
Some(&StartArgs::default()),
2,
)
.unwrap();
assert!(!wait_for_helper(child).success());
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
let bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
let log = String::from_utf8_lossy(&bytes);
assert!(
log.contains("Replaced the executable but failed to restart the daemon."),
"{log}"
);
assert_eq!(std::fs::read(&target).unwrap(), b"invalid executable");
assert!(!paths.script_path.exists());
assert!(!paths.binary_path.exists());
} }
#[test] #[test]
@@ -1282,10 +1445,9 @@ mod tests {
} }
#[test] #[test]
fn auto_update_step_staged_outcome_requests_no_restart() { fn auto_update_step_reports_staged_handoff() {
// The Windows shape: the replacement is staged next to the // The helper, rather than this process, starts the new executable.
// running binary, so the outcome must not ask for the immediate // The daemon must exit to let that staged handoff finish.
// self-restart that `Replaced` triggers.
let candidate = test_candidate(); let candidate = test_candidate();
let outcome = let outcome =
auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap(); auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap();
+203
View File
@@ -0,0 +1,203 @@
//! Launch the update helper with only its stdio handles inherited.
//!
//! std::process::Command on Windows also inherits other inheritable handles.
//! A daemon's listening socket must not survive in the helper: it would keep
//! the port occupied after the daemon exits and prevent its replacement starting.
use std::ffi::OsStr;
use std::fs::File;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::os::windows::process::ExitStatusExt;
use std::path::Path;
use std::process::ExitStatus;
use windows_sys::Win32::Foundation::{
HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT,
};
use windows_sys::Win32::System::Threading::{
CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW,
DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess,
InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION,
STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute,
WaitForSingleObject,
};
pub(super) struct Helper(OwnedHandle);
impl Helper {
pub(super) fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
// SAFETY: the owned process handle remains valid for both calls.
match unsafe { WaitForSingleObject(self.0.as_raw_handle(), 0) } {
WAIT_OBJECT_0 => {
let mut code = 0;
if unsafe { GetExitCodeProcess(self.0.as_raw_handle(), &mut code) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(Some(ExitStatus::from_raw(code)))
}
WAIT_TIMEOUT => Ok(None),
_ => Err(io::Error::last_os_error()),
}
}
pub(super) fn kill(&mut self) -> io::Result<()> {
// SAFETY: this handle belongs to the helper we created.
if unsafe { TerminateProcess(self.0.as_raw_handle(), 1) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub(super) fn wait(&mut self) -> io::Result<()> {
// Only used after kill(); do not leave a live helper on startup failure.
if unsafe { WaitForSingleObject(self.0.as_raw_handle(), 5000) } != WAIT_OBJECT_0 {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"update helper did not exit",
));
}
Ok(())
}
}
// The opaque attribute list needs pointer-aligned storage and explicit teardown.
struct AttributeList(Vec<usize>);
impl Drop for AttributeList {
fn drop(&mut self) {
// SAFETY: this wrapper is constructed only after successful initialization.
unsafe { DeleteProcThreadAttributeList(self.0.as_mut_ptr().cast()) };
}
}
pub(super) fn spawn(
script: &Path,
source: &Path,
target: &Path,
ready: &Path,
log_path: &Path,
) -> io::Result<Helper> {
let root = std::env::var_os("SystemRoot")
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "SystemRoot is not set"))?;
let application = wide(Path::new(&root).join("System32/cmd.exe").as_os_str());
// /S /C strips one outer pair of quotes. Environment expansion keeps paths
// Unicode and avoids CRT escaping, batch-file encoding, and CALL expansion.
let mut command = wide(OsStr::new(
"cmd.exe /D /V:OFF /S /C \"\"%BSK_UPDATE_SCRIPT%\"\"",
));
let overrides = [
("BSK_UPDATE_SCRIPT", script.as_os_str()),
("BSK_UPDATE_SOURCE", source.as_os_str()),
("BSK_UPDATE_TARGET", target.as_os_str()),
("BSK_UPDATE_READY", ready.as_os_str()),
("BSK_UPDATE_LOG", log_path.as_os_str()),
];
let mut env: Vec<_> = std::env::vars_os()
.filter(|(key, _)| {
let key = key.to_string_lossy();
!overrides
.iter()
.any(|(name, _)| key.eq_ignore_ascii_case(name))
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMONIZED_ENV)
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMON_REPLACEMENT_WAIT_ENV)
})
.collect();
env.extend(
overrides
.into_iter()
.map(|(key, value)| (key.into(), value.to_owned())),
);
env.sort_by_key(|(key, _)| key.to_string_lossy().to_uppercase());
let mut environment = Vec::new();
for (key, value) in env {
environment.extend(key.encode_wide());
environment.push(b'=' as u16);
environment.extend(value.encode_wide());
environment.push(0);
}
environment.push(0);
let input = File::open("NUL")?;
let log = File::create(log_path)?;
let handles: [HANDLE; 2] = [input.as_raw_handle(), log.as_raw_handle()];
for &handle in &handles {
// SAFETY: these are dedicated helper stdio handles, held until spawn
// finishes. The explicit handle list excludes every other parent handle.
if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 {
return Err(io::Error::last_os_error());
}
}
let mut size = 0;
// SAFETY: the first call queries the allocation size, as required by Win32.
unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut size) };
if size == 0 {
return Err(io::Error::last_os_error());
}
let mut storage = vec![0usize; size.div_ceil(std::mem::size_of::<usize>())];
// SAFETY: storage has the requested size and pointer alignment.
if unsafe { InitializeProcThreadAttributeList(storage.as_mut_ptr().cast(), 1, 0, &mut size) }
== 0
{
return Err(io::Error::last_os_error());
}
let mut attributes = AttributeList(storage);
// SAFETY: the handle array and attribute storage outlive CreateProcessW.
if unsafe {
UpdateProcThreadAttribute(
attributes.0.as_mut_ptr().cast(),
0,
PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
handles.as_ptr().cast(),
std::mem::size_of_val(&handles),
std::ptr::null_mut(),
std::ptr::null(),
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: these Win32 structs permit zero initialization; required fields
// are populated below before the process is created.
let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
startup.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = handles[0];
startup.StartupInfo.hStdOutput = handles[1];
startup.StartupInfo.hStdError = handles[1];
startup.lpAttributeList = attributes.0.as_mut_ptr().cast();
let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
// SAFETY: strings are NUL-terminated UTF-16, the environment is double-NUL
// terminated, and all buffers/handles remain alive for the duration of spawn.
if unsafe {
CreateProcessW(
application.as_ptr(),
command.as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
1,
CREATE_NO_WINDOW
| CREATE_NEW_PROCESS_GROUP
| CREATE_UNICODE_ENVIRONMENT
| EXTENDED_STARTUPINFO_PRESENT,
environment.as_ptr().cast(),
std::ptr::null(),
&startup.StartupInfo,
&mut process,
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: successful CreateProcessW transfers these two handles to us.
let _thread = unsafe { OwnedHandle::from_raw_handle(process.hThread) };
Ok(Helper(unsafe {
OwnedHandle::from_raw_handle(process.hProcess)
}))
}
fn wide(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(Some(0)).collect()
}
+134
View File
@@ -1399,6 +1399,28 @@ mod windows {
connected = pipe.connect() => { connected = pipe.connect() => {
match connected { match connected {
Ok(()) => { Ok(()) => {
// Keep the connected instance alive until its replacement
// exists. Otherwise a fast handler can close the last
// instance and make new clients fail with NotFound.
loop {
match ServerOptions::new()
.access_inbound(true)
.access_outbound(true)
.create(&listener.pipe_name)
{
Ok(next) => {
listener.first = Some(next);
break;
}
Err(err) => {
warn!(?err, "create next named-pipe instance failed");
tokio::select! {
_ = &mut shutdown => return,
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
}
}
}
}
on_open(); on_open();
let handler = handler.clone(); let handler = handler.clone();
let on_act = on_activity.clone(); let on_act = on_activity.clone();
@@ -1467,6 +1489,118 @@ mod windows {
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::time::Duration;
use tokio::net::windows::named_pipe::ClientOptions;
use tokio::sync::oneshot;
fn isolated_listener() -> NamedPipeListener {
let pipe_name = format!(r"\\.\pipe\bsk-test-{}", uuid::Uuid::new_v4());
let first = ServerOptions::new()
.first_pipe_instance(true)
.create(&pipe_name)
.unwrap();
NamedPipeListener {
pipe_name,
first: Some(first),
}
}
#[tokio::test]
async fn next_instance_exists_before_connection_is_handed_off() {
let listener = isolated_listener();
let name = listener.pipe_name.clone();
let probe_name = name.clone();
let (probe_tx, probe_rx) = oneshot::channel();
let probe_tx = Mutex::new(Some(probe_tx));
let (stop_tx, stop_rx) = oneshot::channel();
let server = tokio::spawn(serve(
listener,
crate::daemon::ipc::default_ping_handler(),
move || {
if let Some(tx) = probe_tx.lock().unwrap().take() {
// This callback runs before the connection task can finish.
// A second client must already have an instance to open.
let result = ClientOptions::new().open(&probe_name).map(drop);
let _ = tx.send(result);
}
},
|| {},
|| {},
async {
let _ = stop_rx.await;
},
));
let client = ClientOptions::new().open(&name).unwrap();
let result = tokio::time::timeout(Duration::from_secs(5), probe_rx).await;
drop(client);
let _ = stop_tx.send(());
server.await.unwrap();
result
.expect("accept callback ran")
.unwrap()
.expect("next pipe instance is ready");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rapid_disconnects_and_concurrent_rpc_connections() {
let listener = isolated_listener();
let name = listener.pipe_name.clone();
let (stop_tx, stop_rx) = oneshot::channel();
let server = tokio::spawn(serve(
listener,
crate::daemon::ipc::default_ping_handler(),
|| {},
|| {},
|| {},
async {
let _ = stop_rx.await;
},
));
let exercise = async {
// Include clients that close without sending any request.
for _ in 0..64 {
drop(
crate::ipc_client::Client::connect_path(name.clone().into())
.await
.unwrap(),
);
}
let mut clients = tokio::task::JoinSet::new();
for _ in 0..8 {
let name = name.clone();
clients.spawn(async move {
for _ in 0..32 {
let mut client =
crate::ipc_client::Client::connect_path(name.clone().into())
.await
.unwrap();
let reply: bsk_protocol::PingResult = client
.call(
bsk_protocol::Method::SystemPing,
&serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap()
.unwrap();
assert!(reply.pong);
}
});
}
while let Some(result) = clients.join_next().await {
result.unwrap();
}
};
let result = tokio::time::timeout(Duration::from_secs(30), exercise).await;
let _ = stop_tx.send(());
server.await.unwrap();
result.expect("connection exercise completed");
}
}
} }
#[cfg(windows)] #[cfg(windows)]
+44 -4
View File
@@ -90,17 +90,21 @@ pub fn pid_alive(pid: u32) -> bool {
#[cfg(windows)] #[cfg(windows)]
{ {
use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
use windows_sys::Win32::System::Threading::{ use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
}; };
unsafe { unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid);
if handle.is_null() { if handle.is_null() {
return false; return false;
} }
// An exited process can still be opened while another handle
// keeps its kernel object alive. Poll its termination signal;
// unlike checking STILL_ACTIVE, this also handles exit code 259.
let alive = WaitForSingleObject(handle, 0) == WAIT_TIMEOUT;
CloseHandle(handle); CloseHandle(handle);
true alive
} }
} }
@@ -110,3 +114,39 @@ pub fn pid_alive(pid: u32) -> bool {
false false
} }
} }
#[cfg(all(test, windows))]
mod tests {
use super::pid_alive;
use std::os::windows::process::CommandExt;
use std::process::Command;
#[test]
fn current_process_is_alive() {
assert!(pid_alive(std::process::id()));
}
#[test]
fn invalid_process_is_not_alive() {
assert!(!pid_alive(0));
assert!(!pid_alive(u32::MAX));
}
#[test]
fn exited_process_with_retained_handle_is_not_alive() {
for exit_code in [0, 259] {
let mut child = Command::new("cmd.exe")
.args(["/D", "/C", &format!("exit {exit_code}")])
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.spawn()
.expect("spawn short-lived process");
assert_eq!(child.wait().unwrap().code(), Some(exit_code));
// Keep Child (and its process handle) alive during the probe.
assert!(
!pid_alive(child.id()),
"exited process with code {exit_code}"
);
drop(child);
}
}
}
+14 -9
View File
@@ -232,8 +232,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state)); let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state)); let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state));
// Fired by the update check task after a successful auto-update: // Fired by the update check task after a successful auto-update:
// the replacement daemon has already been spawned, so this // the replacement daemon (or Windows update helper) is ready, so
// process should shut down and let it take over. // this process should shut down and let it take over.
let restart_notify = Arc::new(tokio::sync::Notify::new()); let restart_notify = Arc::new(tokio::sync::Notify::new());
let update_check_task = let update_check_task =
spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify)); spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify));
@@ -539,8 +539,8 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
/// task spawns the replacement daemon (see /// task spawns the replacement daemon (see
/// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process /// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process
/// shuts down and the new version takes over; on Windows the /// shuts down and the new version takes over; on Windows the
/// replacement can only be staged, so it just logs that a restart is /// helper waits for this process to exit, replaces the binary, and starts
/// needed. /// the new daemon with the same configuration.
pub(crate) fn spawn_update_check_task( pub(crate) fn spawn_update_check_task(
state: Arc<DaemonState>, state: Arc<DaemonState>,
restart: Arc<tokio::sync::Notify>, restart: Arc<tokio::sync::Notify>,
@@ -605,7 +605,11 @@ pub(crate) fn spawn_update_check_task(
|candidate| { |candidate| {
let target = let target =
exe_path.as_deref().context("current executable unknown")?; exe_path.as_deref().context("current executable unknown")?;
update::self_install_candidate(candidate, target) update::self_install_candidate(
candidate,
target,
&restart_start_args(&state.config),
)
}, },
) )
}) })
@@ -635,10 +639,11 @@ pub(crate) fn spawn_update_check_task(
sessions, sessions,
"auto-update postponed: agent session(s) active; will retry on the next tick" "auto-update postponed: agent session(s) active; will retry on the next tick"
), ),
update::AutoUpdateOutcome::Staged { latest } => warn!( update::AutoUpdateOutcome::Staged { latest } => {
%latest, info!(%latest, "update helper ready; exiting so it can replace and restart the daemon");
"auto-update staged the new binary but cannot replace the running daemon in place; restart the daemon (or terminal) to finish the upgrade" restart.notify_one();
), return;
}
update::AutoUpdateOutcome::Replaced { latest } => { update::AutoUpdateOutcome::Replaced { latest } => {
info!( info!(
current = env!("CARGO_PKG_VERSION"), current = env!("CARGO_PKG_VERSION"),
+343
View File
@@ -0,0 +1,343 @@
//! Exercise self-update with a real executable and a local release server.
#![cfg(windows)]
use std::fs;
use std::io::{Cursor, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use bsk::daemon::info::DaemonInfo;
use sha2::{Digest, Sha256};
const MARKER: &[u8] = b"windows-update-regression-fixture";
struct ReleaseServer {
url: String,
requests: Arc<AtomicUsize>,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl ReleaseServer {
fn new(binary: &[u8]) -> Self {
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
archive
.start_file(
"bsk.exe",
zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored),
)
.unwrap();
archive.write_all(binary).unwrap();
let archive = archive.finish().unwrap().into_inner();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let manifest = serde_json::to_vec(&serde_json::json!({
"version": "999.0.0",
"assets": {"windows-x64": {
"url": format!("{url}/bsk.zip"),
"sha256": Sha256::digest(&archive).iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
}},
}))
.unwrap();
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = Arc::clone(&stop);
let requests = Arc::new(AtomicUsize::new(0));
let worker_requests = Arc::clone(&requests);
let worker = thread::spawn(move || {
while !worker_stop.load(Ordering::SeqCst) {
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
continue;
}
Err(err) => panic!("accept release request: {err}"),
};
// Windows accept() inherits the listener's nonblocking mode.
// Keep accept polling for shutdown, but read/write each request
// in blocking mode with the bounded timeouts below.
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut chunk = [0; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let len = stream.read(&mut chunk).unwrap();
if len == 0 {
break;
}
request.extend_from_slice(&chunk[..len]);
}
let body = if request.starts_with(b"GET /bsk.zip ") {
worker_requests.fetch_add(1, Ordering::SeqCst);
&archive
} else {
&manifest
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.unwrap();
stream.write_all(body).unwrap();
}
});
Self {
url: format!("{url}/version.json"),
requests,
stop,
worker: Some(worker),
}
}
}
impl Drop for ReleaseServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
let _ = self.worker.take().unwrap().join();
}
}
#[test]
fn release_server_waits_for_delayed_and_fragmented_request_headers() {
let server = ReleaseServer::new(b"test binary");
let address = server
.url
.strip_prefix("http://")
.unwrap()
.strip_suffix("/version.json")
.unwrap();
let mut stream = TcpStream::connect(address).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
// Give accept() time to run before any bytes arrive, then split the
// headers across writes. Both reads must wait rather than return WouldBlock.
thread::sleep(Duration::from_millis(100));
stream.write_all(b"GET /version.json HTTP/1.1\r\n").unwrap();
thread::sleep(Duration::from_millis(100));
stream.write_all(b"Host: localhost\r\n\r\n").unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}");
let (_, body) = response.split_once("\r\n\r\n").unwrap();
let manifest: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(manifest["version"], "999.0.0");
}
struct Fixture {
_tmp: tempfile::TempDir,
exe: PathBuf,
home: PathBuf,
binary: Vec<u8>,
server: ReleaseServer,
daemon: Option<Child>,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("中文 space %PATH% ! & (update)");
fs::create_dir(&dir).unwrap();
let exe = dir.join("bsk.exe");
let home = tmp.path().join("home");
fs::create_dir(&home).unwrap();
fs::copy(env!("CARGO_BIN_EXE_bsk"), &exe).unwrap();
// A PE overlay distinguishes the replacement without requiring a
// second build or changing the executable's behavior/version.
let mut binary = fs::read(&exe).unwrap();
binary.extend_from_slice(MARKER);
let server = ReleaseServer::new(&binary);
Self {
_tmp: tmp,
exe,
home,
binary,
server,
daemon: None,
}
}
fn command(&self) -> Command {
let mut cmd = Command::new(&self.exe);
cmd.env("BSK_HOME", &self.home)
.env("BSK_UPDATE_MANIFEST_URL", &self.server.url)
.env("BSK_AUTO_UPDATE", "off")
.env("RUST_LOG", "info")
.env("NO_PROXY", "127.0.0.1,localhost")
.env_remove("BSK_DAEMONIZED")
.env_remove("BSK_DAEMON_REPLACES_PID")
.stdin(Stdio::null())
.creation_flags(0x0800_0000);
cmd
}
fn wait_for(&self, description: &str, mut check: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if check() {
return;
}
thread::sleep(Duration::from_millis(50));
}
let diagnostics: Vec<_> = fs::read_dir(self.exe.parent().unwrap())
.unwrap()
.flatten()
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "log"))
.map(|entry| {
(
entry.path(),
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
.into_owned(),
)
})
.collect();
let home_logs: Vec<_> = fs::read_dir(&self.home)
.unwrap()
.flatten()
.filter(|entry| entry.file_name().to_string_lossy().contains("log"))
.map(|entry| {
(
entry.path(),
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
.into_owned(),
)
})
.collect();
panic!(
"timed out waiting for {description}; helper logs: {diagnostics:?}; daemon logs: {home_logs:?}"
);
}
fn info(&self) -> Option<DaemonInfo> {
serde_json::from_slice(&fs::read(self.home.join("daemon.json")).ok()?).ok()
}
fn updated(&self) -> bool {
// A locked executable may briefly reject reads during replacement.
fs::read(&self.exe).is_ok_and(|binary| binary == self.binary)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
// Stop only the daemon belonging to this isolated BSK_HOME. Also reap
// the foreground process on assertion failures to avoid leaking locks.
let _ = self.command().args(["daemon", "stop"]).output();
if let Some(child) = self.daemon.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[test]
fn manual_update_replaces_the_running_executable_after_cli_exit() {
let fixture = Fixture::new();
let out = fixture
.command()
.args(["--json", "update", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(report["status"], "staged");
fixture.wait_for("CLI replacement and helper cleanup", || {
fixture.updated() && fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
});
assert!(
fixture.info().is_none(),
"an update must not start a previously absent daemon"
);
assert_eq!(fixture.server.requests.load(Ordering::SeqCst), 1);
}
#[test]
fn automatic_update_exits_old_daemon_and_restarts_on_the_same_port() {
let mut fixture = Fixture::new();
let port = unused_port();
let log = fs::File::create(fixture.home.join("foreground.log")).unwrap();
let child = fixture
.command()
.env("BSK_AUTO_UPDATE", "on")
// Exercise the environment inherited by a normally detached daemon.
.env("BSK_DAEMONIZED", "1")
.args([
"daemon",
"start",
"--port",
&port.to_string(),
"--session-idle",
"1234ms",
"--daemon-idle",
"30s",
])
.stdout(Stdio::from(log.try_clone().unwrap()))
.stderr(Stdio::from(log))
.spawn()
.unwrap();
let old_pid = child.id();
fixture.daemon = Some(child);
fixture.wait_for("replacement daemon", || {
fixture.updated()
&& fixture
.info()
.is_some_and(|info| info.pid != old_pid && info.ws_port == port)
});
assert!(
fixture
.daemon
.as_mut()
.unwrap()
.try_wait()
.unwrap()
.is_some(),
"old daemon must exit"
);
fixture.wait_for("helper cleanup", || {
fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
});
let status = fixture
.command()
.args(["--json", "status"])
.output()
.unwrap();
assert!(
status.status.success(),
"{}",
String::from_utf8_lossy(&status.stderr)
);
assert_eq!(
fixture.server.requests.load(Ordering::SeqCst),
1,
"the replacement must not stage another update immediately"
);
}
fn unused_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
+27 -6
View File
@@ -112,7 +112,7 @@ shell's theme cannot bleed back in.
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first - **Lifecycle**: hidden while the plugin owns no sessions; appears on the first
`browser_session` start action; disappears when all sessions stop (or the plugin unloads). `browser_session` start action; disappears when all sessions stop (or the plugin unloads).
- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page - **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
frame (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on frame (while viewed, refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
stage while the next one loads, and is kept on errors so the card does not flash), stage while the next one loads, and is kept on errors so the card does not flash),
and a compact icon toolbar (Interrupt + Pop out, hover for the label). and a compact icon toolbar (Interrupt + Pop out, hover for the label).
- **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics - **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics
@@ -129,11 +129,32 @@ shell's theme cannot bleed back in.
- **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user - **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user
gesture, per browser rules), sized from the current card; closing the PiP falls back to the gesture, per browser rules), sized from the current card; closing the PiP falls back to the
in-page card with state intact. Browsers without Document PiP simply hide the button. in-page card with state intact. Browsers without Document PiP simply hide the button.
- **Wire**: the host serves `GET /bsk-observation/state`, `GET /bsk-observation/events` (SSE), - **Screenshot demand**: the client requests periodic screenshots while an observation view is
`POST /bsk-observation/interrupt`, and `GET /bsk-observation/thumbnail/<attachmentId>` over visible, using the PiP document's visibility when popped out. Hidden or collapsed views can
the dsh `webServer` route seam (dsh 0.1's Typert Remote pipeline is closed to out-of-tree keep subscribing to state without requesting screenshots. The first screenshot subscriber
packages). All commands for one session — tool calls and frame captures alike — run through a starts capture scheduling; the last one's departure cancels timers and skips queued captures.
per-session FIFO, because the daemon accepts only one unfinished command per session. An already running capture may finish. Demand applies to the service's owned sessions, not just
the selected session.
- **Wire**: `GET /bsk-observation/events` is the live synchronization channel (SSE). Every connection,
including reconnects, starts with `{ type: "snapshot", sessions, available }`, followed by
`upsert`, `remove`, `reset`, and `availability` events on the same stream. Replace local state
with each snapshot before applying subsequent events. Use `?thumbnails=0` for state-only
subscriptions or `?thumbnails=1` to request screenshots; omitting the parameter defaults to
requesting screenshots for compatibility. Closing the stream releases its screenshot demand.
`GET /bsk-observation/state` remains a one-time `{ sessions, available }` read and does not
request screenshots. It has no ordering guarantee relative to a separate SSE connection;
live clients should initialize and resynchronize from the stream's snapshot instead.
The host also serves `POST /bsk-observation/interrupt`, `POST /bsk-observation/stop`, and
`GET /bsk-observation/thumbnail/<attachmentId>` through the dsh `webServer` route seam
(dsh 0.1's Typert Remote pipeline is closed to out-of-tree packages). All commands for one
session — tool calls and frame captures alike — run through a per-session FIFO, because the
daemon accepts only one unfinished command per session.
- **Programmatic subscription**: `ObservationService.subscribe(listener, { thumbnails: false })`
subscribes to state without requesting screenshots. On an active service, the listener receives
the initial `snapshot` synchronously, before `subscribe` returns, then receives subsequent
changes. `thumbnails` defaults to `true`, so callers should choose it explicitly. The returned
unsubscribe function is safe to call repeatedly and releases this subscription's screenshot
demand. Subscribing after service disposal is a no-op.
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they - **Trust model**: these routes expose live screenshots (and an interrupt write), so they
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
@@ -47,7 +47,6 @@
"client": { "client": {
"platform": "web", "platform": "web",
"inject": [ "inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-tool", "@deepseek-ai/dsh-client-ui-tool",
"@deepseek-ai/dsh-client-ui-layout" "@deepseek-ai/dsh-client-ui-layout"
], ],
@@ -69,7 +68,6 @@
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1", "@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6", "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6", "@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6", "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6", "@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
@@ -82,9 +80,6 @@
"@deepseek-ai/dsh-attachment": { "@deepseek-ai/dsh-attachment": {
"optional": true "optional": true
}, },
"@deepseek-ai/dsh-client-runtime": {
"optional": true
},
"@deepseek-ai/dsh-client-ui-attachment": { "@deepseek-ai/dsh-client-ui-attachment": {
"optional": true "optional": true
}, },
@@ -97,6 +97,10 @@ Continue from returned sequence cursors instead of rereading the same buffer.
- Unknown tab: list tabs instead of guessing. - Unknown tab: list tabs instead of guessing.
- Unknown session: list owned sessions or start one; never try foreign ids. - Unknown session: list owned sessions or start one; never try foreign ids.
- Timeout: inspect current state before deciding whether one longer purposeful wait is useful. - Timeout: inspect current state before deciding whether one longer purposeful wait is useful.
- Fill result unconfirmed: observe the field first; the page may have formatted the value. Continue
if the visible result satisfies the user's intent. Otherwise correct the remaining difference;
do not blindly repeat fill or immediately request human help. For other fill errors, follow the
returned hint and inspect current state before retrying.
- Unrecoverable failure: report the blocker and stop the owned session. - Unrecoverable failure: report the blocker and stop the owned session.
Arbitrary page-script evaluation and interaction recording are intentionally unsupported. Do not Arbitrary page-script evaluation and interaction recording are intentionally unsupported. Do not
@@ -142,6 +142,8 @@
right: 6px; right: 6px;
width: 16px; width: 16px;
height: 16px; height: 16px;
padding: 0;
cursor: pointer;
border-radius: 50%; border-radius: 50%;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -48,7 +48,13 @@ import { createPortal } from "react-dom";
import type { SessionObservation } from "../observation"; import type { SessionObservation } from "../observation";
import css from "./ObservationOverlay.module.css"; import css from "./ObservationOverlay.module.css";
import type { ObservationClientStore } from "./observation-store"; import type { ObservationClientStore } from "./observation-store";
import { focusOf, statusOf, useObservationView, usePip } from "./observation-view"; import {
focusOf,
statusOf,
useObservationView,
usePip,
useThumbnailObservation,
} from "./observation-view";
import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode"; import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode";
// The pure view helpers live in observation-view (shared with the sidebar // The pure view helpers live in observation-view (shared with the sidebar
@@ -341,6 +347,7 @@ export function OverlayBody(props: {
onHeaderPointerDown, onHeaderPointerDown,
} = props; } = props;
const [interrupting, setInterrupting] = useState(false); const [interrupting, setInterrupting] = useState(false);
const viewRef = useThumbnailObservation(store, sessions.length > 0);
const thumbId = focus?.thumbnailAttachmentId; const thumbId = focus?.thumbnailAttachmentId;
useEffect(() => { useEffect(() => {
@@ -373,7 +380,12 @@ export function OverlayBody(props: {
const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle"; const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle";
return ( return (
<div className={cn(css.body, "bsk-obs")} data-state={state} data-in-pip={inPip || undefined}> <div
ref={viewRef}
className={cn(css.body, "bsk-obs")}
data-state={state}
data-in-pip={inPip || undefined}
>
<div <div
className={css.header} className={css.header}
data-testid="obs-header" data-testid="obs-header"
@@ -421,9 +433,15 @@ export function OverlayBody(props: {
</div> </div>
)} )}
{thumb?.status === "error" ? ( {thumb?.status === "error" ? (
<span className={css.badge} aria-label="thumbnail failed"> <button
type="button"
className={css.badge}
aria-label="Retry thumbnail"
title="Frame unavailable — retry"
onClick={() => store.retryThumbnail(thumbId)}
>
<IconWarn size={12} /> <IconWarn size={12} />
</span> </button>
) : null} ) : null}
</div> </div>
{sessions.length >= 2 ? ( {sessions.length >= 2 ? (
@@ -7,6 +7,7 @@
*/ */
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment"; import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
// Development-only types for the injected services; newer hosts no longer ship this package.
import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client"; import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client";
// Type-only: pulls the 'shell.overlay' SlotMap merge into scope. // Type-only: pulls the 'shell.overlay' SlotMap merge into scope.
import type {} from "@deepseek-ai/dsh-client-ui-layout/client"; import type {} from "@deepseek-ai/dsh-client-ui-layout/client";
@@ -1,10 +1,9 @@
/** /**
* Client-side data layer for the observation overlay: initial state fetch, * Client-side data layer for the observation overlay: an ordered SSE snapshot
* SSE increment stream (with a state refetch on every (re)open — events that * and increment stream (each reconnect starts with a fresh snapshot), on-demand
* fired while the stream was down are otherwise lost forever), on-demand * thumbnail blob loading through the host's ephemeral-frame route,
* thumbnail blob loading through the session-authorized readAttachment path,
* and the interrupt call. The last ready blob per session is held until its * and the interrupt call. The last ready blob per session is held until its
* replacement decodes so the overlay can swap frames in place. All I/O is * replacement loads so the overlay can swap frames in place. All I/O is
* injected so tests never touch a network. * injected so tests never touch a network.
*/ */
@@ -12,8 +11,6 @@ import type { ObservationEvent, SessionObservation } from "../observation";
export interface EventSourceLike { export interface EventSourceLike {
onmessage: ((event: { data: string }) => void) | null; onmessage: ((event: { data: string }) => void) | null;
/** Fires on the initial connect AND on every automatic reconnect. */
onopen?: (() => void) | null;
close(): void; close(): void;
} }
@@ -50,10 +47,10 @@ export interface OverlaySnapshot {
readonly available: boolean; readonly available: boolean;
} }
const STATE_URL = "/bsk-observation/state";
const EVENTS_URL = "/bsk-observation/events"; const EVENTS_URL = "/bsk-observation/events";
const INTERRUPT_URL = "/bsk-observation/interrupt"; const INTERRUPT_URL = "/bsk-observation/interrupt";
const STOP_URL = "/bsk-observation/stop"; const STOP_URL = "/bsk-observation/stop";
const THUMBNAIL_RETRY_DELAYS_MS = [1000, 3000];
function revoke(url: string | undefined): void { function revoke(url: string | undefined): void {
if (url !== undefined && typeof URL.revokeObjectURL === "function") { if (url !== undefined && typeof URL.revokeObjectURL === "function") {
@@ -66,8 +63,10 @@ export class ObservationClientStore {
private thumbs = new Map<string, ThumbnailState>(); private thumbs = new Map<string, ThumbnailState>();
/** sessionId → the attachment id currently advertised for it. */ /** sessionId → the attachment id currently advertised for it. */
private readonly thumbBySession = new Map<string, string>(); private readonly thumbBySession = new Map<string, string>();
/** sessionId → last successfully decoded frame (held across the next load). */ /** sessionId → last successfully loaded frame (held across the next load). */
private readonly lastReady = new Map<string, { attachmentId: string; url: string }>(); private readonly lastReady = new Map<string, { attachmentId: string; url: string }>();
private readonly retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly retryCounts = new Map<string, number>();
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private events: EventSourceLike | undefined; private events: EventSourceLike | undefined;
private snapshot: OverlaySnapshot = { private snapshot: OverlaySnapshot = {
@@ -81,6 +80,7 @@ export class ObservationClientStore {
private started = false; private started = false;
/** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */ /** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */
private consumers = 0; private consumers = 0;
private thumbnailConsumers = 0;
constructor(private readonly deps: ObservationClientDeps) {} constructor(private readonly deps: ObservationClientDeps) {}
@@ -133,37 +133,16 @@ export class ObservationClientStore {
return undefined; return undefined;
} }
/** (Re)pull the full state: initial load and every SSE (re)open. */ private connectEvents(): void {
private refreshState(): void { const previous = this.events;
void this.deps this.events = undefined;
.fetchFn(STATE_URL) previous?.close();
.then(async (res) => { const events = this.deps.eventSourceFactory(
if (!res.ok) return; `${EVENTS_URL}?thumbnails=${this.thumbnailConsumers > 0 ? "1" : "0"}`,
const body = (await res.json()) as { );
sessions?: SessionObservation[]; this.events = events;
available?: boolean;
};
const sessions = body.sessions ?? [];
this.sessions = new Map(sessions.map((s) => [s.sessionId, s]));
// Prune thumbnails of sessions that vanished while we were away.
const alive = new Set(sessions.map((s) => s.sessionId));
for (const sessionId of [...this.thumbBySession.keys()]) {
if (!alive.has(sessionId)) this.dropThumb(sessionId);
}
for (const s of sessions) this.trackThumb(s.sessionId, s.thumbnailAttachmentId);
if (typeof body.available === "boolean") this.available = body.available;
this.publish();
})
.catch(() => {});
}
/** Initial fetch + SSE subscription. Idempotent. */
start(): void {
if (this.started) return;
this.started = true;
this.refreshState();
const events = this.deps.eventSourceFactory(EVENTS_URL);
events.onmessage = (message) => { events.onmessage = (message) => {
if (!this.started || this.events !== events) return;
let event: ObservationEvent; let event: ObservationEvent;
try { try {
event = JSON.parse(message.data) as ObservationEvent; event = JSON.parse(message.data) as ObservationEvent;
@@ -172,23 +151,24 @@ export class ObservationClientStore {
} }
this.apply(event); this.apply(event);
}; };
events.onopen = () => { }
// Reconnects lose every event fired during the outage — resync.
if (this.events === events) this.refreshState(); /** Every initial connection and reconnect starts with the server's snapshot. */
}; start(): void {
this.events = events; if (this.started) return;
this.started = true;
this.connectEvents();
this.publish(); this.publish();
} }
stop(): void { stop(): void {
this.events?.close(); const previous = this.events;
this.events = undefined; this.events = undefined;
this.started = false; this.started = false;
for (const thumb of this.thumbs.values()) revoke(thumb.url); previous?.close();
this.thumbs.clear(); this.clearThumbnails();
this.thumbBySession.clear();
this.lastReady.clear();
this.sessions.clear(); this.sessions.clear();
this.available = true;
this.publish(); this.publish();
} }
@@ -209,6 +189,33 @@ export class ObservationClientStore {
if (this.consumers === 0) this.stop(); if (this.consumers === 0) this.stop();
} }
/** Only visible image surfaces request screenshots; metadata watchers do not. */
watchThumbnails(): () => void {
this.thumbnailConsumers += 1;
if (this.thumbnailConsumers === 1 && this.started) this.connectEvents();
let released = false;
return () => {
if (released) return;
released = true;
this.thumbnailConsumers -= 1;
if (this.thumbnailConsumers === 0 && this.started) this.connectEvents();
};
}
private forgetThumbnail(attachmentId: string): void {
clearTimeout(this.retryTimers.get(attachmentId));
this.retryTimers.delete(attachmentId);
this.retryCounts.delete(attachmentId);
revoke(this.thumbs.get(attachmentId)?.url);
this.thumbs.delete(attachmentId);
}
private clearThumbnails(): void {
for (const attachmentId of this.thumbs.keys()) this.forgetThumbnail(attachmentId);
this.thumbBySession.clear();
this.lastReady.clear();
}
/** Forget one session's tracked + held frames, revoking their blob URLs. */ /** Forget one session's tracked + held frames, revoking their blob URLs. */
private dropThumb(sessionId: string): void { private dropThumb(sessionId: string): void {
const attachmentId = this.thumbBySession.get(sessionId); const attachmentId = this.thumbBySession.get(sessionId);
@@ -216,39 +223,45 @@ export class ObservationClientStore {
this.thumbBySession.delete(sessionId); this.thumbBySession.delete(sessionId);
this.lastReady.delete(sessionId); this.lastReady.delete(sessionId);
if (attachmentId !== undefined) { if (attachmentId !== undefined) {
revoke(this.thumbs.get(attachmentId)?.url); this.forgetThumbnail(attachmentId);
this.thumbs.delete(attachmentId);
} }
if (held !== undefined && held.attachmentId !== attachmentId) { if (held !== undefined && held.attachmentId !== attachmentId) {
revoke(held.url); this.forgetThumbnail(held.attachmentId);
this.thumbs.delete(held.attachmentId);
} }
} }
/** /**
* Track the frame a session currently advertises. The last *ready* blob is * Track the frame a session currently advertises. The last *ready* blob is
* kept until the replacement decodes — dropping it on the upsert is what * kept until the replacement loads — dropping it on the upsert is what
* made the overlay flash a placeholder between breaths. * made the overlay flash a placeholder between breaths.
*/ */
private trackThumb(sessionId: string, attachmentId: string | undefined): void { private trackThumb(sessionId: string, attachmentId: string | undefined): void {
if (attachmentId === undefined) {
this.dropThumb(sessionId);
return;
}
const previous = this.thumbBySession.get(sessionId); const previous = this.thumbBySession.get(sessionId);
if (previous === attachmentId) return; if (previous === attachmentId) return;
if (attachmentId !== undefined) this.thumbBySession.set(sessionId, attachmentId); this.thumbBySession.set(sessionId, attachmentId);
else this.thumbBySession.delete(sessionId);
const heldId = this.lastReady.get(sessionId)?.attachmentId; const heldId = this.lastReady.get(sessionId)?.attachmentId;
if (previous !== undefined && previous !== heldId) { if (previous !== undefined && previous !== heldId) {
revoke(this.thumbs.get(previous)?.url); this.forgetThumbnail(previous);
this.thumbs.delete(previous);
} }
} }
private apply(event: ObservationEvent): void { private apply(event: ObservationEvent): void {
if (event.type === "reset") { if (event.type === "snapshot") {
this.sessions = new Map(event.sessions.map((session) => [session.sessionId, session]));
for (const sessionId of this.thumbBySession.keys()) {
if (!this.sessions.has(sessionId)) this.dropThumb(sessionId);
}
for (const session of event.sessions) {
this.trackThumb(session.sessionId, session.thumbnailAttachmentId);
}
this.available = event.available;
} else if (event.type === "reset") {
this.sessions.clear(); this.sessions.clear();
for (const thumb of this.thumbs.values()) revoke(thumb.url); this.clearThumbnails();
this.thumbs.clear();
this.thumbBySession.clear();
this.lastReady.clear();
} else if (event.type === "remove" && event.session !== undefined) { } else if (event.type === "remove" && event.session !== undefined) {
this.sessions.delete(event.session.sessionId); this.sessions.delete(event.session.sessionId);
this.dropThumb(event.session.sessionId); this.dropThumb(event.session.sessionId);
@@ -259,44 +272,77 @@ export class ObservationClientStore {
this.available = event.available; this.available = event.available;
} }
this.publish(); this.publish();
if (event.type === "snapshot") {
for (const session of event.sessions) this.retryThumbnail(session.thumbnailAttachmentId);
}
} }
/** /**
* Ensure a thumbnail load is in flight for one attachment reference. New * Ensure a thumbnail load is in flight for one attachment reference. New
* frames replace the old URL only after they decode; failures keep the last * frames replace the old URL only after they load; failures keep the last
* good frame (the caller renders `status: 'error'` as a small badge over * good frame (the caller renders `status: 'error'` as a small badge over
* the old image). * the old image).
*/ */
ensureThumbnail(attachmentId: string | undefined): void { ensureThumbnail(attachmentId: string | undefined): void {
if (attachmentId === undefined || this.thumbs.has(attachmentId)) return; if (
this.thumbs.set(attachmentId, { status: "loading" }); !this.started ||
attachmentId === undefined ||
this.sessionOf(attachmentId) === undefined ||
this.thumbs.has(attachmentId)
)
return;
const loading: ThumbnailState = { status: "loading" };
this.thumbs.set(attachmentId, loading);
this.publish(); this.publish();
this.deps.loadImage(attachmentId).then( this.deps.loadImage(attachmentId).then(
(url) => { (url) => {
if (!this.thumbs.has(attachmentId)) { if (this.thumbs.get(attachmentId) !== loading) {
// Replaced or removed while loading: never resurrect (or leak) it. // Replaced or removed while loading: never resurrect (or leak) it.
revoke(url); revoke(url);
return; return;
} }
this.retryCounts.delete(attachmentId);
this.thumbs.set(attachmentId, { status: "ready", url }); this.thumbs.set(attachmentId, { status: "ready", url });
const sessionId = this.sessionOf(attachmentId); const sessionId = this.sessionOf(attachmentId);
if (sessionId !== undefined) { if (sessionId !== undefined) {
const previous = this.lastReady.get(sessionId); const previous = this.lastReady.get(sessionId);
if (previous !== undefined && previous.attachmentId !== attachmentId) { if (previous !== undefined && previous.attachmentId !== attachmentId) {
revoke(previous.url); this.forgetThumbnail(previous.attachmentId);
this.thumbs.delete(previous.attachmentId);
} }
this.lastReady.set(sessionId, { attachmentId, url }); this.lastReady.set(sessionId, { attachmentId, url });
} }
this.publish(); this.publish();
}, },
() => { () => {
this.thumbs.set(attachmentId, { status: "error" }); if (this.thumbs.get(attachmentId) !== loading) return;
const failed: ThumbnailState = { status: "error" };
this.thumbs.set(attachmentId, failed);
const attempts = this.retryCounts.get(attachmentId) ?? 0;
const delay = THUMBNAIL_RETRY_DELAYS_MS[attempts];
if (delay !== undefined) {
this.retryCounts.set(attachmentId, attempts + 1);
this.retryTimers.set(
attachmentId,
setTimeout(() => {
this.retryTimers.delete(attachmentId);
if (this.thumbs.get(attachmentId) !== failed) return;
this.thumbs.delete(attachmentId);
this.ensureThumbnail(attachmentId);
}, delay),
);
}
this.publish(); this.publish();
}, },
); );
} }
/** Explicit retry (also used on a fresh connection after an outage). */
retryThumbnail(attachmentId: string | undefined): void {
if (attachmentId === undefined || this.thumbs.get(attachmentId)?.status !== "error") return;
this.forgetThumbnail(attachmentId);
this.ensureThumbnail(attachmentId);
}
/** Interrupt the current or named session's in-flight call. */ /** Interrupt the current or named session's in-flight call. */
async interrupt(sessionId?: string): Promise<boolean> { async interrupt(sessionId?: string): Promise<boolean> {
try { try {
@@ -6,7 +6,7 @@
* without duplicating hooks. * without duplicating hooks.
*/ */
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { SessionObservation } from "../observation"; import type { SessionObservation } from "../observation";
import type { ObservationClientStore, OverlaySnapshot } from "./observation-store"; import type { ObservationClientStore, OverlaySnapshot } from "./observation-store";
@@ -114,6 +114,49 @@ export function useObservationView(
}; };
} }
/** A mounted metadata watcher is not necessarily a visible screenshot viewer. */
export function useThumbnailObservation(store: ObservationClientStore, enabled: boolean) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!enabled || element === null) return;
// A portal uses its PiP document's visibility, independently of the main tab.
const doc = element.ownerDocument;
const Observer = doc.defaultView?.IntersectionObserver;
let active = true;
let intersecting = Observer === undefined;
let release: (() => void) | undefined;
const update = () => {
if (!active) return;
const visible = intersecting && doc.visibilityState !== "hidden";
if (visible && release === undefined) release = store.watchThumbnails();
else if (!visible && release !== undefined) {
release();
release = undefined;
}
};
const observer =
Observer === undefined
? undefined
: new Observer((entries) => {
intersecting = entries.some(
(entry) => entry.target === element && entry.isIntersecting,
);
update();
});
observer?.observe(element);
doc.addEventListener("visibilitychange", update);
update();
return () => {
active = false;
observer?.disconnect();
doc.removeEventListener("visibilitychange", update);
release?.();
};
}, [store, enabled]);
return ref;
}
export interface PipHandle { export interface PipHandle {
/** The open PiP window, or null while the content renders in its carrier. */ /** The open PiP window, or null while the content renders in its carrier. */
readonly pipWindow: Window | null; readonly pipWindow: Window | null;
@@ -5,12 +5,19 @@
* the event allowlist lives in dsh-api-remotes), so the plugin serves its * the event allowlist lives in dsh-api-remotes), so the plugin serves its
* observation channel through the documented `webServer` route seam instead: * observation channel through the documented `webServer` route seam instead:
* *
* GET /bsk-observation/state → { sessions, available } * GET /bsk-observation/state → one-time { sessions, available } read
* GET /bsk-observation/events → SSE stream of ObservationEvent * GET /bsk-observation/events → SSE snapshot, then ObservationEvent increments
* (on every connection, including reconnects)
* ?thumbnails=0: state only; =1: request screenshots
* Omitted parameter defaults to screenshots.
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean} * POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
* POST /bsk-observation/stop → body {sessionId} → {stopped: boolean} * POST /bsk-observation/stop → body {sessionId} → {stopped: boolean}
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes * GET /bsk-observation/thumbnail/<attachmentId> → image bytes
* *
* Live clients initialize/resynchronize from the SSE snapshot; a separate
* /state read has no ordering guarantee relative to the stream and does not
* request screenshots. Closing an SSE connection releases its screenshot demand.
*
* Routes exist only when a webServer service is mounted (web composition); * Routes exist only when a webServer service is mounted (web composition);
* headless deployments skip registration entirely. * headless deployments skip registration entirely.
*/ */
@@ -99,6 +106,7 @@ export function registerObservationRoutes(
if (webServer === undefined) { if (webServer === undefined) {
return () => {}; return () => {};
} }
const streams = new Set<() => void>();
const disposers = [ const disposers = [
webServer.register({ webServer.register({
kind: "exact", kind: "exact",
@@ -129,14 +137,42 @@ export function registerObservationRoutes(
"cache-control": "no-cache", "cache-control": "no-cache",
connection: "keep-alive", connection: "keep-alive",
}); });
const unsubscribe = observation.subscribe((event) => { const thumbnails =
res.write(`data: ${JSON.stringify(event)}\n\n`); new URL(req.url ?? "/", "http://localhost").searchParams.get("thumbnails") !== "0";
}); let unsubscribe: (() => void) | undefined;
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), SSE_HEARTBEAT_MS); let heartbeat: ReturnType<typeof setInterval> | undefined;
res.on("close", () => { let closed = false;
const close = () => {
if (closed) return;
closed = true;
clearInterval(heartbeat); clearInterval(heartbeat);
unsubscribe(); unsubscribe?.();
}); streams.delete(close);
res.end();
};
const write = (data: string) => {
if (closed) return;
try {
res.write(data);
} catch {
close();
}
};
streams.add(close);
res.on("close", close);
res.on("error", close);
try {
unsubscribe = observation.subscribe(
(event) => {
write(`data: ${JSON.stringify(event)}\n\n`);
},
{ thumbnails },
);
if (closed) unsubscribe();
else heartbeat = setInterval(() => write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
} catch {
close();
}
}, },
}), }),
webServer.register({ webServer.register({
@@ -228,6 +264,7 @@ export function registerObservationRoutes(
}), }),
]; ];
return () => { return () => {
for (const close of streams) close();
for (const dispose of disposers) dispose(); for (const dispose of disposers) dispose();
}; };
} }
@@ -49,8 +49,9 @@ export interface SessionObservation {
dshSessionIds?: string[]; dshSessionIds?: string[];
} }
/** Incremental event carried to subscribers (SSE on the wire). */ /** Initial snapshot or incremental change carried to subscribers (SSE on the wire). */
export type ObservationEvent = export type ObservationEvent =
| { type: "snapshot"; sessions: SessionObservation[]; available: boolean }
| { type: "upsert" | "remove" | "reset"; session?: SessionObservation } | { type: "upsert" | "remove" | "reset"; session?: SessionObservation }
| { type: "availability"; available: boolean }; | { type: "availability"; available: boolean };
@@ -89,6 +90,7 @@ export class ObservationService {
private readonly scratchNamespace = randomUUID(); private readonly scratchNamespace = randomUUID();
private readonly observations = new Map<string, SessionObservation>(); private readonly observations = new Map<string, SessionObservation>();
private readonly listeners = new Set<(event: ObservationEvent) => void>(); private readonly listeners = new Set<(event: ObservationEvent) => void>();
private thumbnailViewers = 0;
private readonly captureTimers = new Map<string, unknown>(); private readonly captureTimers = new Map<string, unknown>();
private readonly captureInFlight = new Set<string>(); private readonly captureInFlight = new Set<string>();
/** Captures intentionally cancelled to make way for foreground work. */ /** Captures intentionally cancelled to make way for foreground work. */
@@ -125,7 +127,7 @@ export class ObservationService {
return this.deps.scheduler ?? DEFAULT_SCHEDULER; return this.deps.scheduler ?? DEFAULT_SCHEDULER;
} }
/** All current entries (client initial/resync snapshot). */ /** One-time state read; use subscribe for an ordered snapshot and subsequent changes. */
getState(): SessionObservation[] { getState(): SessionObservation[] {
return [...this.observations.values()].map((entry) => ({ ...entry })); return [...this.observations.values()].map((entry) => ({ ...entry }));
} }
@@ -141,10 +143,42 @@ export class ObservationService {
this.emit({ type: "availability", available }); this.emit({ type: "availability", available });
} }
/** Subscribe to incremental changes; returns an unsubscribe function. */ /**
subscribe(listener: (event: ObservationEvent) => void): () => void { * Subscribe to an ordered initial snapshot and subsequent changes. On an
this.listeners.add(listener); * active service, listener receives the snapshot synchronously before this
return () => this.listeners.delete(listener); * method returns. Subscribing after disposal is a no-op.
*
* `thumbnails` defaults to true for compatibility: this subscription counts
* as a screenshot viewer for the service's owned sessions. Pass false for
* state-only observation. The first viewer starts capture scheduling.
*
* @returns An idempotent unsubscribe function releasing this subscription's
* screenshot demand. When the last viewer leaves, scheduled/queued captures
* are cancelled/skipped; an already running capture may finish.
*/
subscribe(
listener: (event: ObservationEvent) => void,
{ thumbnails = true }: { thumbnails?: boolean } = {},
): () => void {
if (this.disposed) return () => {};
// Each subscription owns its lease, even if a caller reuses a callback.
const receive = (event: ObservationEvent) => listener(event);
this.listeners.add(receive);
try {
listener({ type: "snapshot", sessions: this.getState(), available: this.available });
} catch (error) {
this.listeners.delete(receive);
throw error;
}
if (thumbnails && ++this.thumbnailViewers === 1) {
for (const sessionId of this.observations.keys()) this.scheduleCapture(sessionId, 0);
}
return () => {
if (!this.listeners.delete(receive)) return;
if (thumbnails && --this.thumbnailViewers === 0) {
for (const sessionId of this.captureTimers.keys()) this.cancelCapture(sessionId);
}
};
} }
private emit(event: ObservationEvent): void { private emit(event: ObservationEvent): void {
@@ -355,6 +389,7 @@ export class ObservationService {
this.observations.clear(); this.observations.clear();
this.emit({ type: "reset" }); this.emit({ type: "reset" });
this.listeners.clear(); this.listeners.clear();
this.thumbnailViewers = 0;
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -369,12 +404,21 @@ export class ObservationService {
} }
} }
private canCapture(sessionId: string): boolean {
const entry = this.observations.get(sessionId);
return (
this.deps.options.enabled &&
!this.disposed &&
this.thumbnailViewers > 0 &&
!this.foregroundDepth.has(sessionId) &&
entry !== undefined &&
entry.dead !== true
);
}
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */ /** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
private scheduleCapture(sessionId: string, delayMs?: number): void { private scheduleCapture(sessionId: string, delayMs?: number): void {
if (!this.deps.options.enabled || this.disposed) return; if (!this.canCapture(sessionId)) return;
if (this.foregroundDepth.has(sessionId)) return;
const entry = this.observations.get(sessionId);
if (entry === undefined || entry.dead === true) return;
this.cancelCapture(sessionId); this.cancelCapture(sessionId);
const now = this.scheduler.now(); const now = this.scheduler.now();
const lastSeen = this.lastActivity.get(sessionId) ?? 0; const lastSeen = this.lastActivity.get(sessionId) ?? 0;
@@ -399,9 +443,7 @@ export class ObservationService {
* Failures keep the previous frame and back off silently. * Failures keep the previous frame and back off silently.
*/ */
private async capture(sessionId: string): Promise<void> { private async capture(sessionId: string): Promise<void> {
if (this.disposed) return; if (!this.canCapture(sessionId)) return;
const current = this.observations.get(sessionId);
if (current === undefined || current.dead === true) return;
if (this.captureInFlight.has(sessionId)) return; if (this.captureInFlight.has(sessionId)) return;
this.captureInFlight.add(sessionId); this.captureInFlight.add(sessionId);
// Stable within this service, isolated from captures owned by other instances. // Stable within this service, isolated from captures owned by other instances.
@@ -410,8 +452,8 @@ export class ObservationService {
let writtenPath = outPath; let writtenPath = outPath;
try { try {
const result = await this.deps.queue.run(sessionId, () => { const result = await this.deps.queue.run(sessionId, () => {
// A foreground lease may have arrived while this capture was queued. // The last viewer may have left, or foreground work arrived, while queued.
if (this.foregroundDepth.has(sessionId)) return Promise.resolve(undefined); if (!this.canCapture(sessionId)) return Promise.resolve(undefined);
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], { return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
timeoutMs: 15_000, timeoutMs: 15_000,
tag: `observation:${sessionId}`, tag: `observation:${sessionId}`,
@@ -445,6 +487,7 @@ export class ObservationService {
this.setAvailable(true); this.setAvailable(true);
this.publishFrame(sessionId, entry, data); this.publishFrame(sessionId, entry, data);
} catch { } catch {
if (this.disposed || !this.observations.has(sessionId)) return;
// Foreground preemption is healthy scheduling, not browser/daemon // Foreground preemption is healthy scheduling, not browser/daemon
// unavailability; do not let normal tool traffic trip capture backoff. // unavailability; do not let normal tool traffic trip capture backoff.
if (!this.capturePreempted.delete(sessionId)) { if (!this.capturePreempted.delete(sessionId)) {
@@ -636,7 +636,9 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
session: SESSION_PARAM, session: SESSION_PARAM,
noClear: { noClear: {
type: "boolean", type: "boolean",
description: "Skip the default wipe-the-field-first pass (append instead).", description:
"Append to the end of the existing value instead of clearing it. " +
"The tool moves the caret automatically; no prior click or selection is needed.",
}, },
}, },
output: { output: {
@@ -19,10 +19,12 @@ interface Harness {
emitRaw: (event: unknown) => void; emitRaw: (event: unknown) => void;
fetches: { url: string; init?: { body?: string } }[]; fetches: { url: string; init?: { body?: string } }[];
loadImage: ReturnType<typeof vi.fn>; loadImage: ReturnType<typeof vi.fn>;
eventUrls: string[];
} }
function makeHarness(initial: SessionObservation[]): Harness { function makeHarness(initial: SessionObservation[]): Harness {
const fetches: Harness["fetches"] = []; const fetches: Harness["fetches"] = [];
const eventUrls: string[] = [];
let es: EventSourceLike | undefined; let es: EventSourceLike | undefined;
let current = initial; let current = initial;
const loadImage = vi.fn(async (id: string) => `blob:${id}`); const loadImage = vi.fn(async (id: string) => `blob:${id}`);
@@ -34,9 +36,16 @@ function makeHarness(initial: SessionObservation[]): Harness {
} }
return { ok: true, json: async () => ({ interrupted: true }) }; return { ok: true, json: async () => ({ interrupted: true }) };
}, },
eventSourceFactory: () => { eventSourceFactory: (url) => {
es = { onmessage: null, close: vi.fn() }; eventUrls.push(url);
return es; const connection = { onmessage: null, close: vi.fn() } as EventSourceLike;
es = connection;
queueMicrotask(() =>
connection.onmessage?.({
data: JSON.stringify({ type: "snapshot", sessions: current, available: true }),
}),
);
return connection;
}, },
loadImage, loadImage,
}); });
@@ -58,10 +67,120 @@ function makeHarness(initial: SessionObservation[]): Harness {
}, },
fetches, fetches,
loadImage, loadImage,
eventUrls,
}; };
} }
afterEach(cleanup); afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("visible thumbnail demand", () => {
function intersections() {
const observers: Array<{
notify: (visible: boolean) => void;
disconnect: ReturnType<typeof vi.fn>;
}> = [];
vi.stubGlobal(
"IntersectionObserver",
class {
private target?: Element;
disconnect = vi.fn();
constructor(private callback: IntersectionObserverCallback) {
observers.push(this);
}
observe(target: Element) {
this.target = target;
}
notify(visible: boolean) {
this.callback(
[{ target: this.target, isIntersecting: visible } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
},
);
return observers;
}
it("pauses screenshots on collapse while keeping the metadata feed", async () => {
const observers = intersections();
const h = makeHarness([BUSY]);
const { unmount } = render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
fireEvent.click(screen.getByRole("button", { name: "Collapse" }));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
expect(h.es().close).not.toHaveBeenCalled();
expect(observers[0].disconnect).toHaveBeenCalledOnce();
// A callback queued before disconnect must not reclaim screenshot demand.
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
fireEvent.click(screen.getByRole("button", { name: /Expand browser observation/ }));
act(() => observers[1].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
unmount();
expect(h.es().close).toHaveBeenCalledOnce();
});
it("ignores an initial visibility notification delivered after collapse", async () => {
const observers = intersections();
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
fireEvent.click(screen.getByRole("button", { name: "Collapse" }));
act(() => observers[0].notify(true));
expect(h.eventUrls).toEqual(["/bsk-observation/events?thumbnails=0"]);
});
it("pauses for a hidden document or hidden panel and resumes when visible", async () => {
const observers = intersections();
let visibility: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
act(() => {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=0");
act(() => {
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
act(() => observers[0].notify(false));
expect(h.eventUrls.at(-1)).toContain("thumbnails=0");
});
it("keeps a visible PiP watching when the main document is hidden", async () => {
// The detached PiP test document has no window/IntersectionObserver.
vi.stubGlobal("IntersectionObserver", undefined);
let visibility: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
const pipDoc = document.implementation.createHTMLDocument("pip");
Object.defineProperty(pipDoc, "visibilityState", { value: "visible" });
(window as unknown as Record<string, unknown>).documentPictureInPicture = {
requestWindow: async () => ({ document: pipDoc, addEventListener: () => {}, close: vi.fn() }),
};
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
fireEvent.click(await screen.findByRole("button", { name: /Pop out/ }));
await waitFor(() => expect(pipDoc.body.textContent).toContain("s1"));
act(() => {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
});
});
describe("ObservationOverlay", () => { describe("ObservationOverlay", () => {
beforeEach(() => { beforeEach(() => {
@@ -92,6 +211,16 @@ describe("ObservationOverlay", () => {
await screen.findByRole("img", { name: "session s1 view" }); await screen.findByRole("img", { name: "session s1 view" });
}); });
it("offers a retry after a thumbnail fails without requiring a new frame id", async () => {
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-9" }]);
h.loadImage.mockRejectedValueOnce(new Error("temporary failure"));
render(<ObservationOverlay store={h.store} />);
fireEvent.click(await screen.findByRole("button", { name: "Retry thumbnail" }));
await screen.findByRole("img", { name: "session s1 view" });
expect(h.loadImage).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("button", { name: "Retry thumbnail" })).toBeNull();
});
it("keeps the current frame on stage while the next thumbnail loads", async () => { it("keeps the current frame on stage while the next thumbnail loads", async () => {
let resolveNext: ((url: string) => void) | undefined; let resolveNext: ((url: string) => void) | undefined;
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]); const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]);
@@ -53,8 +53,14 @@ function makeHarness(initial: SessionObservation[]): Harness {
return { ok: true, json: async () => ({ interrupted: true }) }; return { ok: true, json: async () => ({ interrupted: true }) };
}, },
eventSourceFactory: () => { eventSourceFactory: () => {
es = { onmessage: null, close: vi.fn() }; const connection = { onmessage: null, close: vi.fn() } as EventSourceLike;
return es; es = connection;
queueMicrotask(() =>
connection.onmessage?.({
data: JSON.stringify({ type: "snapshot", sessions: current, available: true }),
}),
);
return connection;
}, },
loadImage: async (id: string) => `blob:${id}`, loadImage: async (id: string) => `blob:${id}`,
}); });
@@ -1,12 +1,19 @@
// ObservationClientStore: initial fetch + SSE application, thumbnail loading // ObservationClientStore: ordered SSE snapshots/increments, thumbnail loading
// lifecycle, interrupt wire shape. All I/O faked. // lifecycle, interrupt wire shape. All I/O faked.
import { describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store"; import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
import type { ObservationEvent, SessionObservation } from "../../src/observation"; import type { ObservationEvent, SessionObservation } from "../../src/observation";
const OBS_IDLE: SessionObservation = { sessionId: "s1", action: "idle", since: 1000 }; const OBS_IDLE: SessionObservation = { sessionId: "s1", action: "idle", since: 1000 };
const OBS_BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: 2000 }; const OBS_BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: 2000 };
const stores: ObservationClientStore[] = [];
afterEach(() => {
for (const store of stores.splice(0)) store.stop();
vi.restoreAllMocks();
vi.useRealTimers();
});
function harness(state: SessionObservation[] = []) { function harness(state: SessionObservation[] = []) {
const fetches: { const fetches: {
@@ -14,6 +21,7 @@ function harness(state: SessionObservation[] = []) {
init?: { method?: string; body?: string; headers?: Record<string, string> }; init?: { method?: string; body?: string; headers?: Record<string, string> };
}[] = []; }[] = [];
const esInstances: EventSourceLike[] = []; const esInstances: EventSourceLike[] = [];
const eventUrls: string[] = [];
const fetchFn = vi.fn( const fetchFn = vi.fn(
async ( async (
url: string, url: string,
@@ -29,11 +37,14 @@ function harness(state: SessionObservation[] = []) {
const eventSourceFactory = (url: string) => { const eventSourceFactory = (url: string) => {
const es: EventSourceLike = { onmessage: null, close: vi.fn() }; const es: EventSourceLike = { onmessage: null, close: vi.fn() };
esInstances.push(es); esInstances.push(es);
eventUrls.push(url);
queueMicrotask(() => emit(es, { type: "snapshot", sessions: state, available: true }));
return es; return es;
}; };
const loadImage = vi.fn(async (id: string) => `blob:url-${id}`); const loadImage = vi.fn(async (id: string) => `blob:url-${id}`);
const store = new ObservationClientStore({ fetchFn, eventSourceFactory, loadImage }); const store = new ObservationClientStore({ fetchFn, eventSourceFactory, loadImage });
return { store, fetches, esInstances, loadImage }; stores.push(store);
return { store, fetches, esInstances, eventUrls, loadImage };
} }
function emit(es: EventSourceLike, event: ObservationEvent): void { function emit(es: EventSourceLike, event: ObservationEvent): void {
@@ -73,7 +84,9 @@ describe("ObservationClientStore", () => {
}); });
it("loads each thumbnail once and reports ready", async () => { it("loads each thumbnail once and reports ready", async () => {
const { store, loadImage } = harness(); const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "att-1" }]);
store.start();
await Promise.resolve();
store.ensureThumbnail("att-1"); store.ensureThumbnail("att-1");
store.ensureThumbnail("att-1"); store.ensureThumbnail("att-1");
expect(loadImage).toHaveBeenCalledTimes(1); expect(loadImage).toHaveBeenCalledTimes(1);
@@ -86,7 +99,9 @@ describe("ObservationClientStore", () => {
}); });
it("marks failed thumbnail loads as error", async () => { it("marks failed thumbnail loads as error", async () => {
const { store, loadImage } = harness(); const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "att-bad" }]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValueOnce(new Error("nope")); loadImage.mockRejectedValueOnce(new Error("nope"));
store.ensureThumbnail("att-bad"); store.ensureThumbnail("att-bad");
await vi.waitFor(() => expect(store.getSnapshot().thumbnails["att-bad"].status).toBe("error")); await vi.waitFor(() => expect(store.getSnapshot().thumbnails["att-bad"].status).toBe("error"));
@@ -124,9 +139,7 @@ describe("ObservationClientStore", () => {
describe("thumbnail blob URL lifecycle", () => { describe("thumbnail blob URL lifecycle", () => {
function withRevokeSpy() { function withRevokeSpy() {
const revoke = vi.fn(); return vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
(URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revoke;
return revoke;
} }
it("holds the last ready frame until the replacement decodes", async () => { it("holds the last ready frame until the replacement decodes", async () => {
@@ -231,16 +244,216 @@ describe("thumbnail blob URL lifecycle", () => {
}); });
}); });
describe("SSE (re)open resync", () => { describe("SSE snapshot ordering", () => {
it("refetches the full state when the stream (re)opens", async () => { it("ignores events from a stopped connection after a restart", async () => {
const { store, esInstances } = harness([OBS_IDLE]);
store.start();
await Promise.resolve();
const old = esInstances[0];
store.stop();
emit(old, { type: "upsert", session: OBS_BUSY });
expect(store.getSnapshot().sessions).toEqual([]);
store.start();
await Promise.resolve();
emit(esInstances[1], { type: "upsert", session: OBS_BUSY });
emit(old, { type: "snapshot", sessions: [], available: false });
emit(old, { type: "remove", session: OBS_BUSY });
expect(store.getSnapshot()).toMatchObject({ sessions: [OBS_BUSY], available: true });
});
it("does not apply a queued initial snapshot after stop", async () => {
const { store } = harness([OBS_BUSY]);
store.start();
store.stop();
await Promise.resolve();
expect(store.getSnapshot()).toMatchObject({ sessions: [], thumbnails: {}, subscribed: false });
});
it("replaces state on reconnect before applying subsequent events, without a competing fetch", async () => {
const { store, fetches, esInstances } = harness([OBS_IDLE]); const { store, fetches, esInstances } = harness([OBS_IDLE]);
store.start(); store.start();
await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1)); await vi.waitFor(() => expect(store.getSnapshot().sessions).toHaveLength(1));
const before = fetches.filter((f) => f.url === "/bsk-observation/state").length; emit(esInstances[0], { type: "snapshot", sessions: [], available: false });
esInstances[0].onopen?.(); expect(store.getSnapshot()).toMatchObject({ sessions: [], available: false });
await vi.waitFor(() => emit(esInstances[0], { type: "upsert", session: OBS_BUSY });
expect(fetches.filter((f) => f.url === "/bsk-observation/state").length).toBe(before + 1), await Promise.resolve();
); expect(store.getSnapshot().sessions).toEqual([OBS_BUSY]);
expect(fetches).toEqual([]);
store.stop(); store.stop();
}); });
}); });
describe("thumbnail recovery and stale loads", () => {
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
it("retries a transient failure without needing a different frame id", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValueOnce(new Error("temporary"));
store.ensureThumbnail("a1");
await Promise.resolve();
expect(store.getSnapshot().thumbnails.a1.status).toBe("error");
await vi.advanceTimersByTimeAsync(1000);
expect(loadImage).toHaveBeenCalledTimes(2);
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "ready", url: "blob:url-a1" });
expect(vi.getTimerCount()).toBe(0);
});
it("bounds automatic retries and allows an explicit recovery", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValue(new Error("offline"));
store.ensureThumbnail("a1");
await vi.advanceTimersByTimeAsync(60_000);
expect(loadImage).toHaveBeenCalledTimes(3);
expect(vi.getTimerCount()).toBe(0);
store.ensureThumbnail("a1");
expect(loadImage).toHaveBeenCalledTimes(3);
loadImage.mockResolvedValue("blob:recovered");
store.retryThumbnail("a1");
await Promise.resolve();
expect(store.getSnapshot().displayFrames.s1.url).toBe("blob:recovered");
});
it("cancels backoff when a reconnect retries the current frame", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const session = { ...OBS_IDLE, thumbnailAttachmentId: "a1" };
const { store, esInstances, loadImage } = harness([session]);
store.start();
await Promise.resolve();
loadImage.mockRejectedValueOnce(new Error("offline"));
store.ensureThumbnail("a1");
await Promise.resolve();
emit(esInstances[0], { type: "snapshot", sessions: [session], available: true });
await Promise.resolve();
expect(store.getSnapshot().thumbnails.a1.status).toBe("ready");
await vi.advanceTimersByTimeAsync(60_000);
expect(loadImage).toHaveBeenCalledTimes(2);
});
it.each([
"remove",
"reset",
"stop",
"missing-thumbnail",
"snapshot-removal",
])("cleans frames and cancels retries on %s", async (action) => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const { store, esInstances, loadImage } = harness([
{ ...OBS_IDLE, thumbnailAttachmentId: "a1" },
]);
store.start();
await Promise.resolve();
store.ensureThumbnail("a1");
await Promise.resolve();
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" } });
loadImage.mockRejectedValueOnce(new Error("no frame"));
store.ensureThumbnail("a2");
await Promise.resolve();
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "error", url: "blob:url-a1" });
if (action === "stop") store.stop();
else if (action === "remove") emit(esInstances[0], { type: "remove", session: OBS_IDLE });
else if (action === "reset") emit(esInstances[0], { type: "reset" });
else if (action === "missing-thumbnail")
emit(esInstances[0], { type: "upsert", session: OBS_IDLE });
else emit(esInstances[0], { type: "snapshot", sessions: [], available: true });
expect(store.getSnapshot().thumbnails).toEqual({});
expect(store.getSnapshot().displayFrames).toEqual({});
expect(revoke).toHaveBeenCalledExactlyOnceWith("blob:url-a1");
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
expect(loadImage).toHaveBeenCalledTimes(2);
});
it.each([
"resolve",
"reject",
])("ignores a stale load's %s even when the same id is loaded again", async (outcome) => {
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const { store, esInstances, loadImage } = harness([
{ ...OBS_IDLE, thumbnailAttachmentId: "a1" },
]);
const old = deferred<string>();
loadImage.mockReturnValueOnce(old.promise);
store.start();
await Promise.resolve();
store.ensureThumbnail("a1");
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a2" } });
emit(esInstances[0], { type: "upsert", session: { ...OBS_IDLE, thumbnailAttachmentId: "a1" } });
store.ensureThumbnail("a1");
await Promise.resolve();
if (outcome === "resolve") old.resolve("blob:stale");
else old.reject(new Error("stale error"));
await Promise.resolve();
expect(store.getSnapshot().displayFrames.s1).toEqual({ status: "ready", url: "blob:url-a1" });
expect(revoke).not.toHaveBeenCalledWith("blob:url-a1");
if (outcome === "resolve") expect(revoke).toHaveBeenCalledWith("blob:stale");
});
it.each(["resolve", "reject"])("ignores an in-flight load's %s after stop", async (outcome) => {
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const { store, loadImage } = harness([{ ...OBS_IDLE, thumbnailAttachmentId: "a1" }]);
const pending = deferred<string>();
loadImage.mockReturnValueOnce(pending.promise);
store.start();
await Promise.resolve();
store.ensureThumbnail("a1");
store.stop();
if (outcome === "resolve") pending.resolve("blob:late");
else pending.reject(new Error("late error"));
await Promise.resolve();
expect(store.getSnapshot()).toMatchObject({ sessions: [], thumbnails: {}, displayFrames: {} });
if (outcome === "resolve") expect(revoke).toHaveBeenCalledWith("blob:late");
});
});
describe("thumbnail viewer leases", () => {
it("keeps metadata subscribed while the last screenshot viewer leaves", async () => {
const { store, eventUrls, esInstances } = harness([OBS_IDLE]);
store.acquire();
await Promise.resolve();
expect(eventUrls).toEqual(["/bsk-observation/events?thumbnails=0"]);
const first = store.watchThumbnails();
expect(eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
const second = store.watchThumbnails();
expect(eventUrls).toHaveLength(2);
first();
first();
expect(eventUrls).toHaveLength(2);
second();
expect(eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
expect(store.getSnapshot().subscribed).toBe(true);
store.release();
expect(esInstances.at(-1)?.close).toHaveBeenCalledOnce();
expect(store.getSnapshot().subscribed).toBe(false);
});
it("ignores delayed snapshots from a replaced stream without clearing the displayed state", async () => {
const { store, esInstances } = harness([OBS_IDLE]);
store.start();
await Promise.resolve();
emit(esInstances[0], { type: "upsert", session: OBS_BUSY });
const release = store.watchThumbnails();
expect(store.getSnapshot().sessions).toEqual([OBS_BUSY]);
await Promise.resolve();
emit(esInstances[1], { type: "upsert", session: OBS_BUSY });
emit(esInstances[0], { type: "snapshot", sessions: [], available: false });
expect(store.getSnapshot()).toMatchObject({ sessions: [OBS_BUSY], available: true });
store.stop();
release();
expect(esInstances).toHaveLength(2);
});
});
@@ -6,7 +6,7 @@ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { basename, dirname } from "node:path"; import { basename, dirname } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
actionForLabel, actionForLabel,
type ObservationEvent, type ObservationEvent,
@@ -138,6 +138,8 @@ function setup(opts: {
runner?: FakeRunner; runner?: FakeRunner;
registry?: SessionRegistry; registry?: SessionRegistry;
scheduler?: ReturnType<typeof fakeScheduler>; scheduler?: ReturnType<typeof fakeScheduler>;
thumbnails?: boolean;
queue?: KeyedExecutor;
}) { }) {
const registry = opts.registry ?? new SessionRegistry(5); const registry = opts.registry ?? new SessionRegistry(5);
const runner = opts.runner ?? fakeRunner(); const runner = opts.runner ?? fakeRunner();
@@ -146,13 +148,18 @@ function setup(opts: {
ctx: fakeCtx(), ctx: fakeCtx(),
runner, runner,
registry, registry,
queue: new KeyedExecutor(), queue: opts.queue ?? new KeyedExecutor(),
options: OPTIONS, options: OPTIONS,
scheduler, scheduler,
}); });
const events: ObservationEvent[] = []; const events: ObservationEvent[] = [];
service.subscribe((event) => events.push(event)); const unsubscribe = service.subscribe(
return { service, registry, runner, scheduler, events }; (event) => {
if (event.type !== "snapshot") events.push(event);
},
{ thumbnails: opts.thumbnails ?? true },
);
return { service, registry, runner, scheduler, events, unsubscribe };
} }
/** Drive the registry through a real start so the session is owned. */ /** Drive the registry through a real start so the session is owned. */
@@ -224,6 +231,111 @@ describe("state machine", () => {
}); });
describe("thumbnail cadence", () => { describe("thumbnail cadence", () => {
it("keeps metadata current without screenshots and resumes when a viewer arrives", async () => {
const { service, scheduler, runner, events } = setup({ thumbnails: false });
service.addSession("s1");
service.beginAction("s1", "clicking");
service.endAction("s1");
const releaseForeground = service.acquireForeground("s1");
releaseForeground();
expect(service.getState()[0].action).toBe("idle");
expect(events).toHaveLength(3);
expect(scheduler.pending()).toEqual([]);
expect(runner.calls).toEqual([]);
const snapshots: ObservationEvent[] = [];
const leave = service.subscribe((event) => snapshots.push(event));
expect(snapshots[0]).toEqual({
type: "snapshot",
sessions: service.getState(),
available: true,
});
expect(scheduler.pending()).toEqual([0]);
scheduler.runNext();
await waitFor(() => scheduler.pending().length === 1);
expect(runner.calls).toHaveLength(1);
leave();
leave();
expect(scheduler.pending()).toEqual([]);
service.endAction("s1");
expect(scheduler.pending()).toEqual([]);
const resume = service.subscribe(() => {});
expect(scheduler.pending()).toEqual([0]);
resume();
service.dispose();
});
it("does not capture in a headless service with no subscribers", () => {
const { service, scheduler, unsubscribe } = setup({});
unsubscribe();
service.addSession("s1");
service.endAction("s1");
expect(scheduler.pending()).toEqual([]);
expect(service.getState()).toHaveLength(1);
service.dispose();
});
it("keeps capturing until the last viewer leaves, including reused callbacks", () => {
const { service, scheduler } = setup({ thumbnails: false });
service.addSession("s1");
const listener = () => {};
const first = service.subscribe(listener);
const second = service.subscribe(listener);
expect(scheduler.pending()).toEqual([0]);
first();
first();
expect(scheduler.pending()).toEqual([0]);
second();
expect(scheduler.pending()).toEqual([]);
service.dispose();
});
it("skips a queued screenshot if its last viewer leaves before execution", async () => {
const queue = new KeyedExecutor();
let unblock!: () => void;
const gate = new Promise<void>((resolve) => {
unblock = resolve;
});
const busy = queue.run("s1", () => gate);
const { service, scheduler, runner, unsubscribe } = setup({ queue });
service.addSession("s1");
scheduler.runNext();
unsubscribe();
unblock();
await busy;
await queue.run("s1", async () => {});
expect(runner.calls).toEqual([]);
expect(scheduler.pending()).toEqual([]);
service.dispose();
});
it("lets an already running capture finish without restarting the loop after the last viewer leaves", async () => {
const runner = fakeRunner();
const run = runner.run.bind(runner);
let finish!: () => void;
let started = false;
runner.run = async (args, options) => {
started = true;
await new Promise<void>((resolve) => {
finish = resolve;
});
return run(args, options);
};
const { service, scheduler, unsubscribe } = setup({ runner });
service.addSession("s1");
scheduler.runNext();
await waitFor(() => started);
unsubscribe();
finish();
await waitFor(() => service.getState()[0]?.thumbnailAttachmentId !== undefined);
const args = runner.calls[0].args;
await waitFor(() => !existsSync(args[args.indexOf("--out") + 1]));
expect(scheduler.pending()).toEqual([]);
const leave = service.subscribe(() => {});
expect(scheduler.pending()).toEqual([0]);
leave();
service.dispose();
});
it("gives foreground work priority and resumes with one fresh capture", async () => { it("gives foreground work priority and resumes with one fresh capture", async () => {
const scheduler = fakeScheduler(); const scheduler = fakeScheduler();
let finishCapture: ((result: BskRunResult) => void) | undefined; let finishCapture: ((result: BskRunResult) => void) | undefined;
@@ -540,6 +652,12 @@ describe("HTTP/SSE interface", () => {
const eventsRes = fakeRes(); const eventsRes = fakeRes();
await eventsRoute?.handler(fakeReq(), eventsRes.res as never); await eventsRoute?.handler(fakeReq(), eventsRes.res as never);
service.beginAction("s1", "clicking"); service.beginAction("s1", "clicking");
const streamed = eventsRes.chunks.map((chunk) => JSON.parse(chunk.slice(6)));
expect(streamed.map((event) => event.type)).toEqual(["snapshot", "upsert"]);
expect(streamed[0]).toMatchObject({
sessions: [{ sessionId: "s1", action: "idle" }],
available: true,
});
expect(eventsRes.res.body()).toContain('"action":"clicking"'); expect(eventsRes.res.body()).toContain('"action":"clicking"');
eventsRes.res.close(); eventsRes.res.close();
@@ -566,6 +684,47 @@ describe("HTTP/SSE interface", () => {
service.dispose(); service.dispose();
}); });
it("state-only SSE does not capture, and route disposal releases every active viewer", async () => {
const { service, scheduler } = setup({ thumbnails: false });
service.addSession("s1");
const { routes, webServer } = routeHarness();
const dispose = registerObservationRoutes({ get: () => webServer } as never, service);
const route = routes.get("/bsk-observation/events")!;
const metadata = fakeRes();
await route.handler(fakeReq({ url: "/bsk-observation/events?thumbnails=0" }), metadata.res);
expect(scheduler.pending()).toEqual([]);
const viewing = fakeRes();
await route.handler(fakeReq({ url: "/bsk-observation/events?thumbnails=1" }), viewing.res);
expect(scheduler.pending()).toEqual([0]);
const endMetadata = vi.spyOn(metadata.res, "end");
const endViewing = vi.spyOn(viewing.res, "end");
dispose();
expect(endMetadata).toHaveBeenCalledOnce();
expect(endViewing).toHaveBeenCalledOnce();
expect(scheduler.pending()).toEqual([]);
viewing.res.close();
metadata.res.close();
expect(endViewing).toHaveBeenCalledOnce();
service.dispose();
});
it("releases a viewer when writing its initial snapshot fails", async () => {
const { service, scheduler } = setup({ thumbnails: false });
service.addSession("s1");
const { routes, webServer } = routeHarness();
const dispose = registerObservationRoutes({ get: () => webServer } as never, service);
const response = fakeRes();
vi.spyOn(response.res, "write").mockImplementation(() => {
throw new Error("closed socket");
});
const end = vi.spyOn(response.res, "end");
await routes.get("/bsk-observation/events")!.handler(fakeReq(), response.res);
expect(end).toHaveBeenCalledOnce();
expect(scheduler.pending()).toEqual([]);
dispose();
service.dispose();
});
it("refuses a stop without a sessionId", async () => { it("refuses a stop without a sessionId", async () => {
const registry = new SessionRegistry(5); const registry = new SessionRegistry(5);
own(registry, "s1"); own(registry, "s1");
@@ -124,6 +124,28 @@ describe("parseBskJson", () => {
expect(() => parseBskJson({ ...base, code: 1, stderr: "boom" }, "x")).toThrow(/boom/); expect(() => parseBskJson({ ...base, code: 1, stderr: "boom" }, "x")).toThrow(/boom/);
}); });
it("surfaces fill recovery guidance to the model without retrying", async () => {
const hint =
"observe the field before retrying; the page may have formatted the value. Continue if the visible result satisfies the user's intent";
const reply = {
...base,
code: 3,
stdout: JSON.stringify({
code: "cdp_failed",
message: "fill could not verify the expected value",
data: { reason: "fill_value_mismatch" },
hint,
}),
};
let calls = 0;
const result = await runWithSessionBusyRetry(async () => {
calls++;
return reply;
});
expect(calls).toBe(1);
expect(() => parseBskJson(result, "fill")).toThrow(hint);
});
it("reports killed-by-interrupt children (null exit code) as interrupted", () => { it("reports killed-by-interrupt children (null exit code) as interrupted", () => {
expect(() => parseBskJson({ ...base, code: null }, "navigate")).toThrow(/interrupted/); expect(() => parseBskJson({ ...base, code: null }, "navigate")).toThrow(/interrupted/);
}); });
@@ -25,7 +25,6 @@ const CLIENT_EXTERNALS: readonly string[] = [
"@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-ui-attachment", "@deepseek-ai/dsh-client-ui-attachment",
"@deepseek-ai/dsh-client-schema-form", "@deepseek-ai/dsh-client-schema-form",
"@deepseek-ai/dsh-client-runtime/client",
]; ];
/** Wire/type layers with no shared runtime identity: safe to inline. */ /** Wire/type layers with no shared runtime identity: safe to inline. */
+4
View File
@@ -172,6 +172,10 @@ intended. Read `bsk upload --help` and `bsk download --help` for all flags and e
- Stale ref: observe again and retry the intended action once. - Stale ref: observe again and retry the intended action once.
- Unknown tab or session: list current tabs/sessions; never guess identifiers. - Unknown tab or session: list current tabs/sessions; never guess identifiers.
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful. - Timeout: inspect current page state before deciding whether one longer purposeful wait is useful.
- Fill result unconfirmed (`fill_value_mismatch`): observe the field first; the page may have
formatted the value. Continue if the visible result satisfies the user's intent. Otherwise correct
the remaining difference; do not blindly repeat fill or immediately request human help. For other
fill errors, follow the returned hint and inspect current state before retrying.
- Unsupported command: continue with available capabilities; suggest updating only when the missing - Unsupported command: continue with available capabilities; suggest updating only when the missing
command is necessary. command is necessary.
- Unrecoverable failure: report the blocker and stop the session in a finally-style path. - Unrecoverable failure: report the blocker and stop the session in a finally-style path.