@vellumai/assistant 0.12.2-staging.2 → 0.12.2-staging.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/openapi.yaml +17 -5
  3. package/package.json +1 -1
  4. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  5. package/src/__tests__/config-schema.test.ts +1 -1
  6. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  7. package/src/__tests__/conversation-error.test.ts +4 -1
  8. package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
  9. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  10. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  11. package/src/__tests__/host-cu-proxy.test.ts +116 -18
  12. package/src/__tests__/list-all-apps.test.ts +40 -8
  13. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  14. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  15. package/src/__tests__/mcp-health-check.test.ts +32 -32
  16. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  17. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  18. package/src/__tests__/skills.test.ts +10 -0
  19. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  20. package/src/apps/app-store.ts +6 -5
  21. package/src/browser/operations.ts +2 -1
  22. package/src/browser/types.ts +7 -0
  23. package/src/browser/virtual-desktop-target.ts +34 -0
  24. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  25. package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
  26. package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
  27. package/src/calls/progress-narration.ts +15 -2
  28. package/src/calls/voice-control-protocol.ts +34 -4
  29. package/src/calls/voice-session-bridge.ts +23 -0
  30. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  31. package/src/cli/commands/browser.help.ts +43 -0
  32. package/src/cli/commands/browser.ts +52 -8
  33. package/src/cli/commands/plugins.ts +16 -5
  34. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  35. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  36. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  37. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  38. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  39. package/src/cli/lib/bundled-marketplace.json +13 -0
  40. package/src/cli/lib/install-from-github.ts +17 -18
  41. package/src/cli/lib/install-from-platform.ts +14 -9
  42. package/src/cli/lib/list-installed-plugins.ts +21 -35
  43. package/src/cli/lib/plugin-details.ts +21 -8
  44. package/src/cli/lib/uninstall-plugin.ts +59 -1
  45. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  46. package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
  47. package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
  48. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  49. package/src/config/feature-flag-registry.json +2 -2
  50. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  51. package/src/config/schemas/mcp.ts +9 -7
  52. package/src/config/schemas/monitoring.ts +9 -0
  53. package/src/config/schemas/voice.ts +2 -2
  54. package/src/config/skills.ts +13 -21
  55. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  56. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  57. package/src/daemon/conversation-client-surface.ts +26 -0
  58. package/src/daemon/conversation-runtime-assembly.ts +1 -1
  59. package/src/daemon/conversation-tool-setup.ts +31 -29
  60. package/src/daemon/host-cu-proxy.ts +51 -26
  61. package/src/daemon/host-proxy-preactivation.ts +2 -0
  62. package/src/daemon/mcp-reload-service.ts +7 -0
  63. package/src/daemon/message-types/sync.ts +1 -0
  64. package/src/daemon/providers-setup.ts +4 -0
  65. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  66. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  67. package/src/desktop/desktop-automation-lease.ts +270 -0
  68. package/src/desktop/desktop-browser-client.test.ts +413 -0
  69. package/src/desktop/desktop-browser-client.ts +430 -0
  70. package/src/desktop/desktop-browser-cursor.ts +40 -0
  71. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  72. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  73. package/src/desktop/desktop-browser-operations.ts +130 -0
  74. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  75. package/src/desktop/desktop-chrome-session.ts +58 -8
  76. package/src/desktop/desktop-dependencies.test.ts +29 -0
  77. package/src/desktop/desktop-dependencies.ts +26 -5
  78. package/src/desktop/desktop-display.ts +9 -0
  79. package/src/desktop/desktop-panel-config.ts +14 -1
  80. package/src/desktop/desktop-session-manager.test.ts +86 -3
  81. package/src/desktop/desktop-session-manager.ts +144 -24
  82. package/src/desktop/desktop-stream-bridge.ts +1 -1
  83. package/src/desktop/virtual-desktop-feature.ts +18 -0
  84. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  85. package/src/i18n/__tests__/i18n.test.ts +9 -5
  86. package/src/i18n/messages.ts +13 -4
  87. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  88. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  89. package/src/ipc/assistant-server.ts +15 -1
  90. package/src/ipc/cli-client.ts +13 -5
  91. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
  92. package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
  93. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  94. package/src/live-voice/__tests__/protocol.test.ts +11 -3
  95. package/src/live-voice/__tests__/session-controls.test.ts +34 -0
  96. package/src/live-voice/live-voice-metrics.ts +9 -0
  97. package/src/live-voice/live-voice-session.ts +3 -0
  98. package/src/live-voice/protocol.ts +10 -1
  99. package/src/live-voice/session-controls.ts +28 -0
  100. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  101. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  102. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  103. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  104. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  105. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  106. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  107. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  108. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  109. package/src/mcp/client.ts +64 -16
  110. package/src/mcp/credential-target.ts +105 -0
  111. package/src/mcp/effective-config.ts +42 -15
  112. package/src/mcp/manager.ts +61 -8
  113. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  114. package/src/mcp/mcp-auth-state.ts +38 -6
  115. package/src/mcp/mcp-oauth-provider.ts +101 -33
  116. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  117. package/src/monitoring/mount-watch.ts +348 -0
  118. package/src/monitoring/worker.ts +9 -0
  119. package/src/oauth/seed-providers.ts +11 -0
  120. package/src/onboarding/checkin-event.ts +2 -1
  121. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  122. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  123. package/src/plugins/external-plugin-loader.ts +49 -86
  124. package/src/plugins/installed-plugin-dirs.ts +6 -4
  125. package/src/plugins/mcp-servers.ts +9 -15
  126. package/src/plugins/mtime-cache.ts +2 -1
  127. package/src/plugins/user-loader.ts +2 -1
  128. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  129. package/src/providers/inference/endpoint-probe.ts +27 -12
  130. package/src/providers/openai/responses-provider.ts +9 -2
  131. package/src/providers/opencode/client.test.ts +167 -0
  132. package/src/providers/opencode/client.ts +73 -4
  133. package/src/runtime/AGENTS.md +4 -0
  134. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  135. package/src/runtime/http-server.ts +6 -6
  136. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  137. package/src/runtime/routes/browser-context.ts +39 -0
  138. package/src/runtime/routes/browser-routes.ts +16 -47
  139. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  140. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  141. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  142. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  143. package/src/runtime/routes/plugins-routes.ts +14 -1
  144. package/src/runtime/sync/resource-sync-events.ts +4 -0
  145. package/src/tools/browser/browser-execution.ts +44 -13
  146. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  147. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  148. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  149. package/src/tools/computer-use/definitions.ts +2 -3
  150. package/src/util/abort-reasons.ts +12 -1
  151. package/src/util/plugin-manifest.ts +203 -0
  152. package/src/desktop/desktop-feature.test.ts +0 -18
  153. package/src/desktop/desktop-feature.ts +0 -19
@@ -0,0 +1,413 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+
3
+ import { waitFor } from "../__tests__/helpers/wait-for.js";
4
+ import { executeBrowserOperation } from "../browser/operations.js";
5
+ import { browserManager } from "../tools/browser/browser-manager.js";
6
+ import {
7
+ type CdpTransportEvent,
8
+ type CdpWsTransport,
9
+ CdpWsTransportError,
10
+ } from "../tools/browser/cdp-client/cdp-inspect/ws-transport.js";
11
+ import { DesktopBrowserClient } from "./desktop-browser-client.js";
12
+
13
+ function fixture() {
14
+ const calls: {
15
+ connection: number;
16
+ method: string;
17
+ params: Record<string, unknown>;
18
+ session?: string;
19
+ }[] = [];
20
+ const targets = [
21
+ {
22
+ targetId: "page-1",
23
+ type: "page",
24
+ url: "https://example.com",
25
+ title: "Example",
26
+ },
27
+ ];
28
+ const listeners: ((event: CdpTransportEvent) => void)[] = [];
29
+ let connections = 0;
30
+ let disconnect = () => {};
31
+ let reject:
32
+ | ((
33
+ method: string,
34
+ params: Record<string, unknown>,
35
+ ) => boolean | Promise<boolean>)
36
+ | undefined;
37
+ const browser = new DesktopBrowserClient(async () => {
38
+ const connection = ++connections;
39
+ let closed = false;
40
+ disconnect = () => {
41
+ closed = true;
42
+ };
43
+ return {
44
+ get closed() {
45
+ return closed;
46
+ },
47
+ async send<T>(
48
+ method: string,
49
+ params: Record<string, unknown> = {},
50
+ options?: { sessionId?: string; signal?: AbortSignal },
51
+ ): Promise<T> {
52
+ options?.signal?.throwIfAborted();
53
+ if (closed) {
54
+ throw new CdpWsTransportError("closed");
55
+ }
56
+ calls.push({ connection, method, params, session: options?.sessionId });
57
+ if (await reject?.(method, params)) {
58
+ throw new CdpWsTransportError("closed");
59
+ }
60
+ let result: unknown = {};
61
+ if (method === "Target.getTargets") {
62
+ result = { targetInfos: targets };
63
+ }
64
+ if (method === "Target.attachToTarget") {
65
+ result = { sessionId: `session-${connection}-${params.targetId}` };
66
+ }
67
+ if (method === "Target.createTarget") {
68
+ const targetId = `page-${targets.length + 1}`;
69
+ targets.push({
70
+ targetId,
71
+ type: "page",
72
+ url: "about:blank",
73
+ title: "New",
74
+ });
75
+ result = { targetId };
76
+ }
77
+ if (method === "Runtime.evaluate") {
78
+ result = { result: { value: "Example" } };
79
+ }
80
+ if (method === "DOM.getBoxModel") {
81
+ result = { model: { content: [10, 10, 110, 10, 110, 50, 10, 50] } };
82
+ }
83
+ if (method === "Accessibility.getFullAXTree") {
84
+ result = {
85
+ nodes: [
86
+ {
87
+ nodeId: "1",
88
+ backendDOMNodeId: 42,
89
+ role: { value: "button" },
90
+ name: { value: "Save" },
91
+ properties: [],
92
+ },
93
+ ],
94
+ };
95
+ }
96
+ return result as T;
97
+ },
98
+ addEventListener(listener) {
99
+ listeners.push(listener);
100
+ return () => {};
101
+ },
102
+ dispose() {
103
+ closed = true;
104
+ },
105
+ } satisfies CdpWsTransport;
106
+ });
107
+ return {
108
+ browser,
109
+ calls,
110
+ targets,
111
+ disconnect: () => disconnect(),
112
+ emit: (method: string, params = {}, sessionId?: string) =>
113
+ listeners.forEach((listener) => listener({ method, params, sessionId })),
114
+ fail: (predicate?: typeof reject) => {
115
+ reject = predicate;
116
+ },
117
+ };
118
+ }
119
+ const cleanups: (() => void)[] = [];
120
+ afterEach(() => {
121
+ for (const cleanup of cleanups.splice(0)) {
122
+ cleanup();
123
+ }
124
+ });
125
+
126
+ async function session() {
127
+ const f = fixture();
128
+ cleanups.push(() => f.browser.dispose());
129
+ const abort = new AbortController();
130
+ const cdp = await f.browser.client("conv-123", abort.signal);
131
+ return { ...f, cdp, abort };
132
+ }
133
+
134
+ test("shared snapshot and click use desktop element IDs without personal browser state", async () => {
135
+ const f = await session();
136
+ browserManager.storeSnapshotBackendNodeMap(
137
+ "conv-123",
138
+ new Map([["e1", 999]]),
139
+ );
140
+ cleanups.push(() => browserManager.clearSnapshotBackendNodeMap("conv-123"));
141
+ const context = {
142
+ workingDir: "/tmp",
143
+ trustClass: "guardian" as const,
144
+ conversationId: "conv-123",
145
+ cdpClient: f.cdp,
146
+ signal: f.abort.signal,
147
+ };
148
+ const snapshot = await executeBrowserOperation("snapshot", {}, context);
149
+ expect(snapshot.isError).toBe(false);
150
+ expect(snapshot.content).toContain("Save");
151
+ const element = /\b(e\d+)\b/.exec(snapshot.content)?.[1];
152
+ expect(element).toBeDefined();
153
+ const result = await executeBrowserOperation(
154
+ "click",
155
+ { element_id: element },
156
+ context,
157
+ );
158
+ expect(result.isError).toBe(false);
159
+ expect(
160
+ f.calls.find((call) => call.method === "DOM.getBoxModel")?.params
161
+ .backendNodeId,
162
+ ).toBe(42);
163
+ expect(browserManager.resolveSnapshotBackendNodeId("conv-123", "e1")).toBe(
164
+ 999,
165
+ );
166
+ const pressIndex = f.calls.findIndex(
167
+ (call) => call.params.type === "mousePressed",
168
+ );
169
+ expect(pressIndex).toBeGreaterThan(0);
170
+ expect(f.calls[pressIndex - 1]?.params.expression).toContain(
171
+ "data-vellum-desktop-cursor",
172
+ );
173
+ expect(
174
+ f.calls.filter((call) => call.params.type === "mousePressed"),
175
+ ).toHaveLength(1);
176
+ f.emit("DOM.documentUpdated");
177
+ const stale = await executeBrowserOperation(
178
+ "click",
179
+ { element_id: element },
180
+ context,
181
+ );
182
+ expect(stale.isError).toBe(true);
183
+ expect(
184
+ f.calls.filter((call) => call.params.type === "mousePressed"),
185
+ ).toHaveLength(1);
186
+ });
187
+
188
+ test("release invalidates borrowed clients and releases uncertain input through a fresh connection", async () => {
189
+ const f = await session();
190
+ f.fail(
191
+ (method, params) =>
192
+ method === "Input.dispatchKeyEvent" && params.type === "keyDown",
193
+ );
194
+ await expect(
195
+ f.cdp.send("Input.dispatchKeyEvent", {
196
+ type: "keyDown",
197
+ key: "Shift",
198
+ code: "ShiftLeft",
199
+ }),
200
+ ).rejects.toThrow("do not retry");
201
+ f.abort.abort();
202
+ f.fail();
203
+ await f.browser.release();
204
+ const releases = f.calls.filter((call) => call.params.type === "keyUp");
205
+ expect(releases).toHaveLength(1);
206
+ expect(releases[0]?.connection).toBe(2);
207
+ expect(releases[0]?.session).toBe("session-2-page-1");
208
+ expect(f.calls.filter((call) => call.params.type === "keyDown")).toHaveLength(
209
+ 1,
210
+ );
211
+ await expect(f.cdp.listTabs()).rejects.toBeDefined();
212
+ await expect(
213
+ f.cdp.send("Input.insertText", { text: "late" }),
214
+ ).rejects.toBeDefined();
215
+ });
216
+
217
+ test("failed cleanup retains held input for a later attempt", async () => {
218
+ const f = await session();
219
+ await f.cdp.send("Input.dispatchMouseEvent", {
220
+ type: "mousePressed",
221
+ button: "left",
222
+ x: 10,
223
+ y: 20,
224
+ });
225
+ f.fail((_method, params) => params.type === "mouseReleased");
226
+ await expect(f.browser.release()).rejects.toBeDefined();
227
+ f.fail();
228
+ await f.browser.release();
229
+ expect(
230
+ f.calls
231
+ .filter((call) => call.params.type === "mouseReleased")
232
+ .map((call) => call.connection),
233
+ ).toEqual([2, 3]);
234
+ });
235
+
236
+ test("detachment preserves uncertain key state until target cleanup", async () => {
237
+ const f = await session();
238
+ await f.cdp.send("Input.dispatchKeyEvent", {
239
+ type: "rawKeyDown",
240
+ key: "Alt",
241
+ code: "AltLeft",
242
+ });
243
+ f.emit("Target.detachedFromTarget", { sessionId: "session-1-page-1" });
244
+ await f.browser.release();
245
+ expect(f.calls.filter((call) => call.params.type === "keyUp")).toHaveLength(
246
+ 1,
247
+ );
248
+ });
249
+
250
+ test("tab changes clear element references and aliases are not reused across leases", async () => {
251
+ const f = await session();
252
+ const first = (await f.cdp.listTabs())[0]!.tabId!;
253
+ browserManager.storeSnapshotBackendNodeMap(
254
+ f.cdp.conversationId,
255
+ new Map([["e1", 42]]),
256
+ );
257
+ const created = await f.cdp.send<{ tabId: number }>("Vellum.createTab");
258
+ expect(
259
+ browserManager.resolveSnapshotBackendNodeId(f.cdp.conversationId, "e1"),
260
+ ).toBeNull();
261
+ await f.cdp.selectTab(first);
262
+ expect((await f.cdp.listTabs()).find((tab) => tab.active)?.tabId).toBe(first);
263
+ f.targets.splice(0, 1);
264
+ f.emit("Target.detachedFromTarget", { sessionId: "session-1-page-1" });
265
+ await expect(
266
+ f.cdp.send("Input.insertText", { text: "wrong page" }),
267
+ ).rejects.toThrow("tab closed");
268
+ expect(f.calls.some((call) => call.params.text === "wrong page")).toBe(false);
269
+ await f.browser.release();
270
+ const next = await f.browser.client("conv-456", new AbortController().signal);
271
+ expect(
272
+ (await next.listTabs()).every((tab) => tab.tabId! > created.tabId),
273
+ ).toBe(true);
274
+ });
275
+
276
+ test("navigation during cursor animation prevents input on the replacement page", async () => {
277
+ const f = await session();
278
+ f.fail((method, params) => {
279
+ if (
280
+ method === "Runtime.evaluate" &&
281
+ String(params.expression).includes("data-vellum-desktop-cursor")
282
+ ) {
283
+ f.emit("Page.frameNavigated");
284
+ }
285
+ return false;
286
+ });
287
+ await expect(
288
+ f.cdp.send("Input.dispatchMouseEvent", {
289
+ type: "mousePressed",
290
+ x: 50,
291
+ y: 60,
292
+ button: "left",
293
+ }),
294
+ ).rejects.toThrow("page changed");
295
+ expect(f.calls.some((call) => call.params.type === "mousePressed")).toBe(
296
+ false,
297
+ );
298
+ });
299
+
300
+ test("navigation restores the cursor in its tab without replaying mouse input", async () => {
301
+ const f = await session();
302
+ await f.cdp.send("Input.dispatchMouseEvent", {
303
+ type: "mouseMoved",
304
+ x: 50,
305
+ y: 60,
306
+ });
307
+ f.calls.length = 0;
308
+ f.emit("Page.domContentEventFired", {}, "session-1-page-2");
309
+ f.emit("Page.domContentEventFired", {}, "session-1-page-1");
310
+ await Bun.sleep(0);
311
+ expect(f.calls).toHaveLength(1);
312
+ expect(f.calls[0]).toMatchObject({
313
+ method: "Runtime.evaluate",
314
+ session: "session-1-page-1",
315
+ });
316
+ expect(f.calls[0]?.params.expression).toContain("translate(50px, 60px)");
317
+ });
318
+
319
+ test("keyboard input restores an existing cursor without synthesizing mouse input", async () => {
320
+ const f = await session();
321
+ await f.cdp.send("Input.dispatchMouseEvent", {
322
+ type: "mouseMoved",
323
+ x: 50,
324
+ y: 60,
325
+ });
326
+ f.calls.length = 0;
327
+ await f.cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter" });
328
+ expect(f.calls.map((call) => call.method)).toEqual([
329
+ "Input.dispatchKeyEvent",
330
+ "Runtime.evaluate",
331
+ ]);
332
+ expect(f.calls[1]?.params.expression).toContain("translate(50px, 60px)");
333
+ });
334
+
335
+ test("release suppresses pending cursor restoration and clears it before detaching", async () => {
336
+ const f = await session();
337
+ await f.cdp.send("Input.dispatchMouseEvent", {
338
+ type: "mouseMoved",
339
+ x: 50,
340
+ y: 60,
341
+ });
342
+ f.calls.length = 0;
343
+ f.emit("Page.domContentEventFired", {}, "session-1-page-1");
344
+ await f.browser.release();
345
+ f.emit("Page.domContentEventFired", {}, "session-1-page-1");
346
+ await Bun.sleep(0);
347
+ const expressions = f.calls.filter(
348
+ (call) => call.method === "Runtime.evaluate",
349
+ );
350
+ expect(expressions).toHaveLength(1);
351
+ expect(expressions[0]?.params.expression).toContain("?.remove()");
352
+ });
353
+
354
+ test("release waits for an in-flight cursor restoration before removing it", async () => {
355
+ const f = await session();
356
+ await f.cdp.send("Input.dispatchMouseEvent", {
357
+ type: "mouseMoved",
358
+ x: 50,
359
+ y: 60,
360
+ });
361
+ let finish!: (reject: boolean) => void;
362
+ const restoring = new Promise<boolean>((resolve) => {
363
+ finish = resolve;
364
+ });
365
+ let started = false;
366
+ f.fail((method, params) => {
367
+ if (
368
+ method === "Runtime.evaluate" &&
369
+ String(params.expression).includes("translate(")
370
+ ) {
371
+ started = true;
372
+ return restoring;
373
+ }
374
+ return false;
375
+ });
376
+ f.emit("Page.domContentEventFired", {}, "session-1-page-1");
377
+ await waitFor(() => started);
378
+ const released = f.browser.release();
379
+ try {
380
+ await Bun.sleep(0);
381
+ expect(f.calls.some((call) => call.connection === 2)).toBe(false);
382
+ } finally {
383
+ finish(false);
384
+ await released;
385
+ }
386
+ expect(f.calls.at(-1)?.params.expression).toContain("?.remove()");
387
+ });
388
+
389
+ test("closed connections reconnect after cleanup without retrying input or reusing old clients", async () => {
390
+ const f = await session();
391
+ await f.cdp.send("Input.dispatchKeyEvent", {
392
+ type: "keyDown",
393
+ key: "Shift",
394
+ code: "ShiftLeft",
395
+ });
396
+ let previous = f.cdp;
397
+ for (let round = 0; round < 2; round++) {
398
+ f.disconnect();
399
+ const current = await f.browser.client("conv-123", f.abort.signal);
400
+ await expect(previous.listTabs()).rejects.toThrow("session expired");
401
+ await current.send("Runtime.evaluate", { expression: "document.title" });
402
+ expect(f.calls.at(-1)?.connection).toBe(3 + round * 2);
403
+ previous = current;
404
+ }
405
+ expect(f.calls.filter((call) => call.params.type === "keyDown")).toHaveLength(
406
+ 1,
407
+ );
408
+ expect(
409
+ f.calls
410
+ .filter((call) => call.params.type === "keyUp")
411
+ .map((call) => call.connection),
412
+ ).toEqual([2]);
413
+ });