devtools-inspector-mcp 0.1.0__py3-none-any.whl

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.

Potentially problematic release.


This version of devtools-inspector-mcp might be problematic. Click here for more details.

@@ -0,0 +1,3 @@
1
+ """Live Chrome DevTools diagnostics over MCP."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,607 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ipaddress
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from collections import OrderedDict
9
+ from dataclasses import dataclass
10
+ from typing import Any, Callable
11
+ from urllib.parse import urlsplit
12
+
13
+ from websockets.asyncio.client import ClientConnection, connect
14
+ from websockets.exceptions import ConnectionClosed
15
+
16
+ from .models import Issue, SourceLocation, build_issue
17
+ from .redaction import redact_text, redact_url
18
+
19
+
20
+ _PERFORMANCE_MARKER = "__DEVTOOLS_INSPECTOR_PERFORMANCE__:"
21
+ _PERFORMANCE_OBSERVER_SOURCE = r"""
22
+ (() => {
23
+ const marker = "__DEVTOOLS_INSPECTOR_PERFORMANCE__:";
24
+ const installedKey = "__devtoolsInspectorPerformanceObserverInstalled";
25
+ if (globalThis[installedKey]) return;
26
+ globalThis[installedKey] = true;
27
+ const supports = (type) => Array.isArray(PerformanceObserver?.supportedEntryTypes)
28
+ && PerformanceObserver.supportedEntryTypes.includes(type);
29
+ const emit = (entry) => console.debug(marker + JSON.stringify(entry));
30
+ const longtaskSupported = typeof PerformanceObserver !== "undefined" && supports("longtask");
31
+ const eventSupported = typeof PerformanceObserver !== "undefined" && supports("event");
32
+ emit({kind: "support", longtask: longtaskSupported, event: eventSupported});
33
+ const attribution = (items) => Array.from(items || [], (item) => ({
34
+ containerId: item.containerId || "",
35
+ containerName: item.containerName || "",
36
+ containerSrc: item.containerSrc || "",
37
+ }));
38
+ if (longtaskSupported) {
39
+ try {
40
+ new PerformanceObserver((list) => list.getEntries().forEach((entry) => emit({
41
+ kind: "longtask", url: location.href, startTime: entry.startTime,
42
+ duration: entry.duration, name: entry.name || "unknown",
43
+ attribution: attribution(entry.attribution),
44
+ }))).observe({type: "longtask", buffered: true});
45
+ } catch (_) { emit({kind: "support", longtask: false, event: eventSupported}); }
46
+ }
47
+ if (eventSupported) {
48
+ try {
49
+ new PerformanceObserver((list) => list.getEntries().forEach((entry) => emit({
50
+ kind: "action", url: location.href, name: entry.name || "unknown",
51
+ startTime: entry.startTime, processingStart: entry.processingStart,
52
+ processingEnd: entry.processingEnd, duration: entry.duration,
53
+ }))).observe({type: "event", buffered: true, durationThreshold: 16});
54
+ } catch (_) { /* Event Timing is optional evidence only. */ }
55
+ }
56
+ })()
57
+ """
58
+
59
+
60
+ class BrowserConnectionError(RuntimeError):
61
+ """The local Chrome debugging endpoint could not be used."""
62
+
63
+
64
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
65
+ def redirect_request(self, request, response, code, message, headers, new_url):
66
+ return None
67
+
68
+
69
+ def _validate_local_http_endpoint(endpoint: str) -> tuple[str, int]:
70
+ try:
71
+ parsed = urlsplit(endpoint)
72
+ host = parsed.hostname
73
+ port = parsed.port or 80
74
+ is_local = host == "localhost" or (host is not None and ipaddress.ip_address(host).is_loopback)
75
+ except (ValueError, TypeError):
76
+ raise BrowserConnectionError("Use a valid local Chrome endpoint such as http://127.0.0.1:9222.") from None
77
+ if parsed.scheme != "http" or not is_local or parsed.username or parsed.password or parsed.path not in ("", "/"):
78
+ raise BrowserConnectionError("Only an unauthenticated localhost HTTP debugging endpoint is allowed.")
79
+ return host or "", port
80
+
81
+
82
+ def discover_targets(endpoint: str, timeout: float = 3.0) -> list[dict[str, Any]]:
83
+ _validate_local_http_endpoint(endpoint)
84
+ url = endpoint.rstrip("/") + "/json/list"
85
+ request = urllib.request.Request(url, headers={"Accept": "application/json"})
86
+ try:
87
+ opener = urllib.request.build_opener(_NoRedirectHandler)
88
+ with opener.open(request, timeout=timeout) as response:
89
+ payload = response.read(1_000_001)
90
+ if len(payload) > 1_000_000:
91
+ raise BrowserConnectionError("Chrome returned an oversized target list.")
92
+ targets = json.loads(payload)
93
+ except (OSError, urllib.error.URLError, json.JSONDecodeError) as error:
94
+ raise BrowserConnectionError(f"Could not read Chrome targets at {endpoint}: {error}") from error
95
+ if not isinstance(targets, list):
96
+ raise BrowserConnectionError("Chrome returned an invalid target list.")
97
+ return [target for target in targets if isinstance(target, dict) and target.get("type") == "page"]
98
+
99
+
100
+ def _validate_websocket_target(ws_url: str, endpoint: str) -> None:
101
+ _, endpoint_port = _validate_local_http_endpoint(endpoint)
102
+ try:
103
+ parsed = urlsplit(ws_url)
104
+ ws_host = parsed.hostname
105
+ ws_port = parsed.port or 80
106
+ ws_local = ws_host == "localhost" or (ws_host is not None and ipaddress.ip_address(ws_host).is_loopback)
107
+ except (ValueError, TypeError):
108
+ raise BrowserConnectionError("Chrome provided an invalid page WebSocket URL.") from None
109
+ if parsed.scheme != "ws" or not ws_local or ws_port != endpoint_port or not parsed.path.startswith("/devtools/page/"):
110
+ raise BrowserConnectionError("Chrome provided a page WebSocket outside the selected local debugging endpoint.")
111
+
112
+
113
+ class CDPClient:
114
+ def __init__(self, ws_url: str, on_event: Callable[[str, dict[str, Any]], None]):
115
+ self._ws_url = ws_url
116
+ self._on_event = on_event
117
+ self._socket: ClientConnection | None = None
118
+ self._reader_task: asyncio.Task[None] | None = None
119
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
120
+ self._next_id = 0
121
+ self._trace_events: list[dict[str, Any]] = []
122
+ self._tracing_complete = asyncio.Event()
123
+ self._tracing_active = False
124
+
125
+ @property
126
+ def connected(self) -> bool:
127
+ return self._socket is not None and self._reader_task is not None and not self._reader_task.done()
128
+
129
+ async def open(self) -> None:
130
+ try:
131
+ self._socket = await connect(self._ws_url, open_timeout=5, close_timeout=2, max_size=1_000_000)
132
+ except (OSError, TimeoutError, ConnectionClosed) as error:
133
+ raise BrowserConnectionError(f"Could not connect to the selected Chrome tab: {error}") from error
134
+ self._reader_task = asyncio.create_task(self._read_messages())
135
+ try:
136
+ await self.command("Runtime.enable")
137
+ await self.command("Network.enable", {"maxTotalBufferSize": 1_000_000})
138
+ await self.command("Page.enable")
139
+ try:
140
+ await self.command("Performance.enable")
141
+ except BrowserConnectionError:
142
+ # The observer below is the long-task source. Some CDP versions do not expose Performance.
143
+ pass
144
+ except Exception:
145
+ await self.close()
146
+ raise
147
+
148
+ async def command(self, method: str, params: dict[str, Any] | None = None, timeout: float = 5.0) -> dict[str, Any]:
149
+ if not self.connected or self._socket is None:
150
+ raise BrowserConnectionError("The selected browser tab is disconnected.")
151
+ self._next_id += 1
152
+ command_id = self._next_id
153
+ future = asyncio.get_running_loop().create_future()
154
+ self._pending[command_id] = future
155
+ try:
156
+ await self._socket.send(json.dumps({"id": command_id, "method": method, "params": params or {}}))
157
+ return await asyncio.wait_for(future, timeout=timeout)
158
+ except TimeoutError as error:
159
+ raise BrowserConnectionError(f"Chrome did not answer {method} within {timeout:g}s.") from error
160
+ finally:
161
+ self._pending.pop(command_id, None)
162
+
163
+ async def install_performance_observer(self) -> bool:
164
+ """Install a document-start long-task observer and attach it to the current page."""
165
+ try:
166
+ await self.command("Page.addScriptToEvaluateOnNewDocument", {"source": _PERFORMANCE_OBSERVER_SOURCE})
167
+ await self.command(
168
+ "Runtime.evaluate",
169
+ {"expression": _PERFORMANCE_OBSERVER_SOURCE, "returnByValue": True},
170
+ )
171
+ except BrowserConnectionError:
172
+ return False
173
+ return True
174
+
175
+ async def start_performance_tracing(self) -> bool:
176
+ """Begin a bounded CDP tracing fallback for future main-thread tasks."""
177
+ if self._tracing_active:
178
+ return True
179
+ self._trace_events.clear()
180
+ self._tracing_complete.clear()
181
+ try:
182
+ await self.command(
183
+ "Tracing.start",
184
+ {
185
+ "categories": "devtools.timeline,disabled-by-default-devtools.timeline,toplevel",
186
+ "transferMode": "ReportEvents",
187
+ },
188
+ )
189
+ except BrowserConnectionError:
190
+ return False
191
+ self._tracing_active = True
192
+ return True
193
+
194
+ async def stop_performance_tracing(self) -> list[dict[str, Any]]:
195
+ """Stop tracing and return only events received for this collection window."""
196
+ if not self._tracing_active:
197
+ return []
198
+ try:
199
+ await self.command("Tracing.end")
200
+ await asyncio.wait_for(self._tracing_complete.wait(), timeout=5)
201
+ except (BrowserConnectionError, TimeoutError):
202
+ return []
203
+ finally:
204
+ self._tracing_active = False
205
+ events = self._trace_events
206
+ self._trace_events = []
207
+ return events
208
+
209
+ async def close(self) -> None:
210
+ socket = self._socket
211
+ self._socket = None
212
+ if socket is not None:
213
+ await socket.close()
214
+ task = self._reader_task
215
+ self._reader_task = None
216
+ if task is not None and task is not asyncio.current_task():
217
+ task.cancel()
218
+ try:
219
+ await task
220
+ except asyncio.CancelledError:
221
+ pass
222
+ for future in self._pending.values():
223
+ if not future.done():
224
+ future.set_exception(BrowserConnectionError("The browser connection closed."))
225
+ self._pending.clear()
226
+
227
+ async def _read_messages(self) -> None:
228
+ assert self._socket is not None
229
+ try:
230
+ async for raw_message in self._socket:
231
+ message = json.loads(raw_message)
232
+ if "id" in message:
233
+ future = self._pending.get(message["id"])
234
+ if future is not None and not future.done():
235
+ if "error" in message:
236
+ future.set_exception(BrowserConnectionError(message["error"].get("message", "CDP command failed.")))
237
+ else:
238
+ future.set_result(message.get("result", {}))
239
+ elif isinstance(message.get("method"), str):
240
+ if message["method"] == "Tracing.dataCollected":
241
+ values = message.get("params", {}).get("value", [])
242
+ if isinstance(values, list):
243
+ self._trace_events.extend(item for item in values if isinstance(item, dict))
244
+ elif message["method"] == "Tracing.tracingComplete":
245
+ self._tracing_complete.set()
246
+ try:
247
+ self._on_event(message["method"], message.get("params", {}))
248
+ except Exception:
249
+ continue
250
+ except (ConnectionClosed, OSError, json.JSONDecodeError):
251
+ pass
252
+ finally:
253
+ for future in self._pending.values():
254
+ if not future.done():
255
+ future.set_exception(BrowserConnectionError("The browser debugging connection was lost."))
256
+
257
+
258
+ @dataclass
259
+ class _Request:
260
+ url: str
261
+ method: str
262
+ resource_type: str | None
263
+ start_time: float
264
+ initiator: str | None
265
+
266
+
267
+ class DiagnosticCollector:
268
+ def __init__(self, target_id: str, max_issues: int = 1000):
269
+ self.target_id = target_id
270
+ self.monitoring = True
271
+ self._issues: OrderedDict[str, Issue] = OrderedDict()
272
+ self._requests: dict[str, _Request] = {}
273
+ self._performance_actions: list[dict[str, Any]] = []
274
+ self.performance_observer_supported: bool | None = None
275
+ self._max_issues = max_issues
276
+
277
+ @property
278
+ def issues(self) -> list[Issue]:
279
+ return list(self._issues.values())
280
+
281
+ def handle_event(self, method: str, params: dict[str, Any]) -> None:
282
+ if not self.monitoring:
283
+ return
284
+ if method == "Runtime.consoleAPICalled":
285
+ self._console(params)
286
+ elif method == "Runtime.exceptionThrown":
287
+ self._exception(params)
288
+ elif method == "Network.requestWillBeSent":
289
+ self._request(params)
290
+ elif method == "Network.responseReceived":
291
+ self._response(params)
292
+ elif method == "Network.loadingFailed":
293
+ self._loading_failed(params)
294
+
295
+ def _add(self, issue: Issue) -> None:
296
+ self._issues.setdefault(issue.issue_id, issue)
297
+ self._issues.move_to_end(issue.issue_id)
298
+ while len(self._issues) > self._max_issues:
299
+ self._issues.popitem(last=False)
300
+
301
+ @staticmethod
302
+ def _location(stack: dict[str, Any] | None) -> SourceLocation | None:
303
+ frames = (stack or {}).get("callFrames", [])
304
+ if not frames:
305
+ return None
306
+ frame = frames[0]
307
+ url = frame.get("url")
308
+ if not url:
309
+ return None
310
+ return SourceLocation(
311
+ url=redact_url(url),
312
+ line=frame.get("lineNumber", 0) + 1,
313
+ column=frame.get("columnNumber", 0) + 1,
314
+ function=redact_text(frame.get("functionName") or "", 120) or None,
315
+ )
316
+
317
+ def _console(self, params: dict[str, Any]) -> None:
318
+ level = params.get("type", "log")
319
+ raw_text = " ".join(
320
+ str(item.get("description", item.get("value", "")))
321
+ for item in params.get("args", [])
322
+ if isinstance(item, dict)
323
+ )
324
+ if level == "debug" and raw_text.startswith(_PERFORMANCE_MARKER):
325
+ self._performance_message(raw_text.removeprefix(_PERFORMANCE_MARKER))
326
+ return
327
+ if level not in ("error", "warning"):
328
+ return
329
+ text = redact_text(raw_text or "Console reported an empty message.")
330
+ location = self._location(params.get("stackTrace"))
331
+ self._add(
332
+ build_issue(
333
+ category="console",
334
+ severity="error" if level == "error" else "warning",
335
+ confidence="confirmed",
336
+ title=f"Console {level}: {text[:160]}",
337
+ explanation=text,
338
+ url=location.url if location else None,
339
+ target_id=self.target_id,
340
+ source_location=location,
341
+ evidence={"console_type": level},
342
+ )
343
+ )
344
+
345
+ def add_trace_events(self, events: list[dict[str, Any]], page_url: str | None) -> None:
346
+ """Add CDP trace fallback findings after a trace has been explicitly collected."""
347
+ trace_actions: list[dict[str, Any]] = []
348
+ for event in events:
349
+ if event.get("name") != "EventDispatch" or event.get("ph") != "X":
350
+ continue
351
+ start = self._number(event.get("ts"))
352
+ duration = self._number(event.get("dur"))
353
+ if start is None or duration is None:
354
+ continue
355
+ args = event.get("args")
356
+ data = args.get("data") if isinstance(args, dict) else None
357
+ event_type = data.get("type", "unknown") if isinstance(data, dict) else "unknown"
358
+ trace_actions.append({"name": redact_text(str(event_type), 80), "start": start, "end": start + duration})
359
+ for event in events:
360
+ if event.get("name") != "RunTask" or event.get("ph") != "X":
361
+ continue
362
+ start = self._number(event.get("ts"))
363
+ duration_us = self._number(event.get("dur"))
364
+ if start is None or duration_us is None or duration_us < 50_000:
365
+ continue
366
+ end = start + duration_us
367
+ actions = [action for action in trace_actions if action["start"] <= end and action["end"] >= start]
368
+ duration_ms = duration_us / 1000
369
+ evidence: dict[str, Any] = {
370
+ "source": "CDP Tracing fallback",
371
+ "trace_timestamp_us": start,
372
+ "duration_ms": duration_ms,
373
+ "trace_event": "RunTask",
374
+ }
375
+ if actions:
376
+ evidence["action_association"] = "timing_correlated"
377
+ evidence["actions"] = actions
378
+ self._add(
379
+ build_issue(
380
+ category="performance",
381
+ severity="warning",
382
+ confidence="confirmed",
383
+ title=f"Long main-thread task: {duration_ms:.1f} ms at trace {start:.0f} us",
384
+ explanation=f"CDP tracing observed a main-thread RunTask lasting {duration_ms:.1f} ms.",
385
+ url=page_url,
386
+ target_id=self.target_id,
387
+ evidence=evidence,
388
+ )
389
+ )
390
+
391
+ @staticmethod
392
+ def _number(value: Any) -> float | None:
393
+ if isinstance(value, bool):
394
+ return None
395
+ try:
396
+ number = float(value)
397
+ except (TypeError, ValueError):
398
+ return None
399
+ return number if number >= 0 else None
400
+
401
+ def _performance_message(self, payload: str) -> None:
402
+ try:
403
+ message = json.loads(payload)
404
+ except json.JSONDecodeError:
405
+ return
406
+ if not isinstance(message, dict):
407
+ return
408
+ kind = message.get("kind")
409
+ if kind == "support":
410
+ supported = message.get("longtask")
411
+ self.performance_observer_supported = supported if isinstance(supported, bool) else False
412
+ return
413
+ if kind == "action":
414
+ start = self._number(message.get("processingStart"))
415
+ end = self._number(message.get("processingEnd"))
416
+ if start is None or end is None or end < start:
417
+ return
418
+ self._performance_actions.append({"name": redact_text(str(message.get("name") or "unknown"), 80), "start": start, "end": end})
419
+ self._performance_actions = self._performance_actions[-100:]
420
+ return
421
+ if kind != "longtask":
422
+ return
423
+ start = self._number(message.get("startTime"))
424
+ duration = self._number(message.get("duration"))
425
+ if start is None or duration is None or duration < 50:
426
+ return
427
+ end = start + duration
428
+ actions = [action for action in self._performance_actions if action["start"] <= end and action["end"] >= start]
429
+ url = redact_url(str(message.get("url") or "")) or None
430
+ attribution = message.get("attribution")
431
+ evidence: dict[str, Any] = {
432
+ "source": "PerformanceObserver.longtask",
433
+ "start_time_ms": start,
434
+ "duration_ms": duration,
435
+ "attribution_name": redact_text(str(message.get("name") or "unknown"), 120),
436
+ }
437
+ if isinstance(attribution, list):
438
+ evidence["attribution"] = [
439
+ {key: redact_text(str(item.get(key) or ""), 240) for key in ("containerId", "containerName", "containerSrc")}
440
+ for item in attribution[:10]
441
+ if isinstance(item, dict)
442
+ ]
443
+ if actions:
444
+ evidence["action_association"] = "timing_correlated"
445
+ evidence["actions"] = actions
446
+ self._add(
447
+ build_issue(
448
+ category="performance",
449
+ severity="warning",
450
+ confidence="confirmed",
451
+ title=f"Long main-thread task: {duration:.1f} ms at {start:.1f} ms",
452
+ explanation=f"The page main thread was busy for {duration:.1f} ms.",
453
+ url=url,
454
+ target_id=self.target_id,
455
+ evidence=evidence,
456
+ )
457
+ )
458
+
459
+ def _exception(self, params: dict[str, Any]) -> None:
460
+ details = params.get("exceptionDetails", {})
461
+ exception = details.get("exception", {})
462
+ raw_text = exception.get("description") or details.get("text") or "Uncaught JavaScript exception"
463
+ text = redact_text(str(raw_text))
464
+ location = self._location(details.get("stackTrace"))
465
+ url = redact_url(details["url"]) if details.get("url") else (location.url if location else None)
466
+ self._add(
467
+ build_issue(
468
+ category="runtime",
469
+ severity="error",
470
+ confidence="confirmed",
471
+ title=f"Uncaught exception: {text[:160]}",
472
+ explanation=text,
473
+ url=url,
474
+ target_id=self.target_id,
475
+ source_location=location,
476
+ evidence={"exception_text": text[:1000]},
477
+ )
478
+ )
479
+
480
+ def _request(self, params: dict[str, Any]) -> None:
481
+ request = params.get("request", {})
482
+ request_id = params.get("requestId")
483
+ if not request_id:
484
+ return
485
+ initiator = params.get("initiator", {}).get("type")
486
+ self._requests[request_id] = _Request(
487
+ url=redact_url(request.get("url", "")),
488
+ method=request.get("method", "GET"),
489
+ resource_type=params.get("type"),
490
+ start_time=float(params.get("timestamp", 0)),
491
+ initiator=initiator,
492
+ )
493
+ if len(self._requests) > 1000:
494
+ self._requests.pop(next(iter(self._requests)))
495
+
496
+ def _response(self, params: dict[str, Any]) -> None:
497
+ response = params.get("response", {})
498
+ status = int(response.get("status", 0))
499
+ if status < 400:
500
+ return
501
+ request = self._requests.get(params.get("requestId"), _Request(redact_url(response.get("url", "")), "GET", None, 0, None))
502
+ self._add(
503
+ build_issue(
504
+ category="network",
505
+ severity="error" if status >= 500 else "warning",
506
+ confidence="confirmed",
507
+ title=f"HTTP {status} response",
508
+ explanation=f"{request.method} {request.url} returned HTTP {status}.",
509
+ url=request.url,
510
+ target_id=self.target_id,
511
+ evidence={
512
+ "status": status,
513
+ "resource_type": params.get("type") or request.resource_type,
514
+ "initiator": request.initiator,
515
+ },
516
+ )
517
+ )
518
+
519
+ def _loading_failed(self, params: dict[str, Any]) -> None:
520
+ request = self._requests.get(params.get("requestId"))
521
+ if request is None:
522
+ return
523
+ canceled = bool(params.get("canceled"))
524
+ reason = redact_text(params.get("errorText", "Network request failed"), 240)
525
+ self._add(
526
+ build_issue(
527
+ category="network",
528
+ severity="warning" if canceled else "error",
529
+ confidence="correlated" if canceled else "confirmed",
530
+ title=f"Request {'aborted' if canceled else 'failed'}: {request.method} {request.url}",
531
+ explanation=f"{request.method} {request.url} failed: {reason}.",
532
+ url=request.url,
533
+ target_id=self.target_id,
534
+ evidence={"error_text": reason, "resource_type": request.resource_type, "initiator": request.initiator},
535
+ )
536
+ )
537
+
538
+
539
+ class BrowserSession:
540
+ def __init__(self):
541
+ self.endpoint: str | None = None
542
+ self.target: dict[str, Any] | None = None
543
+ self.collector: DiagnosticCollector | None = None
544
+ self.client: CDPClient | None = None
545
+ self._lock = asyncio.Lock()
546
+
547
+ async def list_targets(self, endpoint: str) -> list[dict[str, str]]:
548
+ targets = await asyncio.to_thread(discover_targets, endpoint)
549
+ return [
550
+ {"target_id": target.get("id", ""), "title": target.get("title", ""), "url": redact_url(target.get("url", ""))}
551
+ for target in targets
552
+ ]
553
+
554
+ async def connect(self, endpoint: str, target_id: str) -> dict[str, Any]:
555
+ async with self._lock:
556
+ await self.disconnect()
557
+ targets = await asyncio.to_thread(discover_targets, endpoint)
558
+ target = next((item for item in targets if item.get("id") == target_id), None)
559
+ if target is None:
560
+ raise BrowserConnectionError(f"Page target {target_id!r} is unavailable. List targets and select an open page.")
561
+ ws_url = target.get("webSocketDebuggerUrl")
562
+ if not isinstance(ws_url, str):
563
+ raise BrowserConnectionError("The selected page does not expose a CDP WebSocket URL.")
564
+ _validate_websocket_target(ws_url, endpoint)
565
+ collector = DiagnosticCollector(target_id)
566
+ client = CDPClient(ws_url, collector.handle_event)
567
+ await client.open()
568
+ await client.install_performance_observer()
569
+ await client.start_performance_tracing()
570
+ self.endpoint = endpoint
571
+ self.target = {"target_id": target_id, "title": target.get("title", ""), "url": redact_url(target.get("url", ""))}
572
+ self.collector = collector
573
+ self.client = client
574
+ return self.status()
575
+
576
+ async def disconnect(self) -> dict[str, Any]:
577
+ client = self.client
578
+ self.client = None
579
+ if client is not None:
580
+ await client.close()
581
+ self.endpoint = None
582
+ self.target = None
583
+ self.collector = None
584
+ return self.status()
585
+
586
+ def status(self) -> dict[str, Any]:
587
+ return {
588
+ "connected": bool(self.client and self.client.connected),
589
+ "endpoint": self.endpoint,
590
+ "target": self.target,
591
+ "monitoring": bool(self.collector and self.collector.monitoring),
592
+ }
593
+
594
+ def diagnostics(self, category: str | None = None) -> list[Issue]:
595
+ if self.collector is None:
596
+ raise BrowserConnectionError("Connect to a browser target before requesting diagnostics.")
597
+ issues = self.collector.issues
598
+ return [issue for issue in issues if category is None or issue.category == category]
599
+
600
+ async def performance_diagnostics(self) -> list[Issue]:
601
+ if self.collector is None or self.client is None:
602
+ raise BrowserConnectionError("Connect to a browser target before requesting performance diagnostics.")
603
+ events = await self.client.stop_performance_tracing()
604
+ page_url = self.target.get("url") if self.target else None
605
+ self.collector.add_trace_events(events, page_url)
606
+ await self.client.start_performance_tracing()
607
+ return self.diagnostics("performance")