webtap-tool 0.11.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.
Files changed (64) hide show
  1. webtap/VISION.md +246 -0
  2. webtap/__init__.py +84 -0
  3. webtap/__main__.py +6 -0
  4. webtap/api/__init__.py +9 -0
  5. webtap/api/app.py +26 -0
  6. webtap/api/models.py +69 -0
  7. webtap/api/server.py +111 -0
  8. webtap/api/sse.py +182 -0
  9. webtap/api/state.py +89 -0
  10. webtap/app.py +79 -0
  11. webtap/cdp/README.md +275 -0
  12. webtap/cdp/__init__.py +12 -0
  13. webtap/cdp/har.py +302 -0
  14. webtap/cdp/schema/README.md +41 -0
  15. webtap/cdp/schema/cdp_protocol.json +32785 -0
  16. webtap/cdp/schema/cdp_version.json +8 -0
  17. webtap/cdp/session.py +667 -0
  18. webtap/client.py +81 -0
  19. webtap/commands/DEVELOPER_GUIDE.md +401 -0
  20. webtap/commands/TIPS.md +269 -0
  21. webtap/commands/__init__.py +29 -0
  22. webtap/commands/_builders.py +331 -0
  23. webtap/commands/_code_generation.py +110 -0
  24. webtap/commands/_tips.py +147 -0
  25. webtap/commands/_utils.py +273 -0
  26. webtap/commands/connection.py +220 -0
  27. webtap/commands/console.py +87 -0
  28. webtap/commands/fetch.py +310 -0
  29. webtap/commands/filters.py +116 -0
  30. webtap/commands/javascript.py +73 -0
  31. webtap/commands/js_export.py +73 -0
  32. webtap/commands/launch.py +72 -0
  33. webtap/commands/navigation.py +197 -0
  34. webtap/commands/network.py +136 -0
  35. webtap/commands/quicktype.py +306 -0
  36. webtap/commands/request.py +93 -0
  37. webtap/commands/selections.py +138 -0
  38. webtap/commands/setup.py +219 -0
  39. webtap/commands/to_model.py +163 -0
  40. webtap/daemon.py +185 -0
  41. webtap/daemon_state.py +53 -0
  42. webtap/filters.py +219 -0
  43. webtap/rpc/__init__.py +14 -0
  44. webtap/rpc/errors.py +49 -0
  45. webtap/rpc/framework.py +223 -0
  46. webtap/rpc/handlers.py +625 -0
  47. webtap/rpc/machine.py +84 -0
  48. webtap/services/README.md +83 -0
  49. webtap/services/__init__.py +15 -0
  50. webtap/services/console.py +124 -0
  51. webtap/services/dom.py +547 -0
  52. webtap/services/fetch.py +415 -0
  53. webtap/services/main.py +392 -0
  54. webtap/services/network.py +401 -0
  55. webtap/services/setup/__init__.py +185 -0
  56. webtap/services/setup/chrome.py +233 -0
  57. webtap/services/setup/desktop.py +255 -0
  58. webtap/services/setup/extension.py +147 -0
  59. webtap/services/setup/platform.py +162 -0
  60. webtap/services/state_snapshot.py +86 -0
  61. webtap_tool-0.11.0.dist-info/METADATA +535 -0
  62. webtap_tool-0.11.0.dist-info/RECORD +64 -0
  63. webtap_tool-0.11.0.dist-info/WHEEL +4 -0
  64. webtap_tool-0.11.0.dist-info/entry_points.txt +2 -0
webtap/rpc/handlers.py ADDED
@@ -0,0 +1,625 @@
1
+ """RPC method handlers - thin wrappers around WebTapService.
2
+
3
+ This module contains all RPC method implementations. Handlers receive RPCContext
4
+ and delegate to WebTapService for business logic. State transitions are managed
5
+ by the ConnectionMachine via ctx.machine.
6
+
7
+ Handler categories:
8
+ - Connection Management: connect, disconnect, pages, status, clear
9
+ - Browser Inspection: browser.startInspect, browser.stopInspect, browser.clear
10
+ - Fetch Interception: fetch.enable, fetch.disable, fetch.resume, fetch.fail, fetch.fulfill
11
+ - Data Queries: network, request, console
12
+ - Filter Management: filters.*
13
+ - Navigation: navigate, reload, back, forward, history, page
14
+ - JavaScript: js
15
+ - Other: cdp, errors.dismiss
16
+
17
+ PUBLIC API:
18
+ - register_handlers: Register all RPC handlers with framework
19
+ - CONNECTED_STATES: States where connected operations are valid
20
+ - CONNECTED_ONLY: States where only connected (not inspecting) is valid
21
+ """
22
+
23
+ from webtap.rpc.errors import ErrorCode, RPCError
24
+ from webtap.rpc.framework import RPCContext, RPCFramework
25
+
26
+ # Common state requirements
27
+ CONNECTED_STATES = ["connected", "inspecting"]
28
+ CONNECTED_ONLY = ["connected"]
29
+
30
+
31
+ __all__ = ["register_handlers", "CONNECTED_STATES", "CONNECTED_ONLY"]
32
+
33
+
34
+ def register_handlers(rpc: RPCFramework) -> None:
35
+ """Register all RPC handlers with the framework.
36
+
37
+ Args:
38
+ rpc: RPCFramework instance to register handlers with
39
+ """
40
+ rpc.method("connect")(connect)
41
+ rpc.method("disconnect", requires_state=CONNECTED_STATES)(disconnect)
42
+ rpc.method("pages", broadcasts=False)(pages)
43
+ rpc.method("status", broadcasts=False)(status)
44
+ rpc.method("clear", requires_state=CONNECTED_STATES)(clear)
45
+
46
+ rpc.method("browser.startInspect", requires_state=CONNECTED_ONLY)(browser_start_inspect)
47
+ rpc.method("browser.stopInspect", requires_state=["inspecting"])(browser_stop_inspect)
48
+ rpc.method("browser.clear", requires_state=CONNECTED_STATES)(browser_clear)
49
+
50
+ rpc.method("fetch.enable", requires_state=CONNECTED_STATES)(fetch_enable)
51
+ rpc.method("fetch.disable", requires_state=CONNECTED_STATES)(fetch_disable)
52
+ rpc.method("fetch.resume", requires_state=CONNECTED_STATES, requires_paused_request=True)(fetch_resume)
53
+ rpc.method("fetch.fail", requires_state=CONNECTED_STATES, requires_paused_request=True)(fetch_fail)
54
+ rpc.method("fetch.fulfill", requires_state=CONNECTED_STATES, requires_paused_request=True)(fetch_fulfill)
55
+
56
+ rpc.method("network", requires_state=CONNECTED_STATES, broadcasts=False)(network)
57
+ rpc.method("request", requires_state=CONNECTED_STATES, broadcasts=False)(request)
58
+ rpc.method("console", requires_state=CONNECTED_STATES, broadcasts=False)(console)
59
+
60
+ rpc.method("filters.status", broadcasts=False)(filters_status)
61
+ rpc.method("filters.add")(filters_add)
62
+ rpc.method("filters.remove")(filters_remove)
63
+ rpc.method("filters.enable", requires_state=CONNECTED_STATES)(filters_enable)
64
+ rpc.method("filters.disable", requires_state=CONNECTED_STATES)(filters_disable)
65
+ rpc.method("filters.enableAll", requires_state=CONNECTED_STATES)(filters_enable_all)
66
+ rpc.method("filters.disableAll", requires_state=CONNECTED_STATES)(filters_disable_all)
67
+
68
+ rpc.method("navigate", requires_state=CONNECTED_STATES)(navigate)
69
+ rpc.method("reload", requires_state=CONNECTED_STATES)(reload)
70
+ rpc.method("back", requires_state=CONNECTED_STATES)(back)
71
+ rpc.method("forward", requires_state=CONNECTED_STATES)(forward)
72
+ rpc.method("history", requires_state=CONNECTED_STATES, broadcasts=False)(history)
73
+ rpc.method("page", requires_state=CONNECTED_STATES, broadcasts=False)(page)
74
+
75
+ rpc.method("js", requires_state=CONNECTED_STATES)(js)
76
+
77
+ rpc.method("cdp", requires_state=CONNECTED_STATES)(cdp)
78
+ rpc.method("errors.dismiss")(errors_dismiss)
79
+
80
+
81
+ def connect(ctx: RPCContext, page_id: str | None = None, page: int | None = None) -> dict:
82
+ """Connect to a Chrome page by index or page ID.
83
+
84
+ Args:
85
+ page_id: Chrome page ID. Defaults to None.
86
+ page: Page index. Defaults to None.
87
+
88
+ Returns:
89
+ Connection result with page details.
90
+
91
+ Raises:
92
+ RPCError: If connection fails or invalid parameters.
93
+ """
94
+ if page is not None and page_id is not None:
95
+ raise RPCError(ErrorCode.INVALID_PARAMS, "Cannot specify both 'page' and 'page_id'")
96
+ if page is None and page_id is None:
97
+ raise RPCError(ErrorCode.INVALID_PARAMS, "Must specify 'page' or 'page_id'")
98
+
99
+ if ctx.service.cdp.is_connected:
100
+ current_info = ctx.service.cdp.page_info or {}
101
+ current_id = current_info.get("id")
102
+ if page_id and page_id == current_id:
103
+ return {
104
+ "connected": True,
105
+ "already_connected": True,
106
+ "title": current_info.get("title", ""),
107
+ "url": current_info.get("url", ""),
108
+ }
109
+
110
+ ctx.machine.start_connect()
111
+
112
+ try:
113
+ result = ctx.service.connect_to_page(page_index=page, page_id=page_id)
114
+ ctx.machine.connect_success()
115
+ return {"connected": True, **result}
116
+
117
+ except Exception as e:
118
+ ctx.machine.connect_failed()
119
+ raise RPCError(ErrorCode.NOT_CONNECTED, str(e))
120
+
121
+
122
+ def disconnect(ctx: RPCContext) -> dict:
123
+ """Disconnect from currently connected page."""
124
+ ctx.machine.start_disconnect()
125
+
126
+ try:
127
+ ctx.service.disconnect()
128
+ ctx.machine.disconnect_complete()
129
+ return {"disconnected": True}
130
+
131
+ except Exception as e:
132
+ # Still complete the transition even if there's an error
133
+ ctx.machine.disconnect_complete()
134
+ raise RPCError(ErrorCode.INTERNAL_ERROR, str(e))
135
+
136
+
137
+ def pages(ctx: RPCContext) -> dict:
138
+ """Get available Chrome pages from /json endpoint."""
139
+ try:
140
+ pages_data = ctx.service.cdp.list_pages()
141
+ return {"pages": pages_data}
142
+ except Exception as e:
143
+ raise RPCError(ErrorCode.INTERNAL_ERROR, f"Failed to list pages: {e}")
144
+
145
+
146
+ def status(ctx: RPCContext) -> dict:
147
+ """Get comprehensive status including connection, events, browser, and fetch details."""
148
+ from webtap.api.state import get_full_state
149
+
150
+ return get_full_state()
151
+
152
+
153
+ def clear(ctx: RPCContext, events: bool = True, console: bool = False) -> dict:
154
+ """Clear various data stores.
155
+
156
+ Args:
157
+ events: Clear CDP events. Defaults to True.
158
+ console: Clear browser console. Defaults to False.
159
+ """
160
+ cleared = []
161
+
162
+ if events:
163
+ ctx.service.cdp.clear_events()
164
+ cleared.append("events")
165
+
166
+ if console:
167
+ if ctx.service.cdp.is_connected:
168
+ success = ctx.service.console.clear_browser_console()
169
+ if success:
170
+ cleared.append("console")
171
+ else:
172
+ cleared.append("console (not connected)")
173
+
174
+ return {"cleared": cleared}
175
+
176
+
177
+ def browser_start_inspect(ctx: RPCContext) -> dict:
178
+ """Enable CDP element inspection mode."""
179
+ ctx.machine.start_inspect()
180
+ result = ctx.service.dom.start_inspect()
181
+ return {**result}
182
+
183
+
184
+ def browser_stop_inspect(ctx: RPCContext) -> dict:
185
+ """Disable CDP element inspection mode."""
186
+ ctx.machine.stop_inspect()
187
+ result = ctx.service.dom.stop_inspect()
188
+ return {**result}
189
+
190
+
191
+ def browser_clear(ctx: RPCContext) -> dict:
192
+ """Clear all element selections."""
193
+ ctx.service.dom.clear_selections()
194
+ return {"success": True, "selections": {}}
195
+
196
+
197
+ def fetch_enable(ctx: RPCContext, request: bool = True, response: bool = False) -> dict:
198
+ """Enable fetch request interception."""
199
+ result = ctx.service.fetch.enable(ctx.service.cdp, response_stage=response)
200
+ return {**result}
201
+
202
+
203
+ def fetch_disable(ctx: RPCContext) -> dict:
204
+ """Disable fetch request interception."""
205
+ result = ctx.service.fetch.disable()
206
+ return {**result}
207
+
208
+
209
+ def fetch_resume(ctx: RPCContext, id: int, paused: dict, modifications: dict | None = None, wait: float = 0.5) -> dict:
210
+ """Resume a paused request.
211
+
212
+ Args:
213
+ id: Request ID from network()
214
+ paused: Paused request dict (injected by framework)
215
+ modifications: Optional request/response modifications. Defaults to None.
216
+ wait: Wait time for follow-up events. Defaults to 0.5.
217
+ """
218
+ try:
219
+ result = ctx.service.fetch.continue_request(paused["rowid"], modifications, wait)
220
+
221
+ response = {
222
+ "id": id,
223
+ "resumed_from": result["resumed_from"],
224
+ "outcome": result["outcome"],
225
+ "remaining": result["remaining"],
226
+ }
227
+
228
+ if result.get("status"):
229
+ response["status"] = result["status"]
230
+
231
+ # For redirects, lookup new HAR ID
232
+ if result.get("redirect_request_id"):
233
+ new_har = ctx.service.cdp.query(
234
+ "SELECT id FROM har_summary WHERE request_id = ? LIMIT 1",
235
+ [result["redirect_request_id"]],
236
+ )
237
+ if new_har:
238
+ response["redirect_id"] = new_har[0][0]
239
+
240
+ return response
241
+ except Exception as e:
242
+ raise RPCError(ErrorCode.INTERNAL_ERROR, str(e))
243
+
244
+
245
+ def fetch_fail(ctx: RPCContext, id: int, paused: dict, reason: str = "BlockedByClient") -> dict:
246
+ """Fail a paused request.
247
+
248
+ Args:
249
+ id: Request ID from network()
250
+ paused: Paused request dict (injected by framework)
251
+ reason: CDP error reason. Defaults to "BlockedByClient".
252
+ """
253
+ try:
254
+ result = ctx.service.fetch.fail_request(paused["rowid"], reason)
255
+ return {
256
+ "id": id,
257
+ "outcome": "failed",
258
+ "reason": reason,
259
+ "remaining": result.get("remaining", 0),
260
+ }
261
+ except Exception as e:
262
+ raise RPCError(ErrorCode.INTERNAL_ERROR, str(e))
263
+
264
+
265
+ def fetch_fulfill(
266
+ ctx: RPCContext,
267
+ id: int,
268
+ paused: dict,
269
+ response_code: int = 200,
270
+ response_headers: list[dict[str, str]] | None = None,
271
+ body: str = "",
272
+ ) -> dict:
273
+ """Fulfill a paused request with a custom response.
274
+
275
+ Args:
276
+ id: Request ID from network()
277
+ paused: Paused request dict (injected by framework)
278
+ response_code: HTTP status code. Defaults to 200.
279
+ response_headers: Response headers. Defaults to None.
280
+ body: Response body. Defaults to "".
281
+ """
282
+ try:
283
+ result = ctx.service.fetch.fulfill_request(paused["rowid"], response_code, response_headers, body)
284
+ return {
285
+ "id": id,
286
+ "outcome": "fulfilled",
287
+ "response_code": response_code,
288
+ "remaining": result.get("remaining", 0),
289
+ }
290
+ except Exception as e:
291
+ raise RPCError(ErrorCode.INTERNAL_ERROR, str(e))
292
+
293
+
294
+ def network(
295
+ ctx: RPCContext,
296
+ limit: int = 50,
297
+ status: int | None = None,
298
+ method: str | None = None,
299
+ resource_type: str | None = None,
300
+ url: str | None = None,
301
+ state: str | None = None,
302
+ show_all: bool = False,
303
+ order: str = "desc",
304
+ ) -> dict:
305
+ """Query network requests with inline filters.
306
+
307
+ Args:
308
+ limit: Maximum number of requests to return. Defaults to 50.
309
+ status: Filter by HTTP status code. Defaults to None.
310
+ method: Filter by HTTP method. Defaults to None.
311
+ resource_type: Filter by resource type. Defaults to None.
312
+ url: Filter by URL pattern. Defaults to None.
313
+ state: Filter by request state. Defaults to None.
314
+ show_all: Show all requests without filter groups. Defaults to False.
315
+ order: Sort order ("asc" or "desc"). Defaults to "desc".
316
+ """
317
+ requests = ctx.service.network.get_requests(
318
+ limit=limit,
319
+ status=status,
320
+ method=method,
321
+ type_filter=resource_type,
322
+ url=url,
323
+ state=state,
324
+ apply_groups=not show_all,
325
+ order=order,
326
+ )
327
+ return {"requests": requests}
328
+
329
+
330
+ def request(ctx: RPCContext, id: int, fields: list[str] | None = None) -> dict:
331
+ """Get request details with field selection.
332
+
333
+ Args:
334
+ id: Request ID from network()
335
+ fields: List of fields to extract. Defaults to None.
336
+ """
337
+ entry = ctx.service.network.get_request_details(id)
338
+ if not entry:
339
+ raise RPCError(ErrorCode.INVALID_PARAMS, f"Request {id} not found")
340
+
341
+ selected = ctx.service.network.select_fields(entry, fields)
342
+ return {"entry": selected}
343
+
344
+
345
+ def console(ctx: RPCContext, limit: int = 50, level: str | None = None) -> dict:
346
+ """Get console messages.
347
+
348
+ Args:
349
+ limit: Maximum number of messages to return. Defaults to 50.
350
+ level: Filter by console level. Defaults to None.
351
+ """
352
+ rows = ctx.service.console.get_recent_messages(limit=limit, level=level)
353
+
354
+ messages = []
355
+ for row in rows:
356
+ rowid, msg_level, source, message, timestamp = row
357
+ messages.append(
358
+ {
359
+ "id": rowid,
360
+ "level": msg_level or "log",
361
+ "source": source or "console",
362
+ "message": message or "",
363
+ "timestamp": float(timestamp) if timestamp else None,
364
+ }
365
+ )
366
+
367
+ return {"messages": messages}
368
+
369
+
370
+ def filters_status(ctx: RPCContext) -> dict:
371
+ """Get all filter groups with enabled status."""
372
+ return ctx.service.filters.get_status()
373
+
374
+
375
+ def filters_add(ctx: RPCContext, name: str, hide: dict) -> dict:
376
+ """Add a new filter group."""
377
+ ctx.service.filters.add(name, hide)
378
+ return {"added": True, "name": name}
379
+
380
+
381
+ def filters_remove(ctx: RPCContext, name: str) -> dict:
382
+ """Remove a filter group."""
383
+ result = ctx.service.filters.remove(name)
384
+ if result:
385
+ return {"removed": True, "name": name}
386
+ return {"removed": False, "name": name}
387
+
388
+
389
+ def filters_enable(ctx: RPCContext, name: str) -> dict:
390
+ """Enable a filter group."""
391
+ result = ctx.service.filters.enable(name)
392
+ if result:
393
+ return {"enabled": True, "name": name}
394
+ raise RPCError(ErrorCode.INVALID_PARAMS, f"Group '{name}' not found")
395
+
396
+
397
+ def filters_disable(ctx: RPCContext, name: str) -> dict:
398
+ """Disable a filter group."""
399
+ result = ctx.service.filters.disable(name)
400
+ if result:
401
+ return {"disabled": True, "name": name}
402
+ raise RPCError(ErrorCode.INVALID_PARAMS, f"Group '{name}' not found")
403
+
404
+
405
+ def filters_enable_all(ctx: RPCContext) -> dict:
406
+ """Enable all filter groups."""
407
+ fm = ctx.service.filters
408
+ for name in fm.groups:
409
+ fm.enable(name)
410
+ return {"enabled": list(fm.enabled)}
411
+
412
+
413
+ def filters_disable_all(ctx: RPCContext) -> dict:
414
+ """Disable all filter groups."""
415
+ fm = ctx.service.filters
416
+ fm.enabled.clear()
417
+ return {"enabled": []}
418
+
419
+
420
+ def cdp(ctx: RPCContext, command: str, params: dict | None = None) -> dict:
421
+ """Execute arbitrary CDP command."""
422
+ try:
423
+ result = ctx.service.cdp.execute(command, params or {})
424
+ return {"result": result}
425
+ except Exception as e:
426
+ raise RPCError(ErrorCode.INTERNAL_ERROR, str(e))
427
+
428
+
429
+ def errors_dismiss(ctx: RPCContext) -> dict:
430
+ """Dismiss the current error."""
431
+ ctx.service.state.error_state = None
432
+ return {"success": True}
433
+
434
+
435
+ def navigate(ctx: RPCContext, url: str) -> dict:
436
+ """Navigate to URL.
437
+
438
+ Args:
439
+ url: Target URL
440
+ """
441
+ try:
442
+ result = ctx.service.cdp.execute("Page.navigate", {"url": url})
443
+ return {
444
+ "url": url,
445
+ "frame_id": result.get("frameId"),
446
+ "loader_id": result.get("loaderId"),
447
+ "error": result.get("errorText"),
448
+ }
449
+ except Exception as e:
450
+ raise RPCError(ErrorCode.INTERNAL_ERROR, f"Navigation failed: {e}")
451
+
452
+
453
+ def reload(ctx: RPCContext, ignore_cache: bool = False) -> dict:
454
+ """Reload current page.
455
+
456
+ Args:
457
+ ignore_cache: Ignore browser cache. Defaults to False.
458
+ """
459
+ try:
460
+ ctx.service.cdp.execute("Page.reload", {"ignoreCache": ignore_cache})
461
+ return {"reloaded": True, "ignore_cache": ignore_cache}
462
+ except Exception as e:
463
+ raise RPCError(ErrorCode.INTERNAL_ERROR, f"Reload failed: {e}")
464
+
465
+
466
+ def back(ctx: RPCContext) -> dict:
467
+ """Navigate back in history."""
468
+ try:
469
+ return _navigate_history(ctx, -1)
470
+ except Exception as e:
471
+ raise RPCError(ErrorCode.INTERNAL_ERROR, f"Back navigation failed: {e}")
472
+
473
+
474
+ def forward(ctx: RPCContext) -> dict:
475
+ """Navigate forward in history."""
476
+ try:
477
+ return _navigate_history(ctx, +1)
478
+ except Exception as e:
479
+ raise RPCError(ErrorCode.INTERNAL_ERROR, f"Forward navigation failed: {e}")
480
+
481
+
482
+ def _navigate_history(ctx: RPCContext, direction: int) -> dict:
483
+ """Navigate history by direction.
484
+
485
+ Args:
486
+ direction: -1 for back, +1 for forward
487
+ """
488
+ result = ctx.service.cdp.execute("Page.getNavigationHistory", {})
489
+ entries = result.get("entries", [])
490
+ current = result.get("currentIndex", 0)
491
+ target_idx = current + direction
492
+
493
+ if target_idx < 0:
494
+ return {"navigated": False, "reason": "Already at first entry"}
495
+ if target_idx >= len(entries):
496
+ return {"navigated": False, "reason": "Already at last entry"}
497
+
498
+ target = entries[target_idx]
499
+ ctx.service.cdp.execute("Page.navigateToHistoryEntry", {"entryId": target["id"]})
500
+
501
+ return {
502
+ "navigated": True,
503
+ "title": target.get("title", ""),
504
+ "url": target.get("url", ""),
505
+ "index": target_idx,
506
+ "total": len(entries),
507
+ }
508
+
509
+
510
+ def history(ctx: RPCContext) -> dict:
511
+ """Get navigation history."""
512
+ try:
513
+ result = ctx.service.cdp.execute("Page.getNavigationHistory", {})
514
+ entries = result.get("entries", [])
515
+ current = result.get("currentIndex", 0)
516
+
517
+ return {
518
+ "entries": [
519
+ {
520
+ "id": e.get("id"),
521
+ "url": e.get("url", ""),
522
+ "title": e.get("title", ""),
523
+ "type": e.get("transitionType", ""),
524
+ "current": i == current,
525
+ }
526
+ for i, e in enumerate(entries)
527
+ ],
528
+ "current_index": current,
529
+ }
530
+ except Exception as e:
531
+ raise RPCError(ErrorCode.INTERNAL_ERROR, f"History failed: {e}")
532
+
533
+
534
+ def page(ctx: RPCContext) -> dict:
535
+ """Get current page info with title from DOM."""
536
+ try:
537
+ result = ctx.service.cdp.execute("Page.getNavigationHistory", {})
538
+ entries = result.get("entries", [])
539
+ current_index = result.get("currentIndex", 0)
540
+
541
+ if not entries or current_index >= len(entries):
542
+ return {"url": "", "title": "", "id": None, "type": ""}
543
+
544
+ current = entries[current_index]
545
+
546
+ try:
547
+ title_result = ctx.service.cdp.execute(
548
+ "Runtime.evaluate", {"expression": "document.title", "returnByValue": True}
549
+ )
550
+ title = title_result.get("result", {}).get("value", current.get("title", ""))
551
+ except Exception:
552
+ title = current.get("title", "")
553
+
554
+ return {
555
+ "url": current.get("url", ""),
556
+ "title": title or "Untitled",
557
+ "id": current.get("id"),
558
+ "type": current.get("transitionType", ""),
559
+ }
560
+ except Exception as e:
561
+ raise RPCError(ErrorCode.INTERNAL_ERROR, f"Page info failed: {e}")
562
+
563
+
564
+ def js(
565
+ ctx: RPCContext,
566
+ code: str,
567
+ selection: int | None = None,
568
+ persist: bool = False,
569
+ await_promise: bool = False,
570
+ return_value: bool = True,
571
+ ) -> dict:
572
+ """Execute JavaScript in browser context.
573
+
574
+ Args:
575
+ code: JavaScript code to execute
576
+ selection: Browser selection number to bind to 'element' variable. Defaults to None.
577
+ persist: Keep variables in global scope. Defaults to False.
578
+ await_promise: Await promise results. Defaults to False.
579
+ return_value: Return the result value. Defaults to True.
580
+ """
581
+ try:
582
+ if selection is not None:
583
+ dom_state = ctx.service.dom.get_state()
584
+ selections = dom_state.get("selections", {})
585
+ sel_key = str(selection)
586
+
587
+ if sel_key not in selections:
588
+ available = ", ".join(selections.keys()) if selections else "none"
589
+ raise RPCError(ErrorCode.INVALID_PARAMS, f"Selection #{selection} not found. Available: {available}")
590
+
591
+ js_path = selections[sel_key].get("jsPath")
592
+ if not js_path:
593
+ raise RPCError(ErrorCode.INVALID_PARAMS, f"Selection #{selection} has no jsPath")
594
+
595
+ # Wrap with element binding (always fresh scope for selection)
596
+ code = f"(() => {{ const element = {js_path}; return ({code}); }})()"
597
+
598
+ elif not persist:
599
+ # Default: wrap in IIFE for fresh scope
600
+ code = f"(() => {{ return ({code}); }})()"
601
+
602
+ result = ctx.service.cdp.execute(
603
+ "Runtime.evaluate",
604
+ {
605
+ "expression": code,
606
+ "awaitPromise": await_promise,
607
+ "returnByValue": return_value,
608
+ },
609
+ )
610
+
611
+ if result.get("exceptionDetails"):
612
+ exception = result["exceptionDetails"]
613
+ error_text = exception.get("exception", {}).get("description", str(exception))
614
+ raise RPCError(ErrorCode.INTERNAL_ERROR, f"JavaScript error: {error_text}")
615
+
616
+ if return_value:
617
+ value = result.get("result", {}).get("value")
618
+ return {"value": value, "executed": True}
619
+ else:
620
+ return {"executed": True}
621
+
622
+ except RPCError:
623
+ raise
624
+ except Exception as e:
625
+ raise RPCError(ErrorCode.INTERNAL_ERROR, f"JS execution failed: {e}")