@flrande/browserctl 0.1.0-dev.7.1

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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README-CN.md +66 -0
  3. package/README.md +66 -0
  4. package/apps/browserctl/src/commands/a11y-snapshot.ts +20 -0
  5. package/apps/browserctl/src/commands/act.ts +20 -0
  6. package/apps/browserctl/src/commands/common.test.ts +87 -0
  7. package/apps/browserctl/src/commands/common.ts +191 -0
  8. package/apps/browserctl/src/commands/console-list.ts +20 -0
  9. package/apps/browserctl/src/commands/cookie-clear.ts +18 -0
  10. package/apps/browserctl/src/commands/cookie-get.ts +18 -0
  11. package/apps/browserctl/src/commands/cookie-set.ts +22 -0
  12. package/apps/browserctl/src/commands/dialog-arm.ts +20 -0
  13. package/apps/browserctl/src/commands/dom-query-all.ts +18 -0
  14. package/apps/browserctl/src/commands/dom-query.ts +18 -0
  15. package/apps/browserctl/src/commands/download-trigger.ts +22 -0
  16. package/apps/browserctl/src/commands/download-wait.test.ts +67 -0
  17. package/apps/browserctl/src/commands/download-wait.ts +27 -0
  18. package/apps/browserctl/src/commands/element-screenshot.ts +20 -0
  19. package/apps/browserctl/src/commands/frame-list.ts +16 -0
  20. package/apps/browserctl/src/commands/frame-snapshot.ts +18 -0
  21. package/apps/browserctl/src/commands/network-wait-for.ts +100 -0
  22. package/apps/browserctl/src/commands/profile-list.ts +16 -0
  23. package/apps/browserctl/src/commands/profile-use.ts +18 -0
  24. package/apps/browserctl/src/commands/response-body.ts +24 -0
  25. package/apps/browserctl/src/commands/screenshot.ts +16 -0
  26. package/apps/browserctl/src/commands/snapshot.ts +16 -0
  27. package/apps/browserctl/src/commands/status.ts +10 -0
  28. package/apps/browserctl/src/commands/storage-get.ts +20 -0
  29. package/apps/browserctl/src/commands/storage-set.ts +22 -0
  30. package/apps/browserctl/src/commands/tab-close.ts +20 -0
  31. package/apps/browserctl/src/commands/tab-focus.ts +20 -0
  32. package/apps/browserctl/src/commands/tab-open.ts +19 -0
  33. package/apps/browserctl/src/commands/tabs.ts +13 -0
  34. package/apps/browserctl/src/commands/upload-arm.ts +26 -0
  35. package/apps/browserctl/src/daemon-client.test.ts +253 -0
  36. package/apps/browserctl/src/daemon-client.ts +632 -0
  37. package/apps/browserctl/src/e2e.test.ts +99 -0
  38. package/apps/browserctl/src/main.test.ts +215 -0
  39. package/apps/browserctl/src/main.ts +372 -0
  40. package/apps/browserctl/src/smoke.test.ts +16 -0
  41. package/apps/browserctl/src/smoke.ts +5 -0
  42. package/apps/browserd/src/bootstrap.ts +432 -0
  43. package/apps/browserd/src/chrome-relay-extension-bridge.test.ts +275 -0
  44. package/apps/browserd/src/chrome-relay-extension-bridge.ts +506 -0
  45. package/apps/browserd/src/container.ts +1531 -0
  46. package/apps/browserd/src/main.test.ts +864 -0
  47. package/apps/browserd/src/main.ts +7 -0
  48. package/bin/browserctl.cjs +21 -0
  49. package/bin/browserd.cjs +21 -0
  50. package/extensions/chrome-relay/README-CN.md +38 -0
  51. package/extensions/chrome-relay/README.md +38 -0
  52. package/extensions/chrome-relay/background.js +1687 -0
  53. package/extensions/chrome-relay/manifest.json +15 -0
  54. package/extensions/chrome-relay/popup.html +369 -0
  55. package/extensions/chrome-relay/popup.js +972 -0
  56. package/package.json +51 -0
  57. package/packages/core/src/bootstrap.test.ts +10 -0
  58. package/packages/core/src/driver-registry.test.ts +45 -0
  59. package/packages/core/src/driver-registry.ts +22 -0
  60. package/packages/core/src/driver.ts +47 -0
  61. package/packages/core/src/index.ts +5 -0
  62. package/packages/core/src/ref-cache.test.ts +61 -0
  63. package/packages/core/src/ref-cache.ts +28 -0
  64. package/packages/core/src/session-store.test.ts +49 -0
  65. package/packages/core/src/session-store.ts +33 -0
  66. package/packages/core/src/types.ts +9 -0
  67. package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +634 -0
  68. package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +2206 -0
  69. package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.test.ts +264 -0
  70. package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.ts +521 -0
  71. package/packages/driver-chrome-relay/src/index.ts +26 -0
  72. package/packages/driver-managed/src/index.ts +22 -0
  73. package/packages/driver-managed/src/managed-driver.test.ts +59 -0
  74. package/packages/driver-managed/src/managed-driver.ts +125 -0
  75. package/packages/driver-managed/src/managed-local-driver.test.ts +506 -0
  76. package/packages/driver-managed/src/managed-local-driver.ts +2021 -0
  77. package/packages/driver-remote-cdp/src/index.ts +19 -0
  78. package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +617 -0
  79. package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +2042 -0
  80. package/packages/protocol/src/envelope.test.ts +25 -0
  81. package/packages/protocol/src/envelope.ts +31 -0
  82. package/packages/protocol/src/errors.test.ts +17 -0
  83. package/packages/protocol/src/errors.ts +11 -0
  84. package/packages/protocol/src/index.ts +3 -0
  85. package/packages/protocol/src/tools.ts +3 -0
  86. package/packages/transport-mcp-stdio/src/index.ts +3 -0
  87. package/packages/transport-mcp-stdio/src/sdk-server.ts +139 -0
  88. package/packages/transport-mcp-stdio/src/server.test.ts +281 -0
  89. package/packages/transport-mcp-stdio/src/server.ts +183 -0
  90. package/packages/transport-mcp-stdio/src/tool-map.ts +67 -0
  91. package/scripts/smoke.ps1 +127 -0
@@ -0,0 +1,1687 @@
1
+ const CONFIG_KEY = "browserctlRelayConfig";
2
+ const DEFAULT_BRIDGE_URL = "ws://127.0.0.1:9223/bridge";
3
+ const RECONNECT_DELAY_MS = 1500;
4
+ const DEBUGGER_PROTOCOL_VERSION = "1.3";
5
+ const TELEMETRY_BODY_MAX_CHARS = 262144;
6
+
7
+ let socket = null;
8
+ let reconnectTimer = null;
9
+ let connectInFlight = false;
10
+ let telemetryRequestSequence = 1;
11
+ let runtimeState = {
12
+ connected: false,
13
+ bridgeUrl: DEFAULT_BRIDGE_URL,
14
+ token: "",
15
+ lastError: "",
16
+ lastConnectedAt: ""
17
+ };
18
+
19
+ const attachedDebuggerTabs = new Set();
20
+ const networkRequestMetaByTab = new Map();
21
+
22
+ function normalizeString(value) {
23
+ if (typeof value !== "string") {
24
+ return "";
25
+ }
26
+
27
+ return value.trim();
28
+ }
29
+
30
+ function normalizeBridgeUrl(value) {
31
+ const rawValue = normalizeString(value);
32
+ if (rawValue.length === 0) {
33
+ return DEFAULT_BRIDGE_URL;
34
+ }
35
+
36
+ try {
37
+ const parsed = new URL(rawValue);
38
+ if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
39
+ return DEFAULT_BRIDGE_URL;
40
+ }
41
+
42
+ return parsed.toString();
43
+ } catch {
44
+ return DEFAULT_BRIDGE_URL;
45
+ }
46
+ }
47
+
48
+ function toErrorMessage(error) {
49
+ if (error instanceof Error) {
50
+ return error.message;
51
+ }
52
+
53
+ return String(error);
54
+ }
55
+
56
+ function buildSocketUrl(config) {
57
+ return normalizeBridgeUrl(config.bridgeUrl);
58
+ }
59
+
60
+ async function readConfig() {
61
+ const stored = await chrome.storage.local.get(CONFIG_KEY);
62
+ const rawConfig = stored?.[CONFIG_KEY];
63
+ const config = {
64
+ bridgeUrl: DEFAULT_BRIDGE_URL,
65
+ token: ""
66
+ };
67
+
68
+ if (rawConfig !== null && typeof rawConfig === "object") {
69
+ config.bridgeUrl = normalizeBridgeUrl(rawConfig.bridgeUrl);
70
+ config.token = normalizeString(rawConfig.token);
71
+ }
72
+
73
+ runtimeState.bridgeUrl = config.bridgeUrl;
74
+ runtimeState.token = config.token;
75
+ return config;
76
+ }
77
+
78
+ async function writeConfig(nextConfig) {
79
+ const normalized = {
80
+ bridgeUrl: normalizeBridgeUrl(nextConfig.bridgeUrl),
81
+ token: normalizeString(nextConfig.token)
82
+ };
83
+ await chrome.storage.local.set({
84
+ [CONFIG_KEY]: normalized
85
+ });
86
+ runtimeState.bridgeUrl = normalized.bridgeUrl;
87
+ runtimeState.token = normalized.token;
88
+ return normalized;
89
+ }
90
+
91
+ function setDisconnectedState(reason) {
92
+ runtimeState.connected = false;
93
+ if (reason.length > 0) {
94
+ runtimeState.lastError = reason;
95
+ }
96
+ }
97
+
98
+ function clearReconnectTimer() {
99
+ if (reconnectTimer === null) {
100
+ return;
101
+ }
102
+
103
+ clearTimeout(reconnectTimer);
104
+ reconnectTimer = null;
105
+ }
106
+
107
+ function scheduleReconnect() {
108
+ if (reconnectTimer !== null) {
109
+ return;
110
+ }
111
+
112
+ reconnectTimer = setTimeout(() => {
113
+ reconnectTimer = null;
114
+ void connectToBridge(false);
115
+ }, RECONNECT_DELAY_MS);
116
+ }
117
+
118
+ function sendSocketEnvelope(payload) {
119
+ if (socket === null || socket.readyState !== WebSocket.OPEN) {
120
+ return false;
121
+ }
122
+
123
+ socket.send(JSON.stringify(payload));
124
+ return true;
125
+ }
126
+
127
+ function sendBridgeEvent(event) {
128
+ sendSocketEnvelope({
129
+ type: "event",
130
+ event
131
+ });
132
+ }
133
+
134
+ function sendHello() {
135
+ sendSocketEnvelope({
136
+ type: "hello",
137
+ role: "extension",
138
+ extensionId: chrome.runtime.id,
139
+ version: chrome.runtime.getManifest().version,
140
+ token: runtimeState.token
141
+ });
142
+ }
143
+
144
+ async function executeInTab(tabId, func, args = []) {
145
+ const results = await chrome.scripting.executeScript({
146
+ target: { tabId },
147
+ func,
148
+ args
149
+ });
150
+
151
+ if (!Array.isArray(results) || results.length === 0) {
152
+ return undefined;
153
+ }
154
+
155
+ return results[0]?.result;
156
+ }
157
+
158
+ function toTabSummary(tab) {
159
+ return {
160
+ tabId: typeof tab.id === "number" ? tab.id : undefined,
161
+ windowId: typeof tab.windowId === "number" ? tab.windowId : undefined,
162
+ active: tab.active === true,
163
+ url: typeof tab.url === "string" ? tab.url : "",
164
+ title: typeof tab.title === "string" ? tab.title : ""
165
+ };
166
+ }
167
+
168
+ function requireNonEmptyString(value, fieldName) {
169
+ const normalized = normalizeString(value);
170
+ if (normalized.length === 0) {
171
+ throw new Error(`${fieldName} must be a non-empty string.`);
172
+ }
173
+
174
+ return normalized;
175
+ }
176
+
177
+ function requireTabId(value, fieldName = "tabId") {
178
+ if (typeof value !== "number" || !Number.isFinite(value)) {
179
+ throw new Error(`${fieldName} must be a number.`);
180
+ }
181
+
182
+ return value;
183
+ }
184
+
185
+ function getNetworkRequestMap(tabId) {
186
+ const existing = networkRequestMetaByTab.get(tabId);
187
+ if (existing !== undefined) {
188
+ return existing;
189
+ }
190
+
191
+ const created = new Map();
192
+ networkRequestMetaByTab.set(tabId, created);
193
+ return created;
194
+ }
195
+
196
+ function clearNetworkRequestState(tabId) {
197
+ networkRequestMetaByTab.delete(tabId);
198
+ }
199
+
200
+ function getOrCreateNetworkRequestMeta(tabId, rawRequestId) {
201
+ const requestMap = getNetworkRequestMap(tabId);
202
+ const existing = requestMap.get(rawRequestId);
203
+ if (existing !== undefined) {
204
+ return existing;
205
+ }
206
+
207
+ const created = {
208
+ requestId: `request:extension:${tabId}:${telemetryRequestSequence}`,
209
+ rawRequestId,
210
+ url: "",
211
+ method: undefined,
212
+ resourceType: undefined,
213
+ status: undefined
214
+ };
215
+ telemetryRequestSequence += 1;
216
+ requestMap.set(rawRequestId, created);
217
+ return created;
218
+ }
219
+
220
+ function readRemoteObjectValue(value) {
221
+ if (value === null || typeof value !== "object") {
222
+ return "";
223
+ }
224
+
225
+ if ("value" in value && value.value !== undefined) {
226
+ try {
227
+ if (typeof value.value === "string") {
228
+ return value.value;
229
+ }
230
+
231
+ return JSON.stringify(value.value);
232
+ } catch {
233
+ return String(value.value);
234
+ }
235
+ }
236
+
237
+ if ("unserializableValue" in value && typeof value.unserializableValue === "string") {
238
+ return value.unserializableValue;
239
+ }
240
+
241
+ if ("description" in value && typeof value.description === "string") {
242
+ return value.description;
243
+ }
244
+
245
+ return "";
246
+ }
247
+
248
+ function toConsoleEntry(params) {
249
+ const args = Array.isArray(params?.args) ? params.args : [];
250
+ const text = args.map((item) => readRemoteObjectValue(item)).join(" ");
251
+ const stackFrames = Array.isArray(params?.stackTrace?.callFrames)
252
+ ? params.stackTrace.callFrames
253
+ : [];
254
+ const firstFrame = stackFrames[0];
255
+
256
+ return {
257
+ type: normalizeString(params?.type) || "log",
258
+ text,
259
+ ...(firstFrame !== undefined
260
+ ? {
261
+ location: {
262
+ ...(normalizeString(firstFrame.url).length > 0 ? { url: firstFrame.url } : {}),
263
+ ...(typeof firstFrame.lineNumber === "number"
264
+ ? { lineNumber: firstFrame.lineNumber }
265
+ : {}),
266
+ ...(typeof firstFrame.columnNumber === "number"
267
+ ? { columnNumber: firstFrame.columnNumber }
268
+ : {})
269
+ }
270
+ }
271
+ : {})
272
+ };
273
+ }
274
+
275
+ function normalizeTelemetryBody(body, base64Encoded) {
276
+ if (typeof body !== "string") {
277
+ return {
278
+ body: "",
279
+ encoding: "utf8"
280
+ };
281
+ }
282
+
283
+ const sliced = body.slice(0, TELEMETRY_BODY_MAX_CHARS);
284
+ return {
285
+ body: sliced,
286
+ encoding: base64Encoded === true ? "base64" : "utf8"
287
+ };
288
+ }
289
+
290
+ async function attachDebuggerToTab(tabId) {
291
+ if (attachedDebuggerTabs.has(tabId)) {
292
+ return;
293
+ }
294
+
295
+ let attachSucceeded = false;
296
+ try {
297
+ await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
298
+ attachSucceeded = true;
299
+ } catch (error) {
300
+ const errorMessage = toErrorMessage(error).toLowerCase();
301
+ if (errorMessage.includes("already attached")) {
302
+ attachSucceeded = true;
303
+ } else if (errorMessage.includes("another debugger")) {
304
+ throw new Error(`Tab ${tabId} is already attached by another debugger.`);
305
+ } else {
306
+ throw error;
307
+ }
308
+ }
309
+
310
+ if (!attachSucceeded) {
311
+ return;
312
+ }
313
+
314
+ attachedDebuggerTabs.add(tabId);
315
+ try {
316
+ await chrome.debugger.sendCommand({ tabId }, "Runtime.enable");
317
+ } catch {
318
+ // Ignore optional domain initialization failures.
319
+ }
320
+
321
+ try {
322
+ await chrome.debugger.sendCommand({ tabId }, "Network.enable");
323
+ } catch {
324
+ // Ignore optional domain initialization failures.
325
+ }
326
+
327
+ try {
328
+ await chrome.debugger.sendCommand({ tabId }, "DOM.enable");
329
+ } catch {
330
+ // Ignore optional domain initialization failures.
331
+ }
332
+ }
333
+
334
+ async function detachDebuggerFromTab(tabId) {
335
+ if (!attachedDebuggerTabs.has(tabId)) {
336
+ clearNetworkRequestState(tabId);
337
+ return;
338
+ }
339
+
340
+ attachedDebuggerTabs.delete(tabId);
341
+ clearNetworkRequestState(tabId);
342
+ try {
343
+ await chrome.debugger.detach({ tabId });
344
+ } catch {
345
+ // Ignore detach errors for already closed tabs.
346
+ }
347
+ }
348
+
349
+ async function ensureDebuggerAttached(tabId) {
350
+ try {
351
+ await attachDebuggerToTab(tabId);
352
+ return true;
353
+ } catch (error) {
354
+ runtimeState.lastError = `Debugger attach failed for tab ${tabId}: ${toErrorMessage(error)}`;
355
+ return false;
356
+ }
357
+ }
358
+
359
+ async function emitNetworkResponseEvent(tabId, rawRequestId) {
360
+ const requestMap = networkRequestMetaByTab.get(tabId);
361
+ const requestMeta = requestMap?.get(rawRequestId);
362
+ if (requestMeta === undefined) {
363
+ return;
364
+ }
365
+
366
+ let responseBodyPayload = {
367
+ body: "",
368
+ encoding: "utf8"
369
+ };
370
+ try {
371
+ const responseBody = await chrome.debugger.sendCommand(
372
+ { tabId },
373
+ "Network.getResponseBody",
374
+ { requestId: rawRequestId }
375
+ );
376
+ responseBodyPayload = normalizeTelemetryBody(
377
+ responseBody?.body,
378
+ responseBody?.base64Encoded === true
379
+ );
380
+ } catch {
381
+ // Some resource types do not expose bodies; keep empty body payload.
382
+ }
383
+
384
+ sendBridgeEvent({
385
+ kind: "response",
386
+ tabId,
387
+ response: {
388
+ requestId: requestMeta.requestId,
389
+ url: requestMeta.url,
390
+ ...(typeof requestMeta.status === "number" ? { status: requestMeta.status } : {}),
391
+ ...(typeof requestMeta.method === "string" ? { method: requestMeta.method } : {}),
392
+ ...(typeof requestMeta.resourceType === "string"
393
+ ? { resourceType: requestMeta.resourceType }
394
+ : {}),
395
+ ...responseBodyPayload
396
+ }
397
+ });
398
+
399
+ requestMap.delete(rawRequestId);
400
+ }
401
+
402
+ function handleDebuggerEvent(source, method, params) {
403
+ if (typeof source?.tabId !== "number") {
404
+ return;
405
+ }
406
+
407
+ const tabId = source.tabId;
408
+ if (method === "Runtime.consoleAPICalled") {
409
+ sendBridgeEvent({
410
+ kind: "console",
411
+ tabId,
412
+ entry: toConsoleEntry(params)
413
+ });
414
+ return;
415
+ }
416
+
417
+ if (method === "Network.requestWillBeSent") {
418
+ const rawRequestId = normalizeString(params?.requestId);
419
+ if (rawRequestId.length === 0) {
420
+ return;
421
+ }
422
+
423
+ const requestMeta = getOrCreateNetworkRequestMeta(tabId, rawRequestId);
424
+ requestMeta.url = normalizeString(params?.request?.url);
425
+ const methodValue = normalizeString(params?.request?.method);
426
+ if (methodValue.length > 0) {
427
+ requestMeta.method = methodValue;
428
+ }
429
+ return;
430
+ }
431
+
432
+ if (method === "Network.responseReceived") {
433
+ const rawRequestId = normalizeString(params?.requestId);
434
+ if (rawRequestId.length === 0) {
435
+ return;
436
+ }
437
+
438
+ const requestMeta = getOrCreateNetworkRequestMeta(tabId, rawRequestId);
439
+ const responseUrl = normalizeString(params?.response?.url);
440
+ if (responseUrl.length > 0) {
441
+ requestMeta.url = responseUrl;
442
+ }
443
+ if (typeof params?.response?.status === "number") {
444
+ requestMeta.status = params.response.status;
445
+ }
446
+ const resourceType = normalizeString(params?.type);
447
+ if (resourceType.length > 0) {
448
+ requestMeta.resourceType = resourceType;
449
+ }
450
+ return;
451
+ }
452
+
453
+ if (method === "Network.loadingFinished") {
454
+ const rawRequestId = normalizeString(params?.requestId);
455
+ if (rawRequestId.length === 0) {
456
+ return;
457
+ }
458
+
459
+ void emitNetworkResponseEvent(tabId, rawRequestId);
460
+ return;
461
+ }
462
+
463
+ if (method === "Network.loadingFailed") {
464
+ const rawRequestId = normalizeString(params?.requestId);
465
+ if (rawRequestId.length === 0) {
466
+ return;
467
+ }
468
+
469
+ const requestMap = networkRequestMetaByTab.get(tabId);
470
+ requestMap?.delete(rawRequestId);
471
+ }
472
+ }
473
+
474
+ function registerDebuggerTelemetryHandlers() {
475
+ chrome.debugger.onEvent.addListener((source, method, params) => {
476
+ handleDebuggerEvent(source, method, params);
477
+ });
478
+
479
+ chrome.debugger.onDetach.addListener((source) => {
480
+ if (typeof source?.tabId !== "number") {
481
+ return;
482
+ }
483
+
484
+ attachedDebuggerTabs.delete(source.tabId);
485
+ clearNetworkRequestState(source.tabId);
486
+ });
487
+
488
+ chrome.tabs.onRemoved.addListener((tabId) => {
489
+ void detachDebuggerFromTab(tabId);
490
+ });
491
+ }
492
+
493
+ async function handleTabOpen(params) {
494
+ const url = requireNonEmptyString(params?.url, "url");
495
+ const tab = await chrome.tabs.create({
496
+ url,
497
+ active: true
498
+ });
499
+ if (typeof tab.id === "number") {
500
+ await ensureDebuggerAttached(tab.id);
501
+ }
502
+ return toTabSummary(tab);
503
+ }
504
+
505
+ async function handleTabGoto(params) {
506
+ const tabId = requireTabId(params?.tabId);
507
+ const url = requireNonEmptyString(params?.url, "url");
508
+ await ensureDebuggerAttached(tabId);
509
+ const tab = await chrome.tabs.update(tabId, {
510
+ url,
511
+ active: true
512
+ });
513
+ return toTabSummary(tab);
514
+ }
515
+
516
+ async function handleTabFocus(params) {
517
+ const tabId = requireTabId(params?.tabId);
518
+ await ensureDebuggerAttached(tabId);
519
+ const tab = await chrome.tabs.get(tabId);
520
+ if (typeof tab.windowId === "number") {
521
+ await chrome.windows.update(tab.windowId, { focused: true });
522
+ }
523
+ const updated = await chrome.tabs.update(tabId, { active: true });
524
+ return toTabSummary(updated);
525
+ }
526
+
527
+ async function handleTabClose(params) {
528
+ const tabId = requireTabId(params?.tabId);
529
+ await detachDebuggerFromTab(tabId);
530
+ await chrome.tabs.remove(tabId);
531
+ return {
532
+ tabId,
533
+ closed: true
534
+ };
535
+ }
536
+
537
+ async function handleTabInfo(params) {
538
+ const tabId = requireTabId(params?.tabId);
539
+ await ensureDebuggerAttached(tabId);
540
+ const tab = await chrome.tabs.get(tabId);
541
+ return toTabSummary(tab);
542
+ }
543
+
544
+ async function handleTabSnapshot(params) {
545
+ const tabId = requireTabId(params?.tabId);
546
+ await ensureDebuggerAttached(tabId);
547
+ const result = await executeInTab(
548
+ tabId,
549
+ () => ({
550
+ url: window.location.href,
551
+ title: document.title ?? "",
552
+ html: document.documentElement ? document.documentElement.outerHTML : ""
553
+ }),
554
+ []
555
+ );
556
+
557
+ if (result !== null && typeof result === "object") {
558
+ return {
559
+ tabId,
560
+ url: typeof result.url === "string" ? result.url : "",
561
+ title: typeof result.title === "string" ? result.title : "",
562
+ html: typeof result.html === "string" ? result.html : ""
563
+ };
564
+ }
565
+
566
+ const tab = await chrome.tabs.get(tabId);
567
+ return {
568
+ tabId,
569
+ url: typeof tab.url === "string" ? tab.url : "",
570
+ title: typeof tab.title === "string" ? tab.title : "",
571
+ html: ""
572
+ };
573
+ }
574
+
575
+ function parseCapturedImageDataUrl(dataUrl) {
576
+ const normalized = normalizeString(dataUrl);
577
+ const match = /^data:([^;,]+);base64,(.+)$/i.exec(normalized);
578
+ if (match === null) {
579
+ throw new Error("tabs.captureVisibleTab returned unsupported image payload.");
580
+ }
581
+
582
+ const mimeType = match[1] || "image/png";
583
+ const imageBase64 = match[2] || "";
584
+ if (imageBase64.length === 0) {
585
+ throw new Error("tabs.captureVisibleTab returned empty image payload.");
586
+ }
587
+
588
+ return {
589
+ mimeType,
590
+ imageBase64
591
+ };
592
+ }
593
+
594
+ async function cropCapturedImageDataUrl(dataUrl, bounds) {
595
+ const fallback = parseCapturedImageDataUrl(dataUrl);
596
+ if (
597
+ typeof OffscreenCanvas !== "function" ||
598
+ typeof createImageBitmap !== "function" ||
599
+ typeof bounds !== "object" ||
600
+ bounds === null
601
+ ) {
602
+ return fallback;
603
+ }
604
+
605
+ const x = typeof bounds.x === "number" && Number.isFinite(bounds.x) ? bounds.x : 0;
606
+ const y = typeof bounds.y === "number" && Number.isFinite(bounds.y) ? bounds.y : 0;
607
+ const width = typeof bounds.width === "number" && Number.isFinite(bounds.width) ? bounds.width : 0;
608
+ const height = typeof bounds.height === "number" && Number.isFinite(bounds.height) ? bounds.height : 0;
609
+ if (width <= 0 || height <= 0) {
610
+ return fallback;
611
+ }
612
+
613
+ try {
614
+ const response = await fetch(dataUrl);
615
+ const imageBlob = await response.blob();
616
+ const bitmap = await createImageBitmap(imageBlob);
617
+ const sx = Math.max(0, Math.min(bitmap.width - 1, Math.floor(x)));
618
+ const sy = Math.max(0, Math.min(bitmap.height - 1, Math.floor(y)));
619
+ const sw = Math.max(1, Math.min(bitmap.width - sx, Math.ceil(width)));
620
+ const sh = Math.max(1, Math.min(bitmap.height - sy, Math.ceil(height)));
621
+ const canvas = new OffscreenCanvas(sw, sh);
622
+ const context = canvas.getContext("2d");
623
+ if (context === null) {
624
+ return fallback;
625
+ }
626
+
627
+ context.drawImage(bitmap, sx, sy, sw, sh, 0, 0, sw, sh);
628
+ const croppedBlob = await canvas.convertToBlob({
629
+ type: "image/png"
630
+ });
631
+ const croppedBuffer = await croppedBlob.arrayBuffer();
632
+ const bytes = new Uint8Array(croppedBuffer);
633
+ let binary = "";
634
+ for (let index = 0; index < bytes.length; index += 1) {
635
+ binary += String.fromCharCode(bytes[index]);
636
+ }
637
+
638
+ return {
639
+ mimeType: "image/png",
640
+ imageBase64: btoa(binary),
641
+ width: sw,
642
+ height: sh
643
+ };
644
+ } catch {
645
+ return fallback;
646
+ }
647
+ }
648
+
649
+ async function handleTabScreenshot(params) {
650
+ const tabId = requireTabId(params?.tabId);
651
+ await ensureDebuggerAttached(tabId);
652
+ const tab = await chrome.tabs.get(tabId);
653
+ if (typeof tab.windowId !== "number") {
654
+ throw new Error(`Unable to resolve window for tab ${tabId}.`);
655
+ }
656
+
657
+ await chrome.windows.update(tab.windowId, { focused: true });
658
+ await chrome.tabs.update(tabId, { active: true });
659
+
660
+ const rawDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
661
+ format: "png"
662
+ });
663
+ const parsedImage = parseCapturedImageDataUrl(rawDataUrl);
664
+
665
+ const dimensions = await executeInTab(
666
+ tabId,
667
+ () => ({
668
+ width:
669
+ typeof window.innerWidth === "number" && typeof window.devicePixelRatio === "number"
670
+ ? Math.max(1, Math.round(window.innerWidth * window.devicePixelRatio))
671
+ : undefined,
672
+ height:
673
+ typeof window.innerHeight === "number" && typeof window.devicePixelRatio === "number"
674
+ ? Math.max(1, Math.round(window.innerHeight * window.devicePixelRatio))
675
+ : undefined
676
+ }),
677
+ []
678
+ );
679
+
680
+ return {
681
+ tabId,
682
+ mimeType: parsedImage.mimeType,
683
+ encoding: "base64",
684
+ imageBase64: parsedImage.imageBase64,
685
+ ...(typeof dimensions?.width === "number" ? { width: dimensions.width } : {}),
686
+ ...(typeof dimensions?.height === "number" ? { height: dimensions.height } : {})
687
+ };
688
+ }
689
+
690
+ async function handleTabDomQuery(params) {
691
+ const tabId = requireTabId(params?.tabId);
692
+ const selector = requireNonEmptyString(params?.selector, "selector");
693
+ await ensureDebuggerAttached(tabId);
694
+ const result = await executeInTab(
695
+ tabId,
696
+ (selectorValue) => {
697
+ let element = null;
698
+ try {
699
+ element = document.querySelector(selectorValue);
700
+ } catch {
701
+ return {
702
+ selector: selectorValue,
703
+ found: false
704
+ };
705
+ }
706
+
707
+ if (element === null) {
708
+ return {
709
+ selector: selectorValue,
710
+ found: false
711
+ };
712
+ }
713
+
714
+ const attributes = {};
715
+ for (const attribute of Array.from(element.attributes)) {
716
+ attributes[attribute.name] = attribute.value;
717
+ }
718
+
719
+ return {
720
+ selector: selectorValue,
721
+ found: true,
722
+ node: {
723
+ tagName: element.tagName.toLowerCase(),
724
+ id: element.id || undefined,
725
+ className: element.className || undefined,
726
+ text: (element.textContent || "").trim().slice(0, 300),
727
+ attributes
728
+ }
729
+ };
730
+ },
731
+ [selector]
732
+ );
733
+
734
+ if (result !== null && typeof result === "object") {
735
+ return result;
736
+ }
737
+
738
+ return {
739
+ selector,
740
+ found: false
741
+ };
742
+ }
743
+
744
+ async function handleTabDomQueryAll(params) {
745
+ const tabId = requireTabId(params?.tabId);
746
+ const selector = requireNonEmptyString(params?.selector, "selector");
747
+ await ensureDebuggerAttached(tabId);
748
+ const result = await executeInTab(
749
+ tabId,
750
+ (selectorValue) => {
751
+ let elements = [];
752
+ try {
753
+ elements = Array.from(document.querySelectorAll(selectorValue));
754
+ } catch {
755
+ return {
756
+ selector: selectorValue,
757
+ count: 0,
758
+ nodes: []
759
+ };
760
+ }
761
+
762
+ const nodes = elements.map((element, index) => {
763
+ const attributes = {};
764
+ for (const attribute of Array.from(element.attributes)) {
765
+ attributes[attribute.name] = attribute.value;
766
+ }
767
+
768
+ return {
769
+ index,
770
+ tagName: element.tagName.toLowerCase(),
771
+ id: element.id || undefined,
772
+ className: element.className || undefined,
773
+ text: (element.textContent || "").trim().slice(0, 300),
774
+ attributes
775
+ };
776
+ });
777
+
778
+ return {
779
+ selector: selectorValue,
780
+ count: nodes.length,
781
+ nodes
782
+ };
783
+ },
784
+ [selector]
785
+ );
786
+
787
+ if (result !== null && typeof result === "object") {
788
+ return result;
789
+ }
790
+
791
+ return {
792
+ selector,
793
+ count: 0,
794
+ nodes: []
795
+ };
796
+ }
797
+
798
+ async function handleTabElementScreenshot(params) {
799
+ const tabId = requireTabId(params?.tabId);
800
+ const selector = requireNonEmptyString(params?.selector, "selector");
801
+ await ensureDebuggerAttached(tabId);
802
+ const tab = await chrome.tabs.get(tabId);
803
+ if (typeof tab.windowId !== "number") {
804
+ throw new Error(`Unable to resolve window for tab ${tabId}.`);
805
+ }
806
+
807
+ await chrome.windows.update(tab.windowId, { focused: true });
808
+ await chrome.tabs.update(tabId, { active: true });
809
+ const elementMeta = await executeInTab(
810
+ tabId,
811
+ (selectorValue) => {
812
+ let element = null;
813
+ try {
814
+ element = document.querySelector(selectorValue);
815
+ } catch {
816
+ return {
817
+ selector: selectorValue,
818
+ found: false
819
+ };
820
+ }
821
+
822
+ if (element === null) {
823
+ return {
824
+ selector: selectorValue,
825
+ found: false
826
+ };
827
+ }
828
+
829
+ element.scrollIntoView({
830
+ block: "center",
831
+ inline: "center",
832
+ behavior: "auto"
833
+ });
834
+ const rect = element.getBoundingClientRect();
835
+ const dpr = typeof window.devicePixelRatio === "number" ? window.devicePixelRatio : 1;
836
+ return {
837
+ selector: selectorValue,
838
+ found: true,
839
+ x: rect.left * dpr,
840
+ y: rect.top * dpr,
841
+ width: Math.max(1, Math.round(rect.width * dpr)),
842
+ height: Math.max(1, Math.round(rect.height * dpr))
843
+ };
844
+ },
845
+ [selector]
846
+ );
847
+
848
+ if (elementMeta === null || typeof elementMeta !== "object" || elementMeta.found !== true) {
849
+ return {
850
+ selector,
851
+ found: false
852
+ };
853
+ }
854
+
855
+ const rawDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
856
+ format: "png"
857
+ });
858
+ const parsedImage = await cropCapturedImageDataUrl(rawDataUrl, elementMeta);
859
+ return {
860
+ selector,
861
+ found: true,
862
+ mimeType: parsedImage.mimeType,
863
+ encoding: "base64",
864
+ imageBase64: parsedImage.imageBase64,
865
+ ...(typeof elementMeta.width === "number" ? { width: elementMeta.width } : {}),
866
+ ...(typeof elementMeta.height === "number" ? { height: elementMeta.height } : {})
867
+ };
868
+ }
869
+
870
+ async function handleTabA11ySnapshot(params) {
871
+ const tabId = requireTabId(params?.tabId);
872
+ const selector = normalizeString(params?.selector);
873
+ await ensureDebuggerAttached(tabId);
874
+ const result = await executeInTab(
875
+ tabId,
876
+ (selectorValue) => {
877
+ const root =
878
+ selectorValue.length > 0
879
+ ? document.querySelector(selectorValue)
880
+ : document.body ?? document.documentElement;
881
+ if (root === null) {
882
+ return {
883
+ selector: selectorValue,
884
+ found: false
885
+ };
886
+ }
887
+
888
+ const build = (element, depth) => {
889
+ const role =
890
+ element.getAttribute("role") ||
891
+ (element.tagName.toLowerCase() === "a"
892
+ ? "link"
893
+ : element.tagName.toLowerCase() === "button"
894
+ ? "button"
895
+ : element.tagName.toLowerCase() === "input"
896
+ ? "textbox"
897
+ : "generic");
898
+ const name =
899
+ element.getAttribute("aria-label") ||
900
+ element.getAttribute("alt") ||
901
+ (element.textContent || "").trim().slice(0, 120);
902
+ if (depth >= 5) {
903
+ return {
904
+ role,
905
+ ...(name.length > 0 ? { name } : {})
906
+ };
907
+ }
908
+
909
+ const children = Array.from(element.children)
910
+ .slice(0, 30)
911
+ .map((child) => build(child, depth + 1));
912
+ return {
913
+ role,
914
+ ...(name.length > 0 ? { name } : {}),
915
+ ...(children.length > 0 ? { children } : {})
916
+ };
917
+ };
918
+
919
+ return {
920
+ selector: selectorValue,
921
+ found: true,
922
+ snapshot: build(root, 0)
923
+ };
924
+ },
925
+ [selector]
926
+ );
927
+
928
+ if (result !== null && typeof result === "object") {
929
+ return result;
930
+ }
931
+
932
+ return {
933
+ selector,
934
+ found: false
935
+ };
936
+ }
937
+
938
+ function toCookieSummary(cookie) {
939
+ return {
940
+ name: typeof cookie.name === "string" ? cookie.name : "",
941
+ value: typeof cookie.value === "string" ? cookie.value : "",
942
+ ...(typeof cookie.domain === "string" ? { domain: cookie.domain } : {}),
943
+ ...(typeof cookie.path === "string" ? { path: cookie.path } : {}),
944
+ ...(typeof cookie.expires === "number" ? { expires: cookie.expires } : {}),
945
+ ...(typeof cookie.httpOnly === "boolean" ? { httpOnly: cookie.httpOnly } : {}),
946
+ ...(typeof cookie.secure === "boolean" ? { secure: cookie.secure } : {}),
947
+ ...(typeof cookie.sameSite === "string" ? { sameSite: cookie.sameSite } : {})
948
+ };
949
+ }
950
+
951
+ function toCookieRemovalUrl(cookie, fallbackUrl) {
952
+ if (typeof fallbackUrl === "string" && fallbackUrl.length > 0) {
953
+ return fallbackUrl;
954
+ }
955
+
956
+ const domain = normalizeString(cookie?.domain).replace(/^\./, "");
957
+ const path = normalizeString(cookie?.path) || "/";
958
+ const secure = cookie?.secure === true;
959
+ if (domain.length === 0) {
960
+ throw new Error("Unable to resolve cookie domain for removal.");
961
+ }
962
+
963
+ return `${secure ? "https" : "http"}://${domain}${path}`;
964
+ }
965
+
966
+ async function handleTabCookieGet(params) {
967
+ const tabId = requireTabId(params?.tabId);
968
+ await ensureDebuggerAttached(tabId);
969
+ const tab = await chrome.tabs.get(tabId);
970
+ const tabUrl = requireNonEmptyString(tab?.url, "tab.url");
971
+ const name = normalizeString(params?.name);
972
+ const cookies = await chrome.cookies.getAll({
973
+ url: tabUrl,
974
+ ...(name.length > 0 ? { name } : {})
975
+ });
976
+
977
+ return {
978
+ cookies: cookies.map((cookie) => toCookieSummary(cookie)),
979
+ ...(name.length > 0 ? { name } : {})
980
+ };
981
+ }
982
+
983
+ async function handleTabCookieSet(params) {
984
+ const tabId = requireTabId(params?.tabId);
985
+ await ensureDebuggerAttached(tabId);
986
+ const tab = await chrome.tabs.get(tabId);
987
+ const tabUrl = requireNonEmptyString(tab?.url, "tab.url");
988
+ const name = requireNonEmptyString(params?.name, "name");
989
+ const value = requireNonEmptyString(params?.value, "value");
990
+ const cookieUrl = normalizeString(params?.url) || tabUrl;
991
+
992
+ const cookie = await chrome.cookies.set({
993
+ url: cookieUrl,
994
+ name,
995
+ value
996
+ });
997
+
998
+ return {
999
+ set: true,
1000
+ cookie: cookie !== null && cookie !== undefined
1001
+ ? toCookieSummary(cookie)
1002
+ : {
1003
+ name,
1004
+ value
1005
+ }
1006
+ };
1007
+ }
1008
+
1009
+ async function handleTabCookieClear(params) {
1010
+ const tabId = requireTabId(params?.tabId);
1011
+ await ensureDebuggerAttached(tabId);
1012
+ const tab = await chrome.tabs.get(tabId);
1013
+ const tabUrl = requireNonEmptyString(tab?.url, "tab.url");
1014
+ const name = normalizeString(params?.name);
1015
+ const cookies = await chrome.cookies.getAll({
1016
+ url: tabUrl,
1017
+ ...(name.length > 0 ? { name } : {})
1018
+ });
1019
+
1020
+ let removed = 0;
1021
+ for (const cookie of cookies) {
1022
+ const removeResult = await chrome.cookies.remove({
1023
+ url: toCookieRemovalUrl(cookie, tabUrl),
1024
+ name: cookie.name,
1025
+ ...(typeof cookie.storeId === "string" ? { storeId: cookie.storeId } : {})
1026
+ });
1027
+ if (removeResult !== null && removeResult !== undefined) {
1028
+ removed += 1;
1029
+ }
1030
+ }
1031
+
1032
+ return {
1033
+ cleared: true,
1034
+ count: removed,
1035
+ ...(name.length > 0 ? { name } : {})
1036
+ };
1037
+ }
1038
+
1039
+ async function handleTabStorageGet(params) {
1040
+ const tabId = requireTabId(params?.tabId);
1041
+ const scope = requireNonEmptyString(params?.scope, "scope");
1042
+ if (scope !== "local" && scope !== "session") {
1043
+ throw new Error("scope must be local or session.");
1044
+ }
1045
+
1046
+ const key = requireNonEmptyString(params?.key, "key");
1047
+ await ensureDebuggerAttached(tabId);
1048
+ const result = await executeInTab(
1049
+ tabId,
1050
+ (scopeValue, keyValue) => {
1051
+ const storage = scopeValue === "session" ? window.sessionStorage : window.localStorage;
1052
+ const value = storage.getItem(keyValue);
1053
+ return {
1054
+ scope: scopeValue,
1055
+ key: keyValue,
1056
+ exists: value !== null,
1057
+ ...(value !== null ? { value } : {})
1058
+ };
1059
+ },
1060
+ [scope, key]
1061
+ );
1062
+
1063
+ if (result !== null && typeof result === "object") {
1064
+ return result;
1065
+ }
1066
+
1067
+ return {
1068
+ scope,
1069
+ key,
1070
+ exists: false
1071
+ };
1072
+ }
1073
+
1074
+ async function handleTabStorageSet(params) {
1075
+ const tabId = requireTabId(params?.tabId);
1076
+ const scope = requireNonEmptyString(params?.scope, "scope");
1077
+ if (scope !== "local" && scope !== "session") {
1078
+ throw new Error("scope must be local or session.");
1079
+ }
1080
+
1081
+ const key = requireNonEmptyString(params?.key, "key");
1082
+ const value = requireNonEmptyString(params?.value, "value");
1083
+ await ensureDebuggerAttached(tabId);
1084
+ await executeInTab(
1085
+ tabId,
1086
+ (scopeValue, keyValue, valueText) => {
1087
+ const storage = scopeValue === "session" ? window.sessionStorage : window.localStorage;
1088
+ storage.setItem(keyValue, valueText);
1089
+ },
1090
+ [scope, key, value]
1091
+ );
1092
+
1093
+ return {
1094
+ scope,
1095
+ key,
1096
+ value,
1097
+ set: true
1098
+ };
1099
+ }
1100
+
1101
+ async function handleTabFrameList(params) {
1102
+ const tabId = requireTabId(params?.tabId);
1103
+ await ensureDebuggerAttached(tabId);
1104
+ const result = await executeInTab(
1105
+ tabId,
1106
+ () => {
1107
+ const frames = [];
1108
+
1109
+ const walk = (currentWindow, framePath, depth) => {
1110
+ let url = "";
1111
+ let title = "";
1112
+ try {
1113
+ url = String(currentWindow.location.href || "");
1114
+ title = String(currentWindow.document.title || "");
1115
+ } catch {
1116
+ // cross-origin frame
1117
+ }
1118
+
1119
+ frames.push({
1120
+ frameId: framePath,
1121
+ url,
1122
+ ...(title.length > 0 ? { name: title } : {}),
1123
+ isMainFrame: framePath === "frame:0",
1124
+ depth
1125
+ });
1126
+
1127
+ let childCount = 0;
1128
+ try {
1129
+ childCount = currentWindow.frames.length;
1130
+ } catch {
1131
+ childCount = 0;
1132
+ }
1133
+
1134
+ for (let index = 0; index < childCount; index += 1) {
1135
+ try {
1136
+ walk(currentWindow.frames[index], `${framePath}.${index}`, depth + 1);
1137
+ } catch {
1138
+ frames.push({
1139
+ frameId: `${framePath}.${index}`,
1140
+ url: "",
1141
+ isMainFrame: false,
1142
+ depth: depth + 1,
1143
+ crossOrigin: true
1144
+ });
1145
+ }
1146
+ }
1147
+ };
1148
+
1149
+ walk(window, "frame:0", 0);
1150
+ return {
1151
+ frames
1152
+ };
1153
+ },
1154
+ []
1155
+ );
1156
+
1157
+ if (result !== null && typeof result === "object") {
1158
+ return result;
1159
+ }
1160
+
1161
+ return {
1162
+ frames: []
1163
+ };
1164
+ }
1165
+
1166
+ async function handleTabFrameSnapshot(params) {
1167
+ const tabId = requireTabId(params?.tabId);
1168
+ const frameId = requireNonEmptyString(params?.frameId, "frameId");
1169
+ await ensureDebuggerAttached(tabId);
1170
+ const result = await executeInTab(
1171
+ tabId,
1172
+ (frameIdValue) => {
1173
+ const normalized = String(frameIdValue || "");
1174
+ const match = /^frame:(\d+(?:\.\d+)*)$/.exec(normalized);
1175
+ if (match === null) {
1176
+ return {
1177
+ frameId: normalized,
1178
+ found: false
1179
+ };
1180
+ }
1181
+
1182
+ const indexPath = match[1]
1183
+ .split(".")
1184
+ .map((segment) => Number.parseInt(segment, 10))
1185
+ .filter((segment) => Number.isFinite(segment));
1186
+ if (indexPath.length === 0 || indexPath[0] !== 0) {
1187
+ return {
1188
+ frameId: normalized,
1189
+ found: false
1190
+ };
1191
+ }
1192
+
1193
+ let currentWindow = window;
1194
+ for (let pointer = 1; pointer < indexPath.length; pointer += 1) {
1195
+ const childIndex = indexPath[pointer];
1196
+ if (!Number.isFinite(childIndex) || childIndex < 0 || childIndex >= currentWindow.frames.length) {
1197
+ return {
1198
+ frameId: normalized,
1199
+ found: false
1200
+ };
1201
+ }
1202
+
1203
+ currentWindow = currentWindow.frames[childIndex];
1204
+ }
1205
+
1206
+ try {
1207
+ const doc = currentWindow.document;
1208
+ return {
1209
+ frameId: normalized,
1210
+ found: true,
1211
+ url: String(currentWindow.location.href || ""),
1212
+ ...(String(doc.title || "").length > 0 ? { name: String(doc.title) } : {}),
1213
+ html: doc.documentElement ? doc.documentElement.outerHTML : "",
1214
+ crossOrigin: false
1215
+ };
1216
+ } catch {
1217
+ return {
1218
+ frameId: normalized,
1219
+ found: true,
1220
+ html: "",
1221
+ crossOrigin: true
1222
+ };
1223
+ }
1224
+ },
1225
+ [frameId]
1226
+ );
1227
+
1228
+ if (result !== null && typeof result === "object") {
1229
+ return result;
1230
+ }
1231
+
1232
+ return {
1233
+ frameId,
1234
+ found: false
1235
+ };
1236
+ }
1237
+
1238
+ async function handleTabAct(params) {
1239
+ const tabId = requireTabId(params?.tabId);
1240
+ const debuggerReady = await ensureDebuggerAttached(tabId);
1241
+ const action = params?.action;
1242
+ const actionType = normalizeString(action?.type);
1243
+
1244
+ if (actionType === "setFiles") {
1245
+ if (!debuggerReady) {
1246
+ throw new Error(
1247
+ `Unable to attach debugger for tab ${tabId}. Close DevTools/other debugger and retry file upload.`
1248
+ );
1249
+ }
1250
+
1251
+ const payload = action?.payload !== null && typeof action?.payload === "object" ? action.payload : {};
1252
+ const selector = requireNonEmptyString(payload?.selector, "selector");
1253
+ const files = Array.isArray(payload?.files)
1254
+ ? payload.files
1255
+ .map((file) => normalizeString(file))
1256
+ .filter((file) => file.length > 0)
1257
+ : [];
1258
+ if (files.length === 0) {
1259
+ throw new Error("files must contain at least one file path.");
1260
+ }
1261
+
1262
+ const evaluateResult = await chrome.debugger.sendCommand(
1263
+ { tabId },
1264
+ "Runtime.evaluate",
1265
+ {
1266
+ expression: `document.querySelector(${JSON.stringify(selector)})`,
1267
+ objectGroup: "browserctl-upload",
1268
+ includeCommandLineAPI: false,
1269
+ silent: true
1270
+ }
1271
+ );
1272
+ const objectId = evaluateResult?.result?.objectId;
1273
+ if (typeof objectId !== "string" || objectId.length === 0) {
1274
+ throw new Error(`Element not found for file upload selector: ${selector}`);
1275
+ }
1276
+
1277
+ let nodeId;
1278
+ try {
1279
+ const nodeResult = await chrome.debugger.sendCommand(
1280
+ { tabId },
1281
+ "DOM.requestNode",
1282
+ { objectId }
1283
+ );
1284
+ nodeId = nodeResult?.nodeId;
1285
+ } finally {
1286
+ try {
1287
+ await chrome.debugger.sendCommand(
1288
+ { tabId },
1289
+ "Runtime.releaseObject",
1290
+ { objectId }
1291
+ );
1292
+ } catch {
1293
+ // Ignore release errors.
1294
+ }
1295
+ }
1296
+
1297
+ if (typeof nodeId !== "number") {
1298
+ throw new Error(`Unable to resolve DOM node for selector: ${selector}`);
1299
+ }
1300
+
1301
+ await chrome.debugger.sendCommand(
1302
+ { tabId },
1303
+ "DOM.setFileInputFiles",
1304
+ {
1305
+ nodeId,
1306
+ files
1307
+ }
1308
+ );
1309
+
1310
+ return { executed: true };
1311
+ }
1312
+
1313
+ const result = await executeInTab(
1314
+ tabId,
1315
+ (actionPayload) => {
1316
+ if (actionPayload === null || typeof actionPayload !== "object") {
1317
+ throw new Error("action must be an object.");
1318
+ }
1319
+
1320
+ const type =
1321
+ typeof actionPayload.type === "string" && actionPayload.type.trim().length > 0
1322
+ ? actionPayload.type.trim()
1323
+ : "";
1324
+ if (type.length === 0) {
1325
+ throw new Error("action.type must be a non-empty string.");
1326
+ }
1327
+
1328
+ const payload =
1329
+ actionPayload.payload !== null && typeof actionPayload.payload === "object"
1330
+ ? actionPayload.payload
1331
+ : {};
1332
+ const selector =
1333
+ typeof payload.selector === "string" && payload.selector.trim().length > 0
1334
+ ? payload.selector.trim()
1335
+ : "";
1336
+
1337
+ const resolveElement = () => {
1338
+ if (selector.length === 0) {
1339
+ throw new Error("selector is required.");
1340
+ }
1341
+
1342
+ const element = document.querySelector(selector);
1343
+ if (element === null) {
1344
+ throw new Error(`Element not found: ${selector}`);
1345
+ }
1346
+ return element;
1347
+ };
1348
+
1349
+ if (type === "click") {
1350
+ const element = resolveElement();
1351
+ if (typeof element.click === "function") {
1352
+ element.click();
1353
+ }
1354
+ return { executed: true };
1355
+ }
1356
+
1357
+ if (type === "fill") {
1358
+ const element = resolveElement();
1359
+ const value = typeof payload.value === "string" ? payload.value : "";
1360
+ if ("value" in element) {
1361
+ element.value = value;
1362
+ element.dispatchEvent(new Event("input", { bubbles: true }));
1363
+ element.dispatchEvent(new Event("change", { bubbles: true }));
1364
+ }
1365
+ return { executed: true };
1366
+ }
1367
+
1368
+ if (type === "type") {
1369
+ const element = resolveElement();
1370
+ const text = typeof payload.text === "string" ? payload.text : "";
1371
+ if ("value" in element) {
1372
+ const previousValue = typeof element.value === "string" ? element.value : "";
1373
+ element.value = `${previousValue}${text}`;
1374
+ element.dispatchEvent(new Event("input", { bubbles: true }));
1375
+ element.dispatchEvent(new Event("change", { bubbles: true }));
1376
+ }
1377
+ return { executed: true };
1378
+ }
1379
+
1380
+ if (type === "press") {
1381
+ const key =
1382
+ typeof payload.key === "string" && payload.key.trim().length > 0
1383
+ ? payload.key.trim()
1384
+ : "Enter";
1385
+ const target = document.activeElement ?? document.body;
1386
+ target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
1387
+ target.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true }));
1388
+ return { executed: true };
1389
+ }
1390
+
1391
+ if (type === "scroll") {
1392
+ const deltaX =
1393
+ typeof payload.deltaX === "number" && Number.isFinite(payload.deltaX)
1394
+ ? payload.deltaX
1395
+ : 0;
1396
+ const deltaY =
1397
+ typeof payload.deltaY === "number" && Number.isFinite(payload.deltaY)
1398
+ ? payload.deltaY
1399
+ : 900;
1400
+ window.scrollBy({
1401
+ left: deltaX,
1402
+ top: deltaY
1403
+ });
1404
+ return {
1405
+ executed: true,
1406
+ deltaX,
1407
+ deltaY,
1408
+ scrollX: window.scrollX,
1409
+ scrollY: window.scrollY
1410
+ };
1411
+ }
1412
+
1413
+ return {
1414
+ executed: false
1415
+ };
1416
+ },
1417
+ [action]
1418
+ );
1419
+
1420
+ if (result !== null && typeof result === "object") {
1421
+ return result;
1422
+ }
1423
+
1424
+ return {
1425
+ executed: false
1426
+ };
1427
+ }
1428
+
1429
+ async function dispatchBridgeRequest(method, params) {
1430
+ switch (method) {
1431
+ case "tab.open":
1432
+ return await handleTabOpen(params);
1433
+ case "tab.goto":
1434
+ return await handleTabGoto(params);
1435
+ case "tab.focus":
1436
+ return await handleTabFocus(params);
1437
+ case "tab.close":
1438
+ return await handleTabClose(params);
1439
+ case "tab.info":
1440
+ return await handleTabInfo(params);
1441
+ case "tab.snapshot":
1442
+ return await handleTabSnapshot(params);
1443
+ case "tab.dom.query":
1444
+ return await handleTabDomQuery(params);
1445
+ case "tab.dom.queryAll":
1446
+ return await handleTabDomQueryAll(params);
1447
+ case "tab.screenshot":
1448
+ return await handleTabScreenshot(params);
1449
+ case "tab.element.screenshot":
1450
+ return await handleTabElementScreenshot(params);
1451
+ case "tab.a11y.snapshot":
1452
+ return await handleTabA11ySnapshot(params);
1453
+ case "tab.cookie.get":
1454
+ return await handleTabCookieGet(params);
1455
+ case "tab.cookie.set":
1456
+ return await handleTabCookieSet(params);
1457
+ case "tab.cookie.clear":
1458
+ return await handleTabCookieClear(params);
1459
+ case "tab.storage.get":
1460
+ return await handleTabStorageGet(params);
1461
+ case "tab.storage.set":
1462
+ return await handleTabStorageSet(params);
1463
+ case "tab.frame.list":
1464
+ return await handleTabFrameList(params);
1465
+ case "tab.frame.snapshot":
1466
+ return await handleTabFrameSnapshot(params);
1467
+ case "tab.act":
1468
+ return await handleTabAct(params);
1469
+ default:
1470
+ throw new Error(`Unsupported method: ${method}`);
1471
+ }
1472
+ }
1473
+
1474
+ async function handleSocketMessage(rawMessage) {
1475
+ let parsedMessage;
1476
+ try {
1477
+ parsedMessage = JSON.parse(String(rawMessage));
1478
+ } catch {
1479
+ return;
1480
+ }
1481
+
1482
+ if (parsedMessage === null || typeof parsedMessage !== "object") {
1483
+ return;
1484
+ }
1485
+
1486
+ if (parsedMessage.type !== "request") {
1487
+ return;
1488
+ }
1489
+
1490
+ const requestId = normalizeString(parsedMessage.id);
1491
+ const method = normalizeString(parsedMessage.method);
1492
+ if (requestId.length === 0 || method.length === 0) {
1493
+ return;
1494
+ }
1495
+
1496
+ try {
1497
+ const result = await dispatchBridgeRequest(method, parsedMessage.params);
1498
+ sendSocketEnvelope({
1499
+ type: "response",
1500
+ id: requestId,
1501
+ ok: true,
1502
+ result
1503
+ });
1504
+ } catch (error) {
1505
+ sendSocketEnvelope({
1506
+ type: "response",
1507
+ id: requestId,
1508
+ ok: false,
1509
+ error: {
1510
+ message: toErrorMessage(error)
1511
+ }
1512
+ });
1513
+ }
1514
+ }
1515
+
1516
+ async function connectToBridge(forceReconnect) {
1517
+ if (connectInFlight) {
1518
+ return;
1519
+ }
1520
+
1521
+ if (
1522
+ !forceReconnect &&
1523
+ socket !== null &&
1524
+ (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)
1525
+ ) {
1526
+ return;
1527
+ }
1528
+
1529
+ connectInFlight = true;
1530
+ clearReconnectTimer();
1531
+
1532
+ try {
1533
+ const config = await readConfig();
1534
+ const socketUrl = buildSocketUrl(config);
1535
+
1536
+ if (socket !== null) {
1537
+ try {
1538
+ socket.close(1000, "Reconnect requested.");
1539
+ } catch {
1540
+ // Ignore close errors while reconnecting.
1541
+ }
1542
+ socket = null;
1543
+ }
1544
+
1545
+ const ws = new WebSocket(socketUrl);
1546
+ socket = ws;
1547
+ runtimeState.bridgeUrl = config.bridgeUrl;
1548
+ runtimeState.token = config.token;
1549
+
1550
+ ws.onopen = () => {
1551
+ runtimeState.connected = true;
1552
+ runtimeState.lastError = "";
1553
+ runtimeState.lastConnectedAt = new Date().toISOString();
1554
+ connectInFlight = false;
1555
+ sendHello();
1556
+ };
1557
+
1558
+ ws.onmessage = (event) => {
1559
+ void handleSocketMessage(event.data);
1560
+ };
1561
+
1562
+ ws.onerror = () => {
1563
+ runtimeState.lastError = "WebSocket connection error.";
1564
+ };
1565
+
1566
+ ws.onclose = (event) => {
1567
+ if (socket === ws) {
1568
+ socket = null;
1569
+ }
1570
+ connectInFlight = false;
1571
+ setDisconnectedState(`WebSocket closed (${event.code}).`);
1572
+ scheduleReconnect();
1573
+ };
1574
+ } catch (error) {
1575
+ connectInFlight = false;
1576
+ setDisconnectedState(toErrorMessage(error));
1577
+ scheduleReconnect();
1578
+ }
1579
+ }
1580
+
1581
+ function getStatusPayload() {
1582
+ return {
1583
+ connected: runtimeState.connected,
1584
+ bridgeUrl: runtimeState.bridgeUrl,
1585
+ tokenConfigured: runtimeState.token.length > 0,
1586
+ lastConnectedAt: runtimeState.lastConnectedAt,
1587
+ lastError: runtimeState.lastError,
1588
+ debuggerAttachedTabs: attachedDebuggerTabs.size
1589
+ };
1590
+ }
1591
+
1592
+ function registerRuntimeMessageHandlers() {
1593
+ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
1594
+ const requestType = normalizeString(request?.type);
1595
+
1596
+ if (requestType === "relay.getStatus") {
1597
+ sendResponse({
1598
+ ok: true,
1599
+ status: getStatusPayload()
1600
+ });
1601
+ return;
1602
+ }
1603
+
1604
+ if (requestType === "relay.getConfig") {
1605
+ void (async () => {
1606
+ const config = await readConfig();
1607
+ sendResponse({
1608
+ ok: true,
1609
+ config
1610
+ });
1611
+ })();
1612
+ return true;
1613
+ }
1614
+
1615
+ if (requestType === "relay.saveConfig") {
1616
+ void (async () => {
1617
+ try {
1618
+ const config = await writeConfig(request?.config ?? {});
1619
+ await connectToBridge(true);
1620
+ sendResponse({
1621
+ ok: true,
1622
+ config,
1623
+ status: getStatusPayload()
1624
+ });
1625
+ } catch (error) {
1626
+ sendResponse({
1627
+ ok: false,
1628
+ error: toErrorMessage(error)
1629
+ });
1630
+ }
1631
+ })();
1632
+ return true;
1633
+ }
1634
+
1635
+ if (requestType === "relay.reconnect") {
1636
+ void (async () => {
1637
+ try {
1638
+ await connectToBridge(true);
1639
+ sendResponse({
1640
+ ok: true,
1641
+ status: getStatusPayload()
1642
+ });
1643
+ } catch (error) {
1644
+ sendResponse({
1645
+ ok: false,
1646
+ error: toErrorMessage(error)
1647
+ });
1648
+ }
1649
+ })();
1650
+ return true;
1651
+ }
1652
+
1653
+ if (requestType === "relay.event") {
1654
+ const tabId =
1655
+ typeof request?.tabId === "number"
1656
+ ? request.tabId
1657
+ : typeof sender?.tab?.id === "number"
1658
+ ? sender.tab.id
1659
+ : undefined;
1660
+ if (typeof tabId === "number" && request?.event !== undefined) {
1661
+ sendBridgeEvent({
1662
+ ...request.event,
1663
+ tabId
1664
+ });
1665
+ }
1666
+
1667
+ sendResponse({
1668
+ ok: true
1669
+ });
1670
+ return;
1671
+ }
1672
+
1673
+ return undefined;
1674
+ });
1675
+ }
1676
+
1677
+ chrome.runtime.onInstalled.addListener(() => {
1678
+ void connectToBridge(true);
1679
+ });
1680
+
1681
+ chrome.runtime.onStartup.addListener(() => {
1682
+ void connectToBridge(false);
1683
+ });
1684
+
1685
+ registerRuntimeMessageHandlers();
1686
+ registerDebuggerTelemetryHandlers();
1687
+ void connectToBridge(false);