From ae073d8c85048f3814e56be8f015722528e2e00e Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 28 May 2026 11:23:24 +0000 Subject: [PATCH 1/7] fix: upload MIME detection + --mime flag + dialog auto-dismiss - Explicit MIME map for common image/doc types instead of relying on system mimetypes db (fixes empty file.type on sites like Medium) - --mime CLI flag to override auto-detected MIME type - Send Escape keypress after upload to dismiss lingering OS file picker - Log detected MIME type at info level --- ceki_sdk/_browser.py | 46 ++++++++++++++++++++++++++++++++++++++++++-- ceki_sdk/cli.py | 4 +++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 2db2a0f..0dcbb56 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -315,12 +315,40 @@ async def snapshot(self) -> Snapshot: self._last_seen_ts = all_msgs[-1].created_at return Snapshot(screenshot=screenshot_b64, chat=all_msgs, ts=datetime.now(timezone.utc)) + _MIME_MAP: dict[str, str] = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".avif": "image/avif", + ".svg": "image/svg+xml", + ".pdf": "application/pdf", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".json": "application/json", + ".txt": "text/plain", + ".csv": "text/csv", + ".html": "text/html", + ".xml": "application/xml", + ".zip": "application/zip", + } + + @staticmethod + def _detect_mime(filename: str) -> str: + ext = Path(filename).suffix.lower() + if ext in Browser._MIME_MAP: + return Browser._MIME_MAP[ext] + guessed, _ = mimetypes.guess_type(filename) + return guessed or "application/octet-stream" + async def upload( self, selector: str, source: str | Path | bytes, *, filename: str | None = None, + mime_type: str | None = None, ) -> dict: """Upload a file to an ```` element. @@ -328,6 +356,7 @@ async def upload( selector: CSS selector for the file input element. source: File path (str/Path) or raw bytes. filename: Override the filename (default: basename of path or ``upload.bin``). + mime_type: Override MIME type (default: auto-detect from extension). Returns: ``{"ok": True, "filename": "...", "size": N}`` on success. @@ -349,9 +378,10 @@ async def upload( else: raise TypeError(f"source must be str, Path, or bytes, got {type(source).__name__}") - mime_type, _ = mimetypes.guess_type(filename) if mime_type is None: - mime_type = "application/octet-stream" + mime_type = self._detect_mime(filename) + + log.info("upload: file=%s mime=%s size=%d", filename, mime_type, len(data)) b64_data = base64.b64encode(data).decode("ascii") @@ -390,6 +420,18 @@ async def upload( if "error" in parsed: raise ValueError(parsed["error"]) + try: + await self.send({ + "method": "Input.dispatchKeyEvent", + "params": {"type": "keyDown", "key": "Escape", "code": "Escape", "windowsVirtualKeyCode": 27, "nativeVirtualKeyCode": 27}, + }) + await self.send({ + "method": "Input.dispatchKeyEvent", + "params": {"type": "keyUp", "key": "Escape", "code": "Escape", "windowsVirtualKeyCode": 27, "nativeVirtualKeyCode": 27}, + }) + except Exception: + pass + return parsed def set_human(self, profile) -> "HumanProfile | None": diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index c2eeddc..fd6c706 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -359,7 +359,8 @@ async def _cmd_upload(args: argparse.Namespace) -> None: client, browser = await _resume_browser(api_key, args.session_id) try: result = await browser.upload( - args.selector, file_path, filename=args.filename + args.selector, file_path, filename=args.filename, + mime_type=getattr(args, "mime_type", None), ) _out(result) except ValueError as e: @@ -509,6 +510,7 @@ def build_parser() -> argparse.ArgumentParser: p_upload.add_argument("--selector", required=True, help="CSS selector for file input") p_upload.add_argument("--file", required=True, dest="file_path", help="Path to file") p_upload.add_argument("--filename", help="Override filename (default: basename)") + p_upload.add_argument("--mime", dest="mime_type", help="Override MIME type (default: auto-detect from extension)") p_captcha = sub.add_parser("request-captcha", help="Request human to solve captcha") p_captcha.add_argument("session_id", help="Session ID") From 6af6394b0f9777694f51459f766fb04d67b26f26 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 28 May 2026 11:40:53 +0000 Subject: [PATCH 2/7] style: fix ruff lint errors (E501 line length, F821, F841) Break long lines to fit 100-char limit, fix undefined name SessionInfo via TYPE_CHECKING guard, remove unused test variables. No functional changes. --- ceki_sdk/__init__.py | 2 +- ceki_sdk/_browser.py | 24 ++++++++--- ceki_sdk/_chat.py | 7 +++- ceki_sdk/_client.py | 30 +++++++++---- ceki_sdk/_profile.py | 3 +- ceki_sdk/cli.py | 51 ++++++++++++++++++----- ceki_sdk/humanize/__init__.py | 2 +- ceki_sdk/humanize/profile.py | 1 - examples/smoke/mvp_smoke_v2.py | 11 ++++- tests/e2e/test_fingerprint_persistence.py | 34 ++++++++++----- tests/test_browser_screenshot_format.py | 4 +- tests/test_captcha.py | 40 ++++++++++++++---- tests/test_chat.py | 18 ++++++-- tests/test_cli.py | 17 ++++---- tests/test_error_mapping.py | 19 +++++++-- tests/test_humanize_browser.py | 5 ++- tests/test_multi_session.py | 18 ++++++-- tests/test_profile.py | 10 +++-- tests/test_provider_disconnect.py | 2 +- tests/test_provider_offline.py | 14 ++++--- tests/test_rent_flow.py | 15 ++++--- tests/test_search.py | 2 +- tests/test_sessions.py | 5 +-- tests/test_state_persistence.py | 4 +- tests/test_switch_tab.py | 9 +++- tests/test_type_keyboard_events.py | 3 ++ tests/test_upload.py | 4 +- 27 files changed, 255 insertions(+), 99 deletions(-) diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 45c0de9..80c6043 100644 --- a/ceki_sdk/__init__.py +++ b/ceki_sdk/__init__.py @@ -2,7 +2,6 @@ from ._captcha import CaptchaResult from ._client import Client from ._connect import ConnectOptions, connect -from ._profile import BrowserProfile from ._exceptions import ( AuthFailed, CaptchaError, @@ -19,6 +18,7 @@ SessionNotFound, ) from ._models import BrowserOption, ChatMessage, Match, ReadReceipt, SessionInfo, Snapshot +from ._profile import BrowserProfile from .humanize import HumanProfile __version__ = "2.15.1" diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 0dcbb56..f134b44 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -12,7 +12,7 @@ import httpx -from .humanize import HumanProfile, Humanizer +from .humanize import Humanizer, HumanProfile if TYPE_CHECKING: from ._client import Client @@ -189,7 +189,9 @@ async def wait_until_ended(self) -> str: async def navigate(self, url: str, *, timeout: float = 30.0) -> dict: if self._humanizer: await self._humanizer.before("navigate") - result = await self.send({"method": "Page.navigate", "params": {"url": url}}, timeout=timeout) + result = await self.send( + {"method": "Page.navigate", "params": {"url": url}}, timeout=timeout, + ) if self._humanizer: await self._humanizer.after("navigate") return result @@ -242,7 +244,10 @@ async def type(self, text: str) -> None: if self._last_pointer is not None: await self.click(*self._last_pointer) else: - log.debug("type() called with humanizer but no last_pointer; falling back to plain insertText") + log.debug( + "type() called with humanizer but no last_pointer;" + " falling back to plain insertText" + ) await self._humanizer.before("type") async for char, delay_ms in self._humanizer.humanize_text(text): await self._send_keystroke(char) @@ -393,7 +398,8 @@ async def upload( "(function() {" f"var input = document.querySelector({js_selector});" "if (!input) return JSON.stringify({error: 'no input matched'});" - "if (input.type !== 'file') return JSON.stringify({error: 'element is not a file input'});" + "if (input.type !== 'file')" + " return JSON.stringify({error: 'element is not a file input'});" f"var b64 = '{b64_data}';" "var bin = atob(b64);" "var bytes = new Uint8Array(bin.length);" @@ -421,13 +427,19 @@ async def upload( raise ValueError(parsed["error"]) try: + esc_params = { + "key": "Escape", + "code": "Escape", + "windowsVirtualKeyCode": 27, + "nativeVirtualKeyCode": 27, + } await self.send({ "method": "Input.dispatchKeyEvent", - "params": {"type": "keyDown", "key": "Escape", "code": "Escape", "windowsVirtualKeyCode": 27, "nativeVirtualKeyCode": 27}, + "params": {"type": "keyDown", **esc_params}, }) await self.send({ "method": "Input.dispatchKeyEvent", - "params": {"type": "keyUp", "key": "Escape", "code": "Escape", "windowsVirtualKeyCode": 27, "nativeVirtualKeyCode": 27}, + "params": {"type": "keyUp", **esc_params}, }) except Exception: pass diff --git a/ceki_sdk/_chat.py b/ceki_sdk/_chat.py index 63d610c..e5f5eaf 100644 --- a/ceki_sdk/_chat.py +++ b/ceki_sdk/_chat.py @@ -86,7 +86,12 @@ async def send_image( if mime is None: mime = _detect_mime(data) if filename is None: - ext = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp'}.get(mime or '', 'bin') + mime_ext = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + } + ext = mime_ext.get(mime or '', 'bin') filename = f'image-{uuid4().hex[:8]}.{ext}' if len(data) > MAX_IMAGE_BYTES: diff --git a/ceki_sdk/_client.py b/ceki_sdk/_client.py index eb09a0b..a859385 100644 --- a/ceki_sdk/_client.py +++ b/ceki_sdk/_client.py @@ -4,7 +4,7 @@ import json import logging import time -from typing import Any +from typing import TYPE_CHECKING, Any import httpx import websockets @@ -25,6 +25,9 @@ ) from ._models import BrowserOption, Match +if TYPE_CHECKING: + from ._models import SessionInfo + log = logging.getLogger(__name__) BACKOFF_STEPS = [1, 2, 4, 8, 16, 32, 60] @@ -99,7 +102,10 @@ async def _connect(self) -> None: self._stashed_first_frame = None except websockets.exceptions.ConnectionClosedError as exc: if exc.rcvd and exc.rcvd.code in (4401, 4403): - raise AuthFailed(f"ws closed with code {exc.rcvd.code}: {exc.rcvd.reason or 'auth_failed'}") from exc + reason = exc.rcvd.reason or 'auth_failed' + raise AuthFailed( + f"ws closed with code {exc.rcvd.code}: {reason}" + ) from exc raise self._last_pong = time.monotonic() @@ -135,7 +141,8 @@ async def list_sessions( headers: dict[str, str] = {"Authorization": f"Bearer {self.api_key}"} if self._basic_auth: import base64 - creds = base64.b64encode(f"{self._basic_auth[0]}:{self._basic_auth[1]}".encode()).decode() + raw = f"{self._basic_auth[0]}:{self._basic_auth[1]}" + creds = base64.b64encode(raw.encode()).decode() headers["X-Basic-Auth"] = f"Basic {creds}" async with httpx.AsyncClient() as http: resp = await http.get( @@ -152,7 +159,8 @@ async def my_browsers(self) -> list[BrowserOption]: headers: dict[str, str] = {"Authorization": f"Bearer {self.api_key}"} if self._basic_auth: import base64 - creds = base64.b64encode(f"{self._basic_auth[0]}:{self._basic_auth[1]}".encode()).decode() + raw = f"{self._basic_auth[0]}:{self._basic_auth[1]}" + creds = base64.b64encode(raw.encode()).decode() headers["X-Basic-Auth"] = f"Basic {creds}" async with httpx.AsyncClient() as http: resp = await http.get(url, headers=headers) @@ -276,9 +284,13 @@ async def _reader_loop(self) -> None: if exc.rcvd and exc.rcvd.code in (4401, 4403): # Server rejected auth post-handshake self._closed = True + reason = exc.rcvd.reason + err = AuthFailed( + f"ws closed with code {exc.rcvd.code}: {reason}" + ) for fut in list(self._pending_rent_queue): if not fut.done(): - fut.set_exception(AuthFailed(f"ws closed with code {exc.rcvd.code}: {exc.rcvd.reason}")) + fut.set_exception(err) self._pending_rent_queue.clear() return if not self._closed and self.reconnect: @@ -315,9 +327,13 @@ async def _dispatch(self, msg: dict[str, Any]) -> None: if mtype == "rent.error": code = msg.get("code", "") message = msg.get("message", "rent failed") - server_event_id = str(msg.get("event_id", "")) if msg.get("event_id") is not None else None + eid = msg.get("event_id") + server_event_id = str(eid) if eid is not None else None from ._exceptions import ProviderOffline - exc_to_raise: Exception = ProviderOffline(message) if code == "provider_offline" else CekiError(message) + if code == "provider_offline": + exc_to_raise: Exception = ProviderOffline(message) + else: + exc_to_raise = CekiError(message) fut: asyncio.Future[Match] | None = None if server_event_id: fut = self._pending_rents.pop(server_event_id, None) diff --git a/ceki_sdk/_profile.py b/ceki_sdk/_profile.py index fffedd3..d5ccfbe 100644 --- a/ceki_sdk/_profile.py +++ b/ceki_sdk/_profile.py @@ -84,7 +84,8 @@ async def import_(self, profile: dict[str, Any]) -> None: version = profile.get("schema_version", 1) if version not in SUPPORTED_SCHEMA_VERSIONS: raise ValueError( - f"unsupported profile schema_version={version}, expected one of {SUPPORTED_SCHEMA_VERSIONS}" + f"unsupported profile schema_version={version}," + f" expected one of {SUPPORTED_SCHEMA_VERSIONS}" ) cookies = profile.get("cookies", []) diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index fd6c706..cbedf04 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -8,17 +8,22 @@ from pathlib import Path from typing import Any -from . import connect, ConnectOptions +from . import ConnectOptions, connect from ._exceptions import ( AuthFailed, CaptchaTimeoutError, CekiError, ConnectionLost, - SessionNotFound, - SessionExpired, NotOwner, + SessionExpired, + SessionNotFound, +) +from ._state import ( + delete_session, + get_last_seen_ts, + save_session, + update_last_seen_ts, ) -from ._state import save_session, load_session, delete_session, get_last_seen_ts, update_last_seen_ts def _out(data: Any) -> None: @@ -258,7 +263,11 @@ async def _cmd_sessions(args: argparse.Namespace) -> None: if not results: print("No sessions found.") return - header = f"{'SID':<8}{'SCHEDULE':<10}{'STARTED':<22}{'DURATION':<10}{'EARNED':<9}{'STATUS':<10}{'RENTER':<16}{'PROVIDER'}" + header = ( + f"{'SID':<8}{'SCHEDULE':<10}{'STARTED':<22}" + f"{'DURATION':<10}{'EARNED':<9}{'STATUS':<10}" + f"{'RENTER':<16}{'PROVIDER'}" + ) print(header) for s in results: started = (s.started_at.strftime("%Y-%m-%dT%H:%M:%SZ") if s.started_at else "—") @@ -385,7 +394,12 @@ async def _cmd_request_captcha(args: argparse.Namespace) -> None: if not result.solved: sys.exit(1) except CaptchaTimeoutError as e: - _out({"solved": False, "cancel_reason": f"timeout:{e.phase}", "child_event_id": None, "correction_id": None}) + _out({ + "solved": False, + "cancel_reason": f"timeout:{e.phase}", + "child_event_id": None, + "correction_id": None, + }) sys.exit(1) finally: if client._ws: @@ -411,7 +425,10 @@ def build_parser() -> argparse.ArgumentParser: p_rent = sub.add_parser("rent", help="Rent a browser") p_rent.add_argument("--schedule", type=int, required=True, help="Schedule ID") - p_rent.add_argument("--mode", choices=["incognito", "main"], default="incognito", help="Profile mode (default: incognito)") + p_rent.add_argument( + "--mode", choices=["incognito", "main"], + default="incognito", help="Profile mode (default: incognito)", + ) p_rent.add_argument("--fingerprint-from", help="Path to profile JSON with fingerprint data") p_snap = sub.add_parser("snapshot", help="Take screenshot + get new chat messages") @@ -510,13 +527,25 @@ def build_parser() -> argparse.ArgumentParser: p_upload.add_argument("--selector", required=True, help="CSS selector for file input") p_upload.add_argument("--file", required=True, dest="file_path", help="Path to file") p_upload.add_argument("--filename", help="Override filename (default: basename)") - p_upload.add_argument("--mime", dest="mime_type", help="Override MIME type (default: auto-detect from extension)") + p_upload.add_argument( + "--mime", dest="mime_type", + help="Override MIME type (default: auto-detect from extension)", + ) p_captcha = sub.add_parser("request-captcha", help="Request human to solve captcha") p_captcha.add_argument("session_id", help="Session ID") - p_captcha.add_argument("--acceptance", type=float, default=60, help="Acceptance timeout sec (min 30)") - p_captcha.add_argument("--completion", type=float, default=120, help="Completion timeout sec (min 30)") - p_captcha.add_argument("--manual", action="store_true", help="Disable auto-accept (agent votes manually)") + p_captcha.add_argument( + "--acceptance", type=float, default=60, + help="Acceptance timeout sec (min 30)", + ) + p_captcha.add_argument( + "--completion", type=float, default=120, + help="Completion timeout sec (min 30)", + ) + p_captcha.add_argument( + "--manual", action="store_true", + help="Disable auto-accept (agent votes manually)", + ) p_cdp = sub.add_parser("cdp", help="Send raw CDP command") p_cdp.add_argument("session_id", help="Session ID") diff --git a/ceki_sdk/humanize/__init__.py b/ceki_sdk/humanize/__init__.py index 1be464f..86bfbb8 100644 --- a/ceki_sdk/humanize/__init__.py +++ b/ceki_sdk/humanize/__init__.py @@ -1,4 +1,4 @@ -from .profile import HumanProfile from .humanizer import Humanizer +from .profile import HumanProfile __all__ = ["HumanProfile", "Humanizer"] diff --git a/ceki_sdk/humanize/profile.py b/ceki_sdk/humanize/profile.py index 605e791..dbc9db4 100644 --- a/ceki_sdk/humanize/profile.py +++ b/ceki_sdk/humanize/profile.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import Any - DEFAULTS: dict[str, Any] = { "version": 1, "name": "custom", diff --git a/examples/smoke/mvp_smoke_v2.py b/examples/smoke/mvp_smoke_v2.py index cef4c15..81264ca 100644 --- a/examples/smoke/mvp_smoke_v2.py +++ b/examples/smoke/mvp_smoke_v2.py @@ -421,14 +421,21 @@ async def scenario_j() -> int: # ── Scenario registry ────────────────────────────────────────────────────── -AUTOMATIC = {"A": scenario_a, "B": scenario_b, "D": scenario_d, "H": scenario_h, "I": scenario_i, "J": scenario_j} +AUTOMATIC = { + "A": scenario_a, "B": scenario_b, "D": scenario_d, + "H": scenario_h, "I": scenario_i, "J": scenario_j, +} MANUAL = {"C": scenario_c, "E": scenario_e, "F": scenario_f, "G": scenario_g} ALL_SCENARIOS = {**AUTOMATIC, **MANUAL} def main() -> int: parser = argparse.ArgumentParser(description="Ceki Browser SDK 2.2.0 integration smoke tests") - parser.add_argument("--scenario", default="A", help="Scenario letter(s): A, B, C-G, H-K, all, or comma-separated (e.g. A,I,J)") + parser.add_argument( + "--scenario", default="A", + help="Scenario letter(s): A, B, C-G, H-K, all," + " or comma-separated (e.g. A,I,J)", + ) args = parser.parse_args() requested = args.scenario.strip() diff --git a/tests/e2e/test_fingerprint_persistence.py b/tests/e2e/test_fingerprint_persistence.py index 975ebe4..7cc891f 100644 --- a/tests/e2e/test_fingerprint_persistence.py +++ b/tests/e2e/test_fingerprint_persistence.py @@ -12,7 +12,6 @@ from __future__ import annotations import asyncio -import json import os import pytest @@ -127,7 +126,7 @@ async def test_fingerprint_persists_across_rents(): finally: await client_a.close() - print(f"\n--- Session A ---") + print("\n--- Session A ---") print(f" UA: {a['ua']}") print(f" TZ: {a['tz']}") print(f" Locale: {a['locale']}") @@ -137,7 +136,7 @@ async def test_fingerprint_persists_across_rents(): if a["fp_cdp"]: print(f" FP seed: {a['fp_cdp'].get('seed')}") else: - print(f" FP CDP: not available (ext < 0.6.102)") + print(" FP CDP: not available (ext < 0.6.102)") fingerprint_from_profile = a["profile"].get("fingerprint") @@ -155,7 +154,7 @@ async def test_fingerprint_persists_across_rents(): finally: await client_b.close() - print(f"\n--- Session B ---") + print("\n--- Session B ---") print(f" UA: {b['ua']}") print(f" TZ: {b['tz']}") print(f" Locale: {b['locale']}") @@ -165,20 +164,33 @@ async def test_fingerprint_persists_across_rents(): if b["fp_cdp"]: print(f" FP seed: {b['fp_cdp'].get('seed')}") else: - print(f" FP CDP: not available (ext < 0.6.102)") + print(" FP CDP: not available (ext < 0.6.102)") # --- Assertions --- if fingerprint_from_profile is None: print("\n⚠ Extension < 0.6.102: Browser.getFingerprint not available.") print(" Cannot test fingerprint persistence (profile has no fingerprint).") print(" Update extension to 0.6.102+ and re-run.") - pytest.skip("Extension too old — Browser.getFingerprint not available, fingerprint not in profile") + pytest.skip( + "Extension too old — Browser.getFingerprint not available," + " fingerprint not in profile" + ) - assert a["ua"] == b["ua"], f"UA mismatch: {a['ua']!r} vs {b['ua']!r}" - assert a["tz"] == b["tz"], f"TZ mismatch: {a['tz']!r} vs {b['tz']!r}" - assert a["locale"] == b["locale"], f"Locale mismatch: {a['locale']!r} vs {b['locale']!r}" - assert a["screen_w"] == b["screen_w"], f"screen.width mismatch: {a['screen_w']} vs {b['screen_w']}" - assert a["screen_h"] == b["screen_h"], f"screen.height mismatch: {a['screen_h']} vs {b['screen_h']}" + assert a["ua"] == b["ua"], ( + f"UA mismatch: {a['ua']!r} vs {b['ua']!r}" + ) + assert a["tz"] == b["tz"], ( + f"TZ mismatch: {a['tz']!r} vs {b['tz']!r}" + ) + assert a["locale"] == b["locale"], ( + f"Locale mismatch: {a['locale']!r} vs {b['locale']!r}" + ) + assert a["screen_w"] == b["screen_w"], ( + f"screen.width mismatch: {a['screen_w']} vs {b['screen_w']}" + ) + assert a["screen_h"] == b["screen_h"], ( + f"screen.height mismatch: {a['screen_h']} vs {b['screen_h']}" + ) assert a["hc"] == b["hc"], f"hardwareConcurrency mismatch: {a['hc']} vs {b['hc']}" assert a["webgl"] == b["webgl"], f"WebGL mismatch: {a['webgl']!r} vs {b['webgl']!r}" diff --git a/tests/test_browser_screenshot_format.py b/tests/test_browser_screenshot_format.py index 6aef270..5356f3f 100644 --- a/tests/test_browser_screenshot_format.py +++ b/tests/test_browser_screenshot_format.py @@ -74,7 +74,9 @@ async def test_screenshot_full_page_sends_layout_metrics_and_clip(browser: Brows capture_call = browser.send.call_args_list[1].args[0] assert capture_call["method"] == "Page.captureScreenshot" assert capture_call["params"]["captureBeyondViewport"] is True - assert capture_call["params"]["clip"] == {"x": 0, "y": 0, "width": 1280, "height": 5000, "scale": 1} + assert capture_call["params"]["clip"] == { + "x": 0, "y": 0, "width": 1280, "height": 5000, "scale": 1, + } async def test_screenshot_full_page_clamps_height(browser: Browser, caplog): diff --git a/tests/test_captcha.py b/tests/test_captcha.py index 4c06cff..c7ffeff 100644 --- a/tests/test_captcha.py +++ b/tests/test_captcha.py @@ -1,13 +1,11 @@ from __future__ import annotations import asyncio -import json from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from ceki_sdk import Client, ConnectOptions, connect -from ceki_sdk._captcha import CaptchaResult +from ceki_sdk import ConnectOptions, connect from ceki_sdk._exceptions import CaptchaError, CaptchaTimeoutError from .conftest import MockRelayServer @@ -57,7 +55,11 @@ async def test_request_captcha_happy_path(mock_relay: MockRelayServer) -> None: try: with _patch_httpx_post(): captcha_task = asyncio.create_task( - browser.request_captcha(acceptance_timeout=30, completion_timeout=30, auto_accept=False) + browser.request_captcha( + acceptance_timeout=30, + completion_timeout=30, + auto_accept=False, + ) ) await asyncio.sleep(0.1) @@ -133,7 +135,11 @@ async def test_provider_declined(mock_relay: MockRelayServer) -> None: try: with _patch_httpx_post(): captcha_task = asyncio.create_task( - browser.request_captcha(acceptance_timeout=30, completion_timeout=30, auto_accept=False) + browser.request_captcha( + acceptance_timeout=30, + completion_timeout=30, + auto_accept=False, + ) ) await asyncio.sleep(0.1) @@ -182,7 +188,11 @@ async def test_request_captcha_happy_path_auto_accept(mock_relay: MockRelayServe with _patch_httpx_post() as mock_http_cls: http_instance = mock_http_cls.return_value captcha_task = asyncio.create_task( - browser.request_captcha(acceptance_timeout=30, completion_timeout=30, auto_accept=True) + browser.request_captcha( + acceptance_timeout=30, + completion_timeout=30, + auto_accept=True, + ) ) await asyncio.sleep(0.1) @@ -229,7 +239,11 @@ async def test_request_captcha_manual_accept(mock_relay: MockRelayServer) -> Non with _patch_httpx_post() as mock_http_cls: http_instance = mock_http_cls.return_value captcha_task = asyncio.create_task( - browser.request_captcha(acceptance_timeout=30, completion_timeout=30, auto_accept=False) + browser.request_captcha( + acceptance_timeout=30, + completion_timeout=30, + auto_accept=False, + ) ) await asyncio.sleep(0.1) @@ -281,7 +295,11 @@ async def test_request_captcha_manual_reject(mock_relay: MockRelayServer) -> Non with _patch_httpx_post() as mock_http_cls: http_instance = mock_http_cls.return_value captcha_task = asyncio.create_task( - browser.request_captcha(acceptance_timeout=30, completion_timeout=30, auto_accept=False) + browser.request_captcha( + acceptance_timeout=30, + completion_timeout=30, + auto_accept=False, + ) ) await asyncio.sleep(0.1) @@ -332,7 +350,11 @@ async def test_request_captcha_no_correction_id_raises(mock_relay: MockRelayServ try: with _patch_httpx_post(): captcha_task = asyncio.create_task( - browser.request_captcha(acceptance_timeout=30, completion_timeout=30, auto_accept=False) + browser.request_captcha( + acceptance_timeout=30, + completion_timeout=30, + auto_accept=False, + ) ) await asyncio.sleep(0.1) diff --git a/tests/test_chat.py b/tests/test_chat.py index 20d9a23..43dc260 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -10,11 +10,16 @@ @pytest.fixture async def chat_browser(mock_relay): - client = await connect("test-key", ConnectOptions(relay_url=f"ws://127.0.0.1:{mock_relay.port}/ws/agent")) + url = f"ws://127.0.0.1:{mock_relay.port}/ws/agent" + client = await connect("test-key", ConnectOptions(relay_url=url)) async def ack_rent(): await asyncio.sleep(0.05) - await mock_relay.send_to_all({"type": "rent_pending", "event_id": "ev-chat", "schedule_id": 1}) + await mock_relay.send_to_all({ + "type": "rent_pending", + "event_id": "ev-chat", + "schedule_id": 1, + }) await asyncio.sleep(0.02) await mock_relay.send_to_all({ "type": "match", @@ -34,11 +39,16 @@ async def ack_rent(): @pytest.fixture async def chat_browser_no_topic(mock_relay): - client = await connect("test-key", ConnectOptions(relay_url=f"ws://127.0.0.1:{mock_relay.port}/ws/agent")) + url = f"ws://127.0.0.1:{mock_relay.port}/ws/agent" + client = await connect("test-key", ConnectOptions(relay_url=url)) async def ack_rent(): await asyncio.sleep(0.05) - await mock_relay.send_to_all({"type": "rent_pending", "event_id": "ev-notopic", "schedule_id": 1}) + await mock_relay.send_to_all({ + "type": "rent_pending", + "event_id": "ev-notopic", + "schedule_id": 1, + }) await asyncio.sleep(0.02) await mock_relay.send_to_all({ "type": "match", diff --git a/tests/test_cli.py b/tests/test_cli.py index 21e3865..98d563c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,18 +3,20 @@ import json import subprocess import sys -import tempfile -from collections.abc import AsyncGenerator from pathlib import Path -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest -from ceki_sdk._state import save_session, load_session, delete_session, get_last_seen_ts, update_last_seen_ts +from ceki_sdk._state import ( + delete_session, + get_last_seen_ts, + load_session, + save_session, + update_last_seen_ts, +) from ceki_sdk.cli import build_parser - # ────────────────────────────────────────────────────────────────────────── # State file tests # ────────────────────────────────────────────────────────────────────────── @@ -408,9 +410,10 @@ async def test_resume_ok(): async def test_snapshot_returns_data(): - from ceki_sdk import Browser import base64 + from ceki_sdk import Browser + client = AsyncMock() client._active_browsers = {} client.chat_url = "https://test/chat" diff --git a/tests/test_error_mapping.py b/tests/test_error_mapping.py index b26407f..d48093a 100644 --- a/tests/test_error_mapping.py +++ b/tests/test_error_mapping.py @@ -7,7 +7,13 @@ import websockets.server from ceki_sdk import ConnectOptions, connect -from ceki_sdk._exceptions import AuthFailed, CekiError, InsufficientFunds, ProviderOffline, SessionEnded +from ceki_sdk._exceptions import ( + AuthFailed, + CekiError, + InsufficientFunds, + ProviderOffline, + SessionEnded, +) class _CloseImmediately4403: @@ -18,7 +24,10 @@ def __init__(self) -> None: self.port: int = 0 @staticmethod - def _select_subprotocol(ws: websockets.server.WebSocketServerProtocol, subprotocols: list[str]) -> str | None: + def _select_subprotocol( + ws: websockets.server.WebSocketServerProtocol, + subprotocols: list[str], + ) -> str | None: for sp in subprotocols: if sp.startswith("bearer."): return sp @@ -51,8 +60,10 @@ async def close_4403_server(): @pytest.mark.asyncio -async def test_connect_bogus_token_close_4403_raises_auth_failed(close_4403_server: _CloseImmediately4403) -> None: - """Relay accepts WS upgrade then immediately closes 4403 → connect() raises AuthFailed within 2s.""" +async def test_connect_bogus_token_close_4403_raises_auth_failed( + close_4403_server: _CloseImmediately4403, +) -> None: + """Relay accepts WS upgrade then immediately closes 4403.""" url = f"ws://127.0.0.1:{close_4403_server.port}" with pytest.raises(AuthFailed): await asyncio.wait_for(connect("bad-token", ConnectOptions(relay_url=url)), timeout=2.0) diff --git a/tests/test_humanize_browser.py b/tests/test_humanize_browser.py index df489ff..5413659 100644 --- a/tests/test_humanize_browser.py +++ b/tests/test_humanize_browser.py @@ -1,10 +1,13 @@ """Tests for Browser humanization integration.""" from __future__ import annotations + import asyncio from unittest.mock import AsyncMock, MagicMock, patch + import pytest + from ceki_sdk._browser import Browser, _resolve_human -from ceki_sdk.humanize import HumanProfile, Humanizer +from ceki_sdk.humanize import Humanizer, HumanProfile def _make_browser(human="natural"): diff --git a/tests/test_multi_session.py b/tests/test_multi_session.py index 01b0d74..824c85f 100644 --- a/tests/test_multi_session.py +++ b/tests/test_multi_session.py @@ -9,7 +9,8 @@ @pytest.mark.asyncio async def test_two_sessions_routed_independently(mock_relay): - client = await connect("test-key", ConnectOptions(relay_url=f"ws://127.0.0.1:{mock_relay.port}/ws/agent")) + url = f"ws://127.0.0.1:{mock_relay.port}/ws/agent" + client = await connect("test-key", ConnectOptions(relay_url=url)) acked: set[str] = set() async def ack_rent(session_id: str, schedule_id: int) -> None: @@ -25,7 +26,11 @@ async def ack_rent(session_id: str, schedule_id: int) -> None: if rent: acked.add(schedule_id) ev_id = f"ev-{session_id}" - await mock_relay.send_to_all({"type": "rent_pending", "event_id": ev_id, "schedule_id": schedule_id}) + await mock_relay.send_to_all({ + "type": "rent_pending", + "event_id": ev_id, + "schedule_id": schedule_id, + }) await asyncio.sleep(0.02) await mock_relay.send_to_all({ "type": "match", @@ -87,12 +92,17 @@ async def reply(): @pytest.mark.asyncio async def test_close_one_session_leaves_other_alive(mock_relay): - client = await connect("test-key", ConnectOptions(relay_url=f"ws://127.0.0.1:{mock_relay.port}/ws/agent")) + url = f"ws://127.0.0.1:{mock_relay.port}/ws/agent" + client = await connect("test-key", ConnectOptions(relay_url=url)) async def ack_rent(session_id): await asyncio.sleep(0.05) ev_id = f"ev-{session_id}" - await mock_relay.send_to_all({"type": "rent_pending", "event_id": ev_id, "schedule_id": 1}) + await mock_relay.send_to_all({ + "type": "rent_pending", + "event_id": ev_id, + "schedule_id": 1, + }) await asyncio.sleep(0.02) await mock_relay.send_to_all({ "type": "match", diff --git a/tests/test_profile.py b/tests/test_profile.py index 7aeafa1..b38dcdb 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,13 +1,12 @@ from __future__ import annotations import json +from unittest.mock import AsyncMock import pytest -from unittest.mock import AsyncMock from ceki_sdk._profile import BrowserProfile - SAMPLE_FINGERPRINT = { "seed": 123456789, "timezoneId": "Europe/Berlin", @@ -230,7 +229,12 @@ async def test_import_storage_values_serialized_as_json(): fb.send.return_value = {} p = BrowserProfile(fb) storage = {"key": 'value with "quotes" and \\backslash'} - await p.import_({"schema_version": 2, "cookies": [], "localStorage": storage, "sessionStorage": {}}) + await p.import_({ + "schema_version": 2, + "cookies": [], + "localStorage": storage, + "sessionStorage": {}, + }) expr = fb.send.call_args_list[0].args[0]["params"]["expression"] assert json.dumps(storage) in expr diff --git a/tests/test_provider_disconnect.py b/tests/test_provider_disconnect.py index fc6fab4..d06eba8 100644 --- a/tests/test_provider_disconnect.py +++ b/tests/test_provider_disconnect.py @@ -4,7 +4,7 @@ import pytest -from ceki_sdk import ConnectOptions, ProviderDisconnected, SessionEnded, connect +from ceki_sdk import ConnectOptions, connect async def _make_browser(mock_relay, session_id: str = "sess-pd"): diff --git a/tests/test_provider_offline.py b/tests/test_provider_offline.py index 49fb9eb..d7beb13 100644 --- a/tests/test_provider_offline.py +++ b/tests/test_provider_offline.py @@ -4,15 +4,17 @@ import pytest -from ceki_sdk import Client, ConnectOptions, connect +from ceki_sdk import ConnectOptions, connect from ceki_sdk._exceptions import ProviderOffline from .conftest import MockRelayServer @pytest.mark.asyncio -async def test_rent_error_provider_offline_raises_provider_offline(mock_relay: MockRelayServer) -> None: - """relay sends rent.error provider_offline after probe timeout → ProviderOffline raised.""" +async def test_rent_error_provider_offline_raises_provider_offline( + mock_relay: MockRelayServer, +) -> None: + """relay sends rent.error provider_offline after probe timeout.""" url = f"ws://127.0.0.1:{mock_relay.port}" client = await connect("testkey", ConnectOptions(relay_url=url)) @@ -38,8 +40,10 @@ async def test_rent_error_provider_offline_raises_provider_offline(mock_relay: M @pytest.mark.asyncio -async def test_rent_error_provider_offline_without_event_id(mock_relay: MockRelayServer) -> None: - """rent.error provider_offline without event_id (early, before rent_pending) → ProviderOffline.""" +async def test_rent_error_provider_offline_without_event_id( + mock_relay: MockRelayServer, +) -> None: + """rent.error provider_offline without event_id (before rent_pending).""" url = f"ws://127.0.0.1:{mock_relay.port}" client = await connect("testkey", ConnectOptions(relay_url=url)) diff --git a/tests/test_rent_flow.py b/tests/test_rent_flow.py index 7bdc2f1..3ad5d7b 100644 --- a/tests/test_rent_flow.py +++ b/tests/test_rent_flow.py @@ -4,11 +4,10 @@ import pytest -from ceki_sdk import Client, ConnectOptions, connect +from ceki_sdk import ConnectOptions, connect from ceki_sdk._exceptions import ( ProviderOffline, RateLimitExceeded, - SessionEnded, ) from tests.test_profile import SAMPLE_FINGERPRINT @@ -76,7 +75,9 @@ async def test_rent_error_with_event_id_raises_exception(mock_relay: MockRelaySe @pytest.mark.asyncio -async def test_rent_early_error_without_event_id_raises_exception(mock_relay: MockRelayServer) -> None: +async def test_rent_early_error_without_event_id_raises_exception( + mock_relay: MockRelayServer, +) -> None: url = f"ws://127.0.0.1:{mock_relay.port}" client = await connect("testkey", ConnectOptions(relay_url=url)) @@ -128,7 +129,9 @@ async def test_rent_with_fingerprint_dict_sends_configure(mock_relay: MockRelayS @pytest.mark.asyncio -async def test_rent_with_fingerprint_false_sends_configure_false(mock_relay: MockRelayServer) -> None: +async def test_rent_with_fingerprint_false_sends_configure_false( + mock_relay: MockRelayServer, +) -> None: url = f"ws://127.0.0.1:{mock_relay.port}" client = await connect("testkey", ConnectOptions(relay_url=url)) @@ -146,7 +149,7 @@ async def test_rent_with_fingerprint_false_sends_configure_false(mock_relay: Moc "price_per_min": 0.01, }) - browser = await asyncio.wait_for(rent_task, timeout=5) + await asyncio.wait_for(rent_task, timeout=5) await asyncio.sleep(0.1) configure_msgs = [m for m in mock_relay.received if m.get("type") == "session.configure"] @@ -175,7 +178,7 @@ async def test_rent_with_fingerprint_true_no_configure(mock_relay: MockRelayServ "price_per_min": 0.01, }) - browser = await asyncio.wait_for(rent_task, timeout=5) + await asyncio.wait_for(rent_task, timeout=5) configure_msgs = [m for m in mock_relay.received if m.get("type") == "session.configure"] assert len(configure_msgs) == 0 diff --git a/tests/test_search.py b/tests/test_search.py index 321b0cc..de71f81 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,7 +1,7 @@ from __future__ import annotations import base64 -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import httpx import pytest diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 7142f83..bfeb27c 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1,11 +1,10 @@ from __future__ import annotations -import asyncio from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from ceki_sdk import Client, ConnectOptions, SessionInfo, connect +from ceki_sdk import ConnectOptions, SessionInfo, connect from .conftest import MockRelayServer @@ -83,7 +82,7 @@ async def test_list_sessions_all(mock_relay: MockRelayServer) -> None: try: patcher, http_mock = _patch_httpx_get() with patcher: - results = await client.list_sessions(active=False) + await client.list_sessions(active=False) call_kwargs = http_mock.get.call_args params = call_kwargs.kwargs.get("params", {}) assert params.get("active") == "0" diff --git a/tests/test_state_persistence.py b/tests/test_state_persistence.py index 58e3d78..6ddb0e8 100644 --- a/tests/test_state_persistence.py +++ b/tests/test_state_persistence.py @@ -4,10 +4,8 @@ from pathlib import Path from unittest.mock import AsyncMock, patch -import pytest - from ceki_sdk import Browser -from ceki_sdk._state import save_session, load_session, get_last_seen_ts, update_last_seen_ts +from ceki_sdk._state import get_last_seen_ts, save_session, update_last_seen_ts def _make_browser(): diff --git a/tests/test_switch_tab.py b/tests/test_switch_tab.py index 54d4583..8c0e7b8 100644 --- a/tests/test_switch_tab.py +++ b/tests/test_switch_tab.py @@ -9,11 +9,16 @@ @pytest.fixture async def browser_fixture(mock_relay): - client = await connect("test-key", ConnectOptions(relay_url=f"ws://127.0.0.1:{mock_relay.port}/ws/agent")) + url = f"ws://127.0.0.1:{mock_relay.port}/ws/agent" + client = await connect("test-key", ConnectOptions(relay_url=url)) async def ack_rent(): await asyncio.sleep(0.05) - await mock_relay.send_to_all({"type": "rent_pending", "event_id": "ev-tab", "schedule_id": 1}) + await mock_relay.send_to_all({ + "type": "rent_pending", + "event_id": "ev-tab", + "schedule_id": 1, + }) await asyncio.sleep(0.02) await mock_relay.send_to_all({ "type": "match", diff --git a/tests/test_type_keyboard_events.py b/tests/test_type_keyboard_events.py index 685f61f..8878564 100644 --- a/tests/test_type_keyboard_events.py +++ b/tests/test_type_keyboard_events.py @@ -1,6 +1,9 @@ from __future__ import annotations + from unittest.mock import AsyncMock, patch + import pytest + from ceki_sdk import Browser diff --git a/tests/test_upload.py b/tests/test_upload.py index b7407bc..0034577 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -4,7 +4,6 @@ import json import subprocess import sys -import tempfile from pathlib import Path from unittest.mock import AsyncMock, patch @@ -13,7 +12,6 @@ from ceki_sdk._browser import Browser from ceki_sdk.cli import build_parser - # ────────────────────────────────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────────────────────────────────── @@ -128,7 +126,7 @@ async def test_upload_escapes_special_chars(tmp_path: Path): } }) - result = await b.upload("input", test_file, filename='file"with\'quotes.png') + await b.upload("input", test_file, filename='file"with\'quotes.png') call_args = b.send.call_args[0][0] expr = call_args["params"]["expression"] From 801c6b4960071118764ab71b06c534146388088a Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 28 May 2026 11:49:36 +0000 Subject: [PATCH 3/7] fix: upload tests use call_args_list[0] for Runtime.evaluate assertion --- tests/test_upload.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_upload.py b/tests/test_upload.py index 0034577..a73ceb2 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -54,7 +54,7 @@ async def test_upload_file_path(tmp_path: Path): assert result == {"ok": True, "filename": "doc.pdf", "size": 21} # Verify send was called with Runtime.evaluate - call_args = b.send.call_args[0][0] + call_args = b.send.call_args_list[0][0][0] assert call_args["method"] == "Runtime.evaluate" expr = call_args["params"]["expression"] assert "document.querySelector" in expr @@ -79,7 +79,7 @@ async def test_upload_bytes_custom_filename(): result = await b.upload("#file-input", data, filename="custom.txt") assert result == {"ok": True, "filename": "custom.txt", "size": 11} - call_args = b.send.call_args[0][0] + call_args = b.send.call_args_list[0][0][0] expr = call_args["params"]["expression"] b64 = base64.b64encode(data).decode("ascii") assert b64 in expr @@ -103,7 +103,7 @@ async def test_upload_bytes_default_filename(): result = await b.upload("input", b"\x00\x01\x02") assert result["filename"] == "upload.bin" - call_args = b.send.call_args[0][0] + call_args = b.send.call_args_list[0][0][0] expr = call_args["params"]["expression"] assert "upload.bin" in expr assert "application/octet-stream" in expr @@ -128,7 +128,7 @@ async def test_upload_escapes_special_chars(tmp_path: Path): await b.upload("input", test_file, filename='file"with\'quotes.png') - call_args = b.send.call_args[0][0] + call_args = b.send.call_args_list[0][0][0] expr = call_args["params"]["expression"] # json.dumps properly escapes the double quote assert r'file\"with' in expr @@ -198,7 +198,7 @@ async def test_upload_mime_type_png(tmp_path: Path): }) await b.upload("input", f) - expr = b.send.call_args[0][0]["params"]["expression"] + expr = b.send.call_args_list[0][0][0]["params"]["expression"] assert "image/png" in expr @@ -212,7 +212,7 @@ async def test_upload_mime_type_pdf(tmp_path: Path): }) await b.upload("input", f) - expr = b.send.call_args[0][0]["params"]["expression"] + expr = b.send.call_args_list[0][0][0]["params"]["expression"] assert "application/pdf" in expr @@ -226,7 +226,7 @@ async def test_upload_mime_type_unknown(tmp_path: Path): }) await b.upload("input", f) - expr = b.send.call_args[0][0]["params"]["expression"] + expr = b.send.call_args_list[0][0][0]["params"]["expression"] assert "application/octet-stream" in expr From fc13b77fd9bdec4cbe7ff1bbfa1d6b84fac20f41 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 28 May 2026 12:09:37 +0000 Subject: [PATCH 4/7] fix: pin websockets<14 + update humanize tests for dispatchKeyEvent --- pyproject.toml | 2 +- tests/test_humanize_browser.py | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9773f2d..92b33fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Framework :: AsyncIO", ] dependencies = [ - "websockets>=12", + "websockets>=12,<14", "httpx>=0.27", "pydantic>=2", ] diff --git a/tests/test_humanize_browser.py b/tests/test_humanize_browser.py index 5413659..c5bc80b 100644 --- a/tests/test_humanize_browser.py +++ b/tests/test_humanize_browser.py @@ -57,14 +57,16 @@ class TestBrowserHumanNone: """human=None means zero overhead.""" @pytest.mark.asyncio - async def test_type_sends_single_insert(self): + async def test_type_sends_per_char_keystrokes(self): b = _make_browser(human=None) b.send = AsyncMock(return_value={}) await b.type("hello") - b.send.assert_called_once() - call_args = b.send.call_args[0][0] - assert call_args["method"] == "Input.insertText" - assert call_args["params"]["text"] == "hello" + key_down_calls = [ + c for c in b.send.call_args_list + if c[0][0].get("method") == "Input.dispatchKeyEvent" + and c[0][0]["params"].get("type") == "keyDown" + ] + assert len(key_down_calls) == 5 @pytest.mark.asyncio async def test_click_no_sleep(self): @@ -90,9 +92,12 @@ async def test_type_per_char(self): b = _make_browser(human="natural") b.send = AsyncMock(return_value={}) await b.type("abc") - insert_calls = [c for c in b.send.call_args_list - if c[0][0].get("method") == "Input.insertText"] - assert len(insert_calls) == 3 + key_down_calls = [ + c for c in b.send.call_args_list + if c[0][0].get("method") == "Input.dispatchKeyEvent" + and c[0][0]["params"].get("type") == "keyDown" + ] + assert len(key_down_calls) == 3 @pytest.mark.asyncio async def test_click_timing_variance(self): From 9013573cfd6af39371a403d94bdfe51e14295771 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 28 May 2026 12:21:42 +0000 Subject: [PATCH 5/7] fix: multi_session test checks browser_id (matches client rent msg format) --- tests/test_multi_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_multi_session.py b/tests/test_multi_session.py index 824c85f..8192315 100644 --- a/tests/test_multi_session.py +++ b/tests/test_multi_session.py @@ -19,7 +19,7 @@ async def ack_rent(session_id: str, schedule_id: int) -> None: await asyncio.sleep(0.05) rent = next( (m for m in mock_relay.received - if m.get("type") == "rent" and m.get("schedule_id") == schedule_id + if m.get("type") == "rent" and m.get("browser_id") == schedule_id and schedule_id not in acked), None, ) From 1ba97b49cccc8f6b9b5707e3b6c35d320c022d9a Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 28 May 2026 12:35:32 +0000 Subject: [PATCH 6/7] feat: use stdlib mimetypes instead of hardcoded MIME map (~190 IANA types) --- ceki_sdk/_browser.py | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index f134b44..5c83623 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -14,6 +14,11 @@ from .humanize import Humanizer, HumanProfile +mimetypes.init() +for _ext, _mime in {".avif": "image/avif", ".webm": "video/webm", ".woff2": "font/woff2"}.items(): + if not mimetypes.guess_type(f"x{_ext}")[0]: + mimetypes.add_type(_mime, _ext) + if TYPE_CHECKING: from ._client import Client from ._captcha import CaptchaResult @@ -320,30 +325,8 @@ async def snapshot(self) -> Snapshot: self._last_seen_ts = all_msgs[-1].created_at return Snapshot(screenshot=screenshot_b64, chat=all_msgs, ts=datetime.now(timezone.utc)) - _MIME_MAP: dict[str, str] = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".avif": "image/avif", - ".svg": "image/svg+xml", - ".pdf": "application/pdf", - ".mp4": "video/mp4", - ".webm": "video/webm", - ".json": "application/json", - ".txt": "text/plain", - ".csv": "text/csv", - ".html": "text/html", - ".xml": "application/xml", - ".zip": "application/zip", - } - @staticmethod def _detect_mime(filename: str) -> str: - ext = Path(filename).suffix.lower() - if ext in Browser._MIME_MAP: - return Browser._MIME_MAP[ext] guessed, _ = mimetypes.guess_type(filename) return guessed or "application/octet-stream" From 4ee2acdf42994de5b42e316653c3d3b927803a10 Mon Sep 17 00:00:00 2001 From: ceki-plugin Date: Thu, 28 May 2026 12:37:05 +0000 Subject: [PATCH 7/7] fix: move mimetypes init after imports (ruff E402) --- ceki_sdk/_browser.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 5c83623..83856e2 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -14,11 +14,6 @@ from .humanize import Humanizer, HumanProfile -mimetypes.init() -for _ext, _mime in {".avif": "image/avif", ".webm": "video/webm", ".woff2": "font/woff2"}.items(): - if not mimetypes.guess_type(f"x{_ext}")[0]: - mimetypes.add_type(_mime, _ext) - if TYPE_CHECKING: from ._client import Client from ._captcha import CaptchaResult @@ -34,6 +29,11 @@ log = logging.getLogger(__name__) +mimetypes.init() +for _ext, _mime in {".avif": "image/avif", ".webm": "video/webm", ".woff2": "font/woff2"}.items(): + if not mimetypes.guess_type(f"x{_ext}")[0]: + mimetypes.add_type(_mime, _ext) + EventCallback = Callable[[str, dict[str, Any]], Awaitable[None]] TabOpenedCallback = Callable[[str], Awaitable[None]] SimpleCallback = Callable[[], Awaitable[None]]