s-librarykit 0.5.0__tar.gz → 0.5.2__tar.gz

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 (106) hide show
  1. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/PKG-INFO +1 -1
  2. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/browser/capture_tap.py +91 -5
  3. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/pyproject.toml +1 -1
  4. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_capture_tap.py +24 -0
  5. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/.gitignore +0 -0
  6. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/.gitlab-ci.yml +0 -0
  7. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/AGENTS.md +0 -0
  8. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/CHANGELOG.md +0 -0
  9. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/LICENSE +0 -0
  10. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/README.md +0 -0
  11. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/docs/superpowers/specs/2026-06-24-unified-sessions-design.md +0 -0
  12. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/__init__.py +0 -0
  13. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/antibot/__init__.py +0 -0
  14. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/antibot/_helpers.py +0 -0
  15. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/antibot/cdp.py +0 -0
  16. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/antibot/curl_cffi.py +0 -0
  17. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/antibot/page_evaluate.py +0 -0
  18. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/antibot/warmup_minter.py +0 -0
  19. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/auth.py +0 -0
  20. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/blocks.py +0 -0
  21. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/browser/__init__.py +0 -0
  22. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/browser/_engine.py +0 -0
  23. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/browser/camoufox.py +0 -0
  24. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/browser/geometry.py +0 -0
  25. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/browser/launchers.py +0 -0
  26. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/browser/nodriver.py +0 -0
  27. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/browser/persistent.py +0 -0
  28. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/browser/state.py +0 -0
  29. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/bulk.py +0 -0
  30. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/checkpoint.py +0 -0
  31. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/config_util.py +0 -0
  32. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/contract.py +0 -0
  33. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/cookie_refresh.py +0 -0
  34. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/cookies.py +0 -0
  35. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/entities.py +0 -0
  36. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/enums.py +0 -0
  37. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/errmap.py +0 -0
  38. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/errors.py +0 -0
  39. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/factory.py +0 -0
  40. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/forms.py +0 -0
  41. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/graphql.py +0 -0
  42. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/limit.py +0 -0
  43. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/orchestration/__init__.py +0 -0
  44. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/orchestration/health.py +0 -0
  45. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/orchestration/onboarding.py +0 -0
  46. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/orchestration/session_loader.py +0 -0
  47. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/pagination.py +0 -0
  48. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/pool.py +0 -0
  49. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/ports.py +0 -0
  50. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/protocols.py +0 -0
  51. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/redaction.py +0 -0
  52. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/refresh.py +0 -0
  53. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/retry.py +0 -0
  54. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/rpc.py +0 -0
  55. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/secret_store.py +0 -0
  56. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/session_bridge.py +0 -0
  57. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/session_paths.py +0 -0
  58. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/sessions/__init__.py +0 -0
  59. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/sessions/_const.py +0 -0
  60. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/sessions/_fsutil.py +0 -0
  61. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/stream.py +0 -0
  62. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/transport/__init__.py +0 -0
  63. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/transport/_helpers.py +0 -0
  64. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/transport/http_client.py +0 -0
  65. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/transport/permissive.py +0 -0
  66. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/transport/rest_client.py +0 -0
  67. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/transport/transport_http.py +0 -0
  68. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/upload.py +0 -0
  69. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/librarykit/windows.py +0 -0
  70. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/conftest.py +0 -0
  71. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_antibot.py +0 -0
  72. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_auth.py +0 -0
  73. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_blocks.py +0 -0
  74. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_browser.py +0 -0
  75. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_bulk.py +0 -0
  76. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_checkpoint.py +0 -0
  77. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_config_util.py +0 -0
  78. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_cookie_refresh.py +0 -0
  79. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_cookies.py +0 -0
  80. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_errmap.py +0 -0
  81. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_errors.py +0 -0
  82. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_factory.py +0 -0
  83. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_forms.py +0 -0
  84. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_graphql.py +0 -0
  85. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_limit.py +0 -0
  86. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_limit_contract.py +0 -0
  87. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_nodriver_backend.py +0 -0
  88. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_orchestration.py +0 -0
  89. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_pagination.py +0 -0
  90. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_permissive_http.py +0 -0
  91. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_pool.py +0 -0
  92. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_redaction.py +0 -0
  93. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_refresh.py +0 -0
  94. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_retry.py +0 -0
  95. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_rpc.py +0 -0
  96. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_session_bridge.py +0 -0
  97. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_session_paths.py +0 -0
  98. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_sessions.py +0 -0
  99. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_stream.py +0 -0
  100. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_transport_client.py +0 -0
  101. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_transport_rest.py +0 -0
  102. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_transport_sync.py +0 -0
  103. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_upload.py +0 -0
  104. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_warmup_minter.py +0 -0
  105. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/tests/test_windows.py +0 -0
  106. {s_librarykit-0.5.0 → s_librarykit-0.5.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: s-librarykit
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Жирный КОРЕНЬ китов: единая error-иерархия, config/retry/paths-утили, checkpoint/resume, транспорт/auth/sessions — общий код для clikit, adapterkit и доменных пакетов.
5
5
  Author: Dmitry
6
6
  License: MIT
@@ -206,6 +206,8 @@ class _PlaywrightAdapter(_BaseAdapter):
206
206
  self._context = page.context
207
207
  # Request-объект → стабильный request_id (для сшивки request/response/failed).
208
208
  self._ids: "weakref.WeakKeyDictionary[Any, str]" = weakref.WeakKeyDictionary()
209
+ self._drain_stop: asyncio.Event | None = None
210
+ self._drain_task: Any = None
209
211
 
210
212
  def _id_for(self, request: Any) -> str:
211
213
  rid = self._ids.get(request)
@@ -222,6 +224,10 @@ class _PlaywrightAdapter(_BaseAdapter):
222
224
  await ctx.expose_binding(self._binding, self._on_binding)
223
225
  with contextlib.suppress(Exception):
224
226
  await ctx.add_init_script(self._init_script)
227
+ # Дренаж очереди — fallback для движков, где binding не виден из мира init-script
228
+ # (CamouFox/Firefox гонят его в изолированном мире). На chromium очередь пуста.
229
+ self._drain_stop = asyncio.Event()
230
+ self._drain_task = asyncio.ensure_future(self._drain_queue_loop())
225
231
  if self._h.on_request is not None:
226
232
  ctx.on("request", lambda r: self._spawn(self._handle_request(r)))
227
233
  if self._h.on_response is not None:
@@ -282,7 +288,11 @@ class _PlaywrightAdapter(_BaseAdapter):
282
288
  if self._body_filter(response.url, rtype, mime):
283
289
  try:
284
290
  raw = await response.body()
285
- body = raw[: self._max_body_bytes] if raw else raw
291
+ # Дропаем СЛИШКОМ большие тела (не пишем битый обрезок как «captured»):
292
+ if raw is not None and len(raw) > self._max_body_bytes:
293
+ body_error = f"body too large: {len(raw)} > {self._max_body_bytes}"
294
+ else:
295
+ body = raw
286
296
  except Exception as err: # noqa: BLE001
287
297
  body_error = f"{type(err).__name__}: {err}"[:160]
288
298
  await self._emit(self._h.on_response, NormalizedResponse(
@@ -303,7 +313,42 @@ class _PlaywrightAdapter(_BaseAdapter):
303
313
  return await self._context.storage_state()
304
314
  return {"cookies": [], "origins": []}
305
315
 
316
+ async def _drain_queue_loop(self) -> None:
317
+ """Периодически дренит globalThis.__captureTraceQueue → on_action (fallback CamouFox)."""
318
+ assert self._drain_stop is not None
319
+ while not self._drain_stop.is_set():
320
+ try:
321
+ await asyncio.wait_for(self._drain_stop.wait(), timeout=0.3)
322
+ break
323
+ except asyncio.TimeoutError:
324
+ pass
325
+ await self._drain_queue_once()
326
+
327
+ async def _drain_queue_once(self) -> None:
328
+ try:
329
+ items = await self._page.evaluate(
330
+ "() => { const q = globalThis.__captureTraceQueue || [];"
331
+ " globalThis.__captureTraceQueue = []; return q; }"
332
+ )
333
+ except Exception:
334
+ return
335
+ for s in items or []:
336
+ try:
337
+ payload = json.loads(s) if isinstance(s, str) else s
338
+ except Exception:
339
+ continue
340
+ await self._emit(self._h.on_action, NormalizedAction(raw=payload))
341
+
306
342
  async def detach(self) -> None:
343
+ # Остановить дренаж очереди trace (CamouFox-fallback) + финальный дренаж.
344
+ if self._drain_stop is not None:
345
+ self._drain_stop.set()
346
+ with contextlib.suppress(Exception):
347
+ await self._drain_queue_once()
348
+ if self._drain_task is not None:
349
+ self._drain_task.cancel()
350
+ with contextlib.suppress(Exception, asyncio.CancelledError):
351
+ await self._drain_task
307
352
  # Слушатели не снимаем поимённо (Playwright требует ссылку на сам хендлер):
308
353
  # контекст всё равно закрывает backend.aclose() сразу после detach. Здесь —
309
354
  # только дренаж фоновых задач нормализации, чтобы не потерять последние тела.
@@ -315,11 +360,23 @@ class _CdpAdapter(_BaseAdapter):
315
360
 
316
361
  family = "cdp"
317
362
 
363
+ # Cap на незакрытые rid: SSE/стриминг/зависшие соединения не эмитят
364
+ # LoadingFinished/Failed, иначе _pending/_resp_meta растут всю сессию.
365
+ _MAX_INFLIGHT = 4096
366
+
318
367
  def __init__(self, tab: Any, handlers: _Handlers, **kw: Any) -> None:
319
368
  super().__init__(tab, handlers, **kw)
320
369
  self._tab = tab
321
370
  self._pending: dict[str, dict[str, Any]] = {}
322
371
  self._resp_meta: dict[str, dict[str, Any]] = {}
372
+ self._ws_urls: dict[str, str] = {} # rid → реальный wss:// URL (из WebSocketCreated)
373
+
374
+ def _evict_stale(self) -> None:
375
+ """FIFO-эвикция самых старых висящих rid при переполнении (dict хранит порядок)."""
376
+ while len(self._pending) > self._MAX_INFLIGHT:
377
+ old = next(iter(self._pending))
378
+ self._pending.pop(old, None)
379
+ self._resp_meta.pop(old, None)
323
380
 
324
381
  async def attach(self) -> None:
325
382
  from nodriver import cdp # type: ignore[import-not-found]
@@ -332,6 +389,8 @@ class _CdpAdapter(_BaseAdapter):
332
389
  self._tab.add_handler(cdp.network.LoadingFinished, self._on_fin)
333
390
  self._tab.add_handler(cdp.network.LoadingFailed, self._on_failed)
334
391
  if self._capture_ws and self._h.on_ws is not None:
392
+ # WebSocketCreated несёт реальный URL (Frame*-события — только request_id).
393
+ self._tab.add_handler(cdp.network.WebSocketCreated, self._on_ws_created)
335
394
  self._tab.add_handler(cdp.network.WebSocketFrameSent, self._on_ws_sent)
336
395
  self._tab.add_handler(cdp.network.WebSocketFrameReceived, self._on_ws_recv)
337
396
  if self._h.on_action is not None and self._init_script:
@@ -348,7 +407,24 @@ class _CdpAdapter(_BaseAdapter):
348
407
 
349
408
  async def _on_req(self, ev: Any, *_: Any) -> None:
350
409
  rid = str(ev.request_id)
351
- entry = self._pending.setdefault(rid, {"id": self._next_id()})
410
+ # Chrome переиспользует тот же CDP requestId для цепочки редиректов и отдаёт
411
+ # промежуточный 3xx в ev.redirect_response (отдельного ResponseReceived НЕТ).
412
+ # Флашим завершённый хоп отдельной записью под его id — паритет с Playwright.
413
+ redirect = getattr(ev, "redirect_response", None)
414
+ if redirect is not None:
415
+ prev = self._pending.pop(rid, None)
416
+ self._resp_meta.pop(rid, None)
417
+ if prev is not None:
418
+ await self._emit(self._h.on_response, NormalizedResponse(
419
+ request_id=prev["id"], status=int(getattr(redirect, "status", 0) or 0),
420
+ status_text=getattr(redirect, "status_text", "") or "",
421
+ headers=_lower_headers(getattr(redirect, "headers", {}) or {}),
422
+ mime_type=getattr(redirect, "mime_type", "") or "", body=None, body_error=None,
423
+ ))
424
+ entry = self._pending.get(rid)
425
+ if entry is None:
426
+ entry = {"id": self._next_id()}
427
+ self._pending[rid] = entry
352
428
  req = ev.request
353
429
  post = None
354
430
  pd = getattr(req, "post_data", None)
@@ -360,6 +436,7 @@ class _CdpAdapter(_BaseAdapter):
360
436
  frame_url=ev.document_url or "", post_data=post,
361
437
  )
362
438
  await self._emit(self._h.on_request, entry["norm"])
439
+ self._evict_stale()
363
440
 
364
441
  async def _on_resp(self, ev: Any, *_: Any) -> None:
365
442
  rid = str(ev.request_id)
@@ -372,6 +449,7 @@ class _CdpAdapter(_BaseAdapter):
372
449
  "mime": getattr(resp, "mime_type", "") or "",
373
450
  "rtype": self._rtype(ev.type_),
374
451
  }
452
+ self._evict_stale()
375
453
 
376
454
  async def _on_fin(self, ev: Any, *_: Any) -> None:
377
455
  rid = str(ev.request_id)
@@ -392,7 +470,10 @@ class _CdpAdapter(_BaseAdapter):
392
470
  try:
393
471
  raw, b64 = await self._tab.send(self._cdp.network.get_response_body(ev.request_id))
394
472
  data = _b64.b64decode(raw) if b64 else (raw or "").encode("utf-8", "replace")
395
- body = data[: self._max_body_bytes]
473
+ if len(data) > self._max_body_bytes:
474
+ body_error = f"body too large: {len(data)} > {self._max_body_bytes}"
475
+ else:
476
+ body = data
396
477
  last_err = None
397
478
  break
398
479
  except Exception as err: # noqa: BLE001
@@ -414,6 +495,10 @@ class _CdpAdapter(_BaseAdapter):
414
495
  err = getattr(ev, "error_text", "") or ("canceled" if getattr(ev, "canceled", False) else "failed")
415
496
  await self._emit(self._h.on_failure, NormalizedFailure(request_id=entry["id"], error_text=str(err)))
416
497
 
498
+ async def _on_ws_created(self, ev: Any, *_: Any) -> None:
499
+ # Единственное CDP-событие WS с реальным URL — запоминаем rid→url.
500
+ self._ws_urls[str(getattr(ev, "request_id", ""))] = getattr(ev, "url", "") or ""
501
+
417
502
  async def _on_ws_sent(self, ev: Any, *_: Any) -> None:
418
503
  await self._ws(ev, "sent")
419
504
 
@@ -424,8 +509,9 @@ class _CdpAdapter(_BaseAdapter):
424
509
  resp = getattr(ev, "response", None)
425
510
  payload = getattr(resp, "payload_data", "") if resp is not None else ""
426
511
  opcode = getattr(resp, "opcode", None) if resp is not None else None
512
+ rid = str(getattr(ev, "request_id", ""))
427
513
  await self._emit(self._h.on_ws, NormalizedWsFrame(
428
- url=str(getattr(ev, "request_id", "")), direction=direction,
514
+ url=self._ws_urls.get(rid, ""), direction=direction,
429
515
  payload=payload, opcode=int(opcode) if opcode is not None else None,
430
516
  ))
431
517
 
@@ -450,7 +536,7 @@ class _CdpAdapter(_BaseAdapter):
450
536
  "path": getattr(c, "path", "/"), "expires": float(getattr(c, "expires", -1) or -1),
451
537
  "httpOnly": bool(getattr(c, "http_only", False)),
452
538
  "secure": bool(getattr(c, "secure", False)),
453
- "sameSite": (str(getattr(c, "same_site", "") or "").capitalize() or "Lax"),
539
+ "sameSite": (getattr(getattr(c, "same_site", None), "value", None) or "Lax"),
454
540
  })
455
541
  return {"cookies": cookies, "origins": []}
456
542
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "s-librarykit"
3
- version = "0.5.0"
3
+ version = "0.5.2"
4
4
  description = "Жирный КОРЕНЬ китов: единая error-иерархия, config/retry/paths-утили, checkpoint/resume, транспорт/auth/sessions — общий код для clikit, adapterkit и доменных пакетов."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -106,10 +106,15 @@ class _MockContext:
106
106
  class _MockPage:
107
107
  def __init__(self, context):
108
108
  self.context = context
109
+ self.queue_items: list[str] = []
109
110
 
110
111
  def on(self, *_a):
111
112
  pass
112
113
 
114
+ async def evaluate(self, _script):
115
+ items, self.queue_items = self.queue_items, []
116
+ return items
117
+
113
118
 
114
119
  class _MockBackend:
115
120
  def __init__(self, page):
@@ -183,3 +188,22 @@ def test_attach_tap_playwright_flow():
183
188
  assert got_resp[0].status == 200 and got_resp[0].body == b'{"ok":true}'
184
189
  # request_id сшит между request и response
185
190
  assert got_req[0].request_id == got_resp[0].request_id
191
+
192
+
193
+ def test_attach_tap_drain_queue_fallback():
194
+ """CamouFox-fallback: событие из globalThis.__captureTraceQueue дренится page.evaluate → on_action."""
195
+ ctx = _MockContext()
196
+ page = _MockPage(ctx)
197
+ page.queue_items = ['{"kind":"action","type":"click"}', '{"kind":"mutation"}']
198
+ backend = _MockBackend(page)
199
+ got: list = []
200
+
201
+ async def run():
202
+ handle = await attach_capture_tap(
203
+ backend, on_action=got.append, init_script="/*trace*/", action_binding="__t",
204
+ )
205
+ await handle.detach() # финальный дренаж соберёт очередь
206
+
207
+ asyncio.run(run())
208
+ kinds = {a.raw.get("kind") for a in got}
209
+ assert kinds == {"action", "mutation"}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes