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.
- devtools_inspector/__init__.py +3 -0
- devtools_inspector/cdp.py +607 -0
- devtools_inspector/fixing.py +310 -0
- devtools_inspector/models.py +65 -0
- devtools_inspector/redaction.py +43 -0
- devtools_inspector/server.py +123 -0
- devtools_inspector/setup.py +159 -0
- devtools_inspector_mcp-0.1.0.dist-info/METADATA +242 -0
- devtools_inspector_mcp-0.1.0.dist-info/RECORD +11 -0
- devtools_inspector_mcp-0.1.0.dist-info/WHEEL +4 -0
- devtools_inspector_mcp-0.1.0.dist-info/entry_points.txt +3 -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")
|