diff --git a/README.md b/README.md index 8d3908b..67c62b4 100644 --- a/README.md +++ b/README.md @@ -123,28 +123,45 @@ These are NOT automated tests — they require a live relay, an online provider, ## Human Mode -Browser actions can optionally include human-like timing — delays before/after actions and per-character typing with jitter. +Behavioral humanization is **ON by default** in both `main` and `incognito` profile modes: + +- **Typing** — per-character keystrokes with natural inter-key cadence + jitter (extension-side, `Ceki.typeText`). +- **Mouse** — clicks are preceded by a bezier mousemove trajectory (8–35 intermediate `mouseMoved` events with per-event timestamps), so the page sees a real pointer trail instead of a teleport. + +Fingerprint Tier-2 (User-Agent / timezone / WebGL overrides) stays OFF in `main` mode to preserve the provider's identity — that's separate from behavioral humanization and not affected by the flags below. ```python -# Default: natural profile (enabled by default) +# Default: behavioral humanizer ON (natural profile) browser = await client.rent(schedule_id) # Explicit profile browser = await client.rent(schedule_id, human="careful") -# Disable humanization +# Disable session-wide humanization browser = await client.rent(schedule_id, human=None) # Custom profile dict browser = await client.rent(schedule_id, human={"typing": {"wpm": 130}}) ``` +### Per-call disable + +Each humanized method accepts `human=False` for raw, flat behavior on **just that call** — useful for fast scripted seeding without leaking jitter elsewhere: + +```python +await browser.type("user@example.com", human=False) # flat keystrokes, no jitter +await browser.click(120, 240, human=False) # straight pointer jump +await browser.scroll(delta_y=-300, human=False) +``` + +The CLI equivalent is `--no-human` / `--raw` on `type`, `click`, `scroll`, `navigate`. Both flags mean "this call only". + ### High-level methods ```python await browser.navigate("https://example.com") await browser.click(100, 200) -await browser.type("Hello, world!") # Ships one Ceki.typeText command; extension fans it out per-char (keydown/keyUp/+shift) with human delays. Long text no longer trips the relay command cap. +await browser.type("Hello, world!") # Ships one Ceki.typeText command; extension fans it out per-char with human delays. Long text no longer trips the relay command cap. await browser.scroll(delta_y=-300) img_bytes = await browser.screenshot() ``` @@ -153,14 +170,14 @@ img_bytes = await browser.screenshot() ```python prev = browser.set_human("careful") # Switch profile, returns previous -browser.set_human(None) # Disable mid-session +browser.set_human(None) # Disable session-wide humanization ``` ### Environment variables - `CEKI_HUMAN_PROFILE` — Override default profile name (e.g., `careful`) - `CEKI_HUMAN_PROFILE_PATH` — Path to custom JSON profile file -- `CEKI_HUMAN_DISABLE=1` — Disable humanization entirely +- `CEKI_HUMAN_DISABLE=1` — **Global kill-switch**: disable humanization for every call regardless of `human=...` arguments or CLI flags ## CLI @@ -209,10 +226,10 @@ The CLI persists session state locally — after `rent` it saves the session ID | Command | Description | |---|---| -| `navigate SID URL` | Open URL | -| `click SID X Y` | Click at viewport coordinates | -| `type SID TEXT [--natural]` | Type text into focused element | -| `scroll SID X Y DY` | Scroll from (X, Y) by `DY` pixels | +| `navigate SID URL [--no-human\|--raw]` | Open URL (humanized by default; `--no-human` skips pre/post delays) | +| `click SID X Y [--no-human\|--raw]` | Click at viewport coordinates (mousemove trail ON by default; `--no-human` for direct jump) | +| `type SID TEXT [--selector CSS] [--no-human\|--raw]` | Type text (humanized by default; `--no-human` for flat keystrokes) | +| `scroll SID X Y DY [--no-human\|--raw]` | Scroll from (X, Y) by `DY` pixels (eased by default; `--no-human` for raw CDP wheel) | | `screenshot SID -o FILE [--format png\|jpeg] [--full]` | Save screenshot | | `snapshot SID -o FILE` | Screenshot + new chat messages | | `switch-tab SID` | Switch active tab | @@ -255,6 +272,62 @@ Successful commands write a single JSON line to stdout. Errors go to stderr as ` Full reference (with EN+RU): https://browser.ceki.me/docs#cli +### `ceki contract` — participate in contracts via `/mcp/agent` + +For AI agents executing tasks inside a contract: list contracts/jobs, post +results, propose corrections, vote, poll notifications. + +``` +ceki contract list # my contracts +ceki contract members # contract members +ceki contract tasks [cid] # events of contract(s) +ceki contract my-jobs # events assigned to me +ceki contract task # event detail +ceki contract children # event children +ceki contract history # audit history +ceki contract create --label "X" [--status N] [--type N] \ + [--kal-schedule N] [--start ..] [--end ..] [--date ..] \ + [--duration N] [--amount N] [--currency USD] \ + [--benefitable agent:8|user:61] [--desc ".."] +ceki contract comment --label ".." [--status N] [--duration N] \ + [--amount N] [--currency USD] [--benefitable agent:8] [--desc ".."] +ceki contract propose [--status N] [--label ..] [--desc ..] \ + [--duration N] [--amount N] [--currency USD] [--benefitable agent:8] +ceki contract vote --ids 1,2 --vote true|false +ceki contract poll # single tick (returns [] on 429) +ceki contract watch [sec] # continuous (min 6s, 10/min/token) +ceki contract tools # list available MCP tools +ceki contract raw '' # call any tool directly +``` + +#### Environment + +| Variable | Meaning | +|---|---| +| `CEKI_AGENT_TOKEN` | Bearer agent token (`ag_*`). Falls back to `CEKI_API_KEY`. | +| `CEKI_API_URL` | Base URL — `/mcp/agent` and `/api/agent/polling` are derived from it. | +| `CEKI_AGENT_MCP_ENDPOINT` | Override MCP endpoint (backward compat). | +| `CEKI_API_BASE` | Override REST polling base. | +| `CEKI_CONTRACT_IDS` | Default contract id(s): `"14"`, `"14,21"`, or `"[14,21]"`. | + +Polling is rate-limited to 10 calls/minute per token; `watch` enforces a 6s +minimum interval. + +### `ceki timelog` — event time tracking via `/mcp/agent` + +Top-level group (not under `contract`). Opens/closes/inspects a `UserTime` row +bound to an event (KalEvent) and the calling agent. Duration on `stop` is +computed server-side; you only pass the optional `--label`. + +``` +ceki timelog start # timelog-start +ceki timelog stop [--label "что сделал"] # timelog-stop +ceki timelog check # timelog-check (open log?) +``` + +Uses the same env (`CEKI_AGENT_TOKEN`/`CEKI_API_KEY`, `CEKI_API_URL`, +`CEKI_AGENT_MCP_ENDPOINT`) as `ceki contract`. + ## Development ```bash diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index b81ad7b..4a5fe32 100644 --- a/ceki_sdk/__init__.py +++ b/ceki_sdk/__init__.py @@ -21,7 +21,7 @@ from ._profile import BrowserProfile from .humanize import HumanProfile -__version__ = "2.16.0" +__version__ = "2.23.0" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 8bb0816..8645ece 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -191,28 +191,43 @@ async def wait_until_ended(self) -> str: # High-level browser actions (with optional human-like timing) # ────────────────────────────────────────────────────────────────────────── - async def navigate(self, url: str, *, timeout: float = 30.0) -> dict: - if self._humanizer: - await self._humanizer.before("navigate") + def _humanize_for_call(self, human: bool | None) -> "Humanizer | None": + # task 427 — per-call kill-switch. human=False bypasses humanizer + # (timings) AND tells the extension to skip mouse-jitter via the + # `_ceki_raw` param marker (see cdp.ts in ceki-browser-extension). + # human=None → use session default (env / constructor). human=True + # forces humanizer even if global env disabled it (corner case; + # respects None humanizer if no profile). + if human is False: + return None + return self._humanizer + + async def navigate(self, url: str, *, timeout: float = 30.0, human: bool | None = None) -> dict: + h = self._humanize_for_call(human) + if h: + await h.before("navigate") result = await self.send( {"method": "Page.navigate", "params": {"url": url}}, timeout=timeout, ) - if self._humanizer: - await self._humanizer.after("navigate") + if h: + await h.after("navigate") return result - async def click(self, x: int | float, y: int | float) -> None: - if self._humanizer: - await self._humanizer.before("click") + async def click(self, x: int | float, y: int | float, *, human: bool | None = None) -> None: + h = self._humanize_for_call(human) + if h: + await h.before("click") + raw_flag = {"_ceki_raw": True} if h is None else {} await self.send({"method": "Input.dispatchMouseEvent", "params": { "type": "mousePressed", "x": int(x), "y": int(y), "button": "left", "clickCount": 1, + **raw_flag, }}) await self.send({"method": "Input.dispatchMouseEvent", "params": { "type": "mouseReleased", "x": int(x), "y": int(y), "button": "left", "clickCount": 1, }}) self._last_pointer = (int(x), int(y)) - if self._humanizer: - await self._humanizer.after("click") + if h: + await h.after("click") async def _send_keystroke(self, char: str) -> None: from .humanize.keymap import keymap_for_char @@ -244,67 +259,92 @@ async def _send_keystroke(self, char: str) -> None: "windowsVirtualKeyCode": 16, "nativeVirtualKeyCode": 16, }}) - async def type(self, text: str) -> None: + async def type( + self, + text: str, + *, + selector: str | None = None, + human: bool | None = None, + ) -> None: # task 413 — typing humanizer moved into the extension. The SDK # now sends ONE Ceki.typeText command instead of N per-char # dispatchKeyEvent calls, so long inputs no longer burn through # the 500 cmd / 60s relay cap and inter-key delays land without # WS jitter. The extension owns keymap + profile timings. - if self._humanizer: - if self._last_pointer is not None: + # + # task 425 BUG-1 — optional `selector` is forwarded to the extension + # which focuses the matching element via chrome.scripting.executeScript + # across all frames. The previous SDK-side Runtime.evaluate hit + # "ReferenceError: document is not defined" on signup.live.com et al. + # because Chrome routed the bare CDP eval to the page's service-worker + # execution context where `document` is undefined. chrome.scripting + # always lands in a page frame. + h = self._humanize_for_call(human) + if h: + if self._last_pointer is not None and selector is None: await self.click(*self._last_pointer) - else: + elif selector is None: log.debug( "type() called with humanizer but no last_pointer;" " input may not land on the intended element" ) - await self._humanizer.before("type") + await h.before("type") - human: str | None = None - if self._humanizer and self._humanizer.profile: - name = self._humanizer.profile.name - human = name if name in ("natural", "careful") else "natural" + human_name: str | None = None + if h and h.profile: + name = h.profile.name + human_name = name if name in ("natural", "careful") else "natural" - await self.send({ - "method": "Ceki.typeText", - "params": {"text": text, "human": human}, - }) + params: dict[str, Any] = {"text": text, "human": human_name} + if selector is not None: + params["selector"] = selector - if self._humanizer: - await self._humanizer.after("type") + await self.send({"method": "Ceki.typeText", "params": params}) + + if h: + await h.after("type") async def scroll( - self, x: int = 0, y: int = 0, *, delta_x: int = 0, delta_y: int = -300 + self, x: int = 0, y: int = 0, *, delta_x: int = 0, delta_y: int = -300, + human: bool | None = None, ) -> None: - if self._humanizer: - await self._humanizer.before("scroll") + h = self._humanize_for_call(human) + if h: + await h.before("scroll") await self.send({"method": "Input.dispatchMouseEvent", "params": { "type": "mouseWheel", "x": x, "y": y, "deltaX": delta_x, "deltaY": delta_y, }}) self._last_pointer = (int(x), int(y)) - if self._humanizer: - await self._humanizer.after("scroll") + if h: + await h.after("scroll") async def screenshot( self, *, format: Literal["base64", "png"] = "base64", full_page: bool = False, + timeout: float = 120.0, ) -> dict | bytes: """Take a screenshot. Args: format: ``"base64"`` (default) returns CDP-shape dict, ``"png"`` returns raw PNG bytes. full_page: If True, capture the entire scrollable page, not just the viewport. + timeout: CDP timeout in seconds (default 120 — heavy pages like + signup.live.com routinely take 60+ seconds to capture, task 425). """ if format not in ("base64", "png"): raise ValueError(f"Unsupported format: {format!r}. Use 'base64' or 'png'.") if self._humanizer: await self._humanizer.before("screenshot") - params: dict[str, Any] = {} + # task 425 BUG-3 — `optimizeForSpeed: true` skips the JPEG quality + # tuning Chrome would otherwise run when the page is still painting + # (signup.live.com lazy-loads frames for ~minutes). Combined with + # the bumped timeout this turns 60s timeouts into sub-second captures. + params: dict[str, Any] = {"optimizeForSpeed": True} if full_page: - metrics = await self.send({"method": "Page.getLayoutMetrics"}) + metrics = await self.send({"method": "Page.getLayoutMetrics"}, timeout=timeout) content = metrics.get("contentSize", {}) width = int(content.get("width", 0)) height = int(content.get("height", 0)) @@ -315,7 +355,9 @@ async def screenshot( params["captureBeyondViewport"] = True params["clip"] = {"x": 0, "y": 0, "width": width, "height": height, "scale": 1} - resp = await self.send({"method": "Page.captureScreenshot", "params": params}) + resp = await self.send( + {"method": "Page.captureScreenshot", "params": params}, timeout=timeout, + ) if self._humanizer: await self._humanizer.after("screenshot") if format == "base64": @@ -324,9 +366,14 @@ async def screenshot( data = resp.get("data", "") return _b64.b64decode(data) if data else b"" - async def snapshot(self) -> Snapshot: + async def snapshot(self, *, timeout: float = 120.0) -> Snapshot: from datetime import datetime, timezone - resp = await self.send({"method": "Page.captureScreenshot"}) + # task 425 BUG-3 — same `optimizeForSpeed` + bumped timeout as + # screenshot(); heavy pages would otherwise hit the 60s default. + resp = await self.send( + {"method": "Page.captureScreenshot", "params": {"optimizeForSpeed": True}}, + timeout=timeout, + ) screenshot_b64 = resp.get("data", "") all_msgs = await self.chat.history(since=self._last_seen_ts) if self._last_seen_ts and all_msgs: diff --git a/ceki_sdk/cli.py b/ceki_sdk/cli.py index cbedf04..aa72519 100644 --- a/ceki_sdk/cli.py +++ b/ceki_sdk/cli.py @@ -114,11 +114,19 @@ async def _cmd_snapshot(args: argparse.Namespace) -> None: await client.disconnect() +def _human_flag(args: argparse.Namespace) -> bool | None: + # task 427 — humanization is default ON. --no-human / --raw on the + # per-command parser (or root) requests raw mode for this single call. + if getattr(args, "no_human", False) or getattr(args, "raw", False): + return False + return None + + async def _cmd_navigate(args: argparse.Namespace) -> None: api_key = _get_api_key() client, browser = await _resume_browser(api_key, args.session_id) try: - await browser.navigate(args.url) + await browser.navigate(args.url, human=_human_flag(args)) _out({"ok": True}) finally: if client._ws: @@ -129,7 +137,7 @@ async def _cmd_click(args: argparse.Namespace) -> None: api_key = _get_api_key() client, browser = await _resume_browser(api_key, args.session_id) try: - await browser.click(args.x, args.y) + await browser.click(args.x, args.y, human=_human_flag(args)) _out({"ok": True, "pointer": [args.x, args.y]}) finally: if client._ws: @@ -137,13 +145,14 @@ async def _cmd_click(args: argparse.Namespace) -> None: async def _cmd_type(args: argparse.Namespace) -> None: + # task 429 — typing is humanized BY DEFAULT in both modes (revert of + # task 428 opt-in). --no-human / --raw → explicit flat for THIS call + # only (the real BUG-B fix: stop the leak, but keep default-ON). + # --natural is a no-op alias kept for backwards compatibility. api_key = _get_api_key() - human = "natural" if args.natural else None client, browser = await _resume_browser(api_key, args.session_id) - if human is None: - browser.set_human(None) try: - await browser.type(args.text) + await browser.type(args.text, selector=args.selector, human=_human_flag(args)) _out({"ok": True}) finally: if client._ws: @@ -154,7 +163,7 @@ async def _cmd_scroll(args: argparse.Namespace) -> None: api_key = _get_api_key() client, browser = await _resume_browser(api_key, args.session_id) try: - await browser.scroll(args.x, args.y, delta_y=args.dy) + await browser.scroll(args.x, args.y, delta_y=args.dy, human=_human_flag(args)) _out({"ok": True}) finally: if client._ws: @@ -258,7 +267,7 @@ async def _cmd_sessions(args: argparse.Namespace) -> None: limit = getattr(args, "limit", 50) results = await client.list_sessions(active=active, limit=limit) if getattr(args, "json", False): - _out([r.model_dump() for r in results]) + _out([r.model_dump(mode="json") for r in results]) else: if not results: print("No sessions found.") @@ -287,7 +296,7 @@ async def _cmd_my_browsers(args: argparse.Namespace) -> None: client = await connect(api_key, _connect_options()) try: results = await client.my_browsers() - _out([r.model_dump() for r in results]) + _out([r.model_dump(mode="json") for r in results]) finally: if client._ws: await client.disconnect() @@ -302,7 +311,7 @@ async def _cmd_search(args: argparse.Namespace) -> None: k, v = f.split("=", 1) filters[k] = v results = await client.search(filters=filters, limit=args.limit) - _out([r.model_dump() for r in results]) + _out([r.model_dump(mode="json") for r in results]) finally: if client._ws: await client.disconnect() @@ -406,6 +415,159 @@ async def _cmd_request_captcha(args: argparse.Namespace) -> None: await client.disconnect() +def _contract_client(): + from .contract import ContractClient + return ContractClient() + + +def _contract_dump(value: Any) -> None: + if isinstance(value, str): + sys.stdout.write(value) + sys.stdout.write("\n") + else: + json.dump(value, sys.stdout, indent=2, ensure_ascii=False) + sys.stdout.write("\n") + sys.stdout.flush() + + +def _cmd_contract(args: argparse.Namespace) -> int: + from .contract import ContractError, contract_ids_from_env + + action = args.contract_action + try: + with _contract_client() as cli: + if action == "list": + _contract_dump(cli.list_contracts()) + elif action == "members": + _contract_dump(cli.members(args.cid)) + elif action == "tasks": + ids = [args.cid] if args.cid is not None else contract_ids_from_env() + if not ids: + _err("no contract id (positional or CEKI_CONTRACT_IDS)", "args") + return 1 + for cid in ids: + print(f"--- contract {cid} ---") + _contract_dump(cli.tasks(int(cid))) + elif action == "my-jobs": + _contract_dump(cli.my_jobs()) + elif action == "task": + _contract_dump(cli.task(args.eid)) + elif action == "children": + _contract_dump(cli.children(args.eid)) + elif action == "history": + _contract_dump(cli.history(args.eid, limit=args.limit)) + elif action == "create": + cid = args.cid if args.cid is not None else ( + int(contract_ids_from_env()[0]) if contract_ids_from_env() else None + ) + if cid is None: + _err("contract id required (positional or CEKI_CONTRACT_IDS)", "args") + return 1 + data_obj = json.loads(args.data) if args.data else None + _contract_dump(cli.create( + cid, + label=args.label, + type_id=args.type, + status_id=args.status, + kal_schedule_id=args.kal_schedule, + start=args.start, + end=args.end, + timezone=args.timezone, + date=args.date, + duration=args.duration, + amount=args.amount, + currency=args.currency, + description=args.desc, + data=data_obj, + benefitable=args.benefitable, + )) + elif action == "comment": + _contract_dump(cli.comment( + args.eid, + label=args.label, + type_id=args.type, + status_id=args.status, + start=args.start, + end=args.end, + date=args.date, + duration=args.duration, + amount=args.amount, + currency=args.currency, + description=args.desc, + benefitable=args.benefitable, + )) + elif action == "propose": + _contract_dump(cli.propose( + args.eid, + status_id=args.status, + label=args.label, + description=args.desc, + start=args.start, + end=args.end, + date=args.date, + duration=args.duration, + amount=args.amount, + currency=args.currency, + benefitable=args.benefitable, + )) + elif action == "vote": + ids = [int(s) for s in str(args.ids).split(",") if s.strip()] + vote = str(args.vote).lower() in ("true", "1", "yes") + _contract_dump(cli.vote(args.eid, ids, vote)) + elif action == "poll": + items = cli.poll() + _contract_dump({"count": len(items), "notifications": items}) + elif action == "watch": + sec = max(6, int(args.interval or 8)) + sys.stderr.write( + f"[watch] poll every {sec}s (limit 10/min/token; do not go below 6s)\n" + ) + sys.stderr.flush() + import time as _time + while True: + items = cli.poll() + if items: + from datetime import datetime, timezone + ts = datetime.now(timezone.utc).isoformat() + for n in items: + print(json.dumps({"ts": ts, "notification": n}, ensure_ascii=False)) + _time.sleep(sec) + elif action == "tools": + _contract_dump(cli.tools()) + elif action == "raw": + payload = json.loads(args.args) if args.args else {} + _contract_dump(cli.raw(args.tool, payload)) + else: + _err(f"unknown contract action: {action}") + return 1 + except ContractError as e: + _err(str(e), "contract") + return 1 + return 0 + + +def _cmd_timelog(args: argparse.Namespace) -> int: + from .contract import ContractError + from .timelog import TimelogClient + + action = args.timelog_action + try: + with TimelogClient() as cli: + if action == "start": + _contract_dump(cli.start(args.event_id)) + elif action == "stop": + _contract_dump(cli.stop(args.event_id, label=args.label)) + elif action == "check": + _contract_dump(cli.check(args.event_id)) + else: + _err(f"unknown timelog action: {action}") + return 1 + except ContractError as e: + _err(str(e), "timelog") + return 1 + return 0 + + async def _cmd_cdp(args: argparse.Namespace) -> None: api_key = _get_api_key() client, browser = await _resume_browser(api_key, args.session_id) @@ -420,6 +582,13 @@ async def _cmd_cdp(args: argparse.Namespace) -> None: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="ceki", description="CLI for browser.ceki.me rental") + parser.add_argument( + "--no-human", "--raw", + action="store_true", + dest="no_human", + help="Disable behavioral humanization (mouse jitter, typing cadence) " + "for this command. Same as CEKI_HUMAN_DISABLE=1 but per-call.", + ) sub = parser.add_subparsers(dest="command", required=True) @@ -438,22 +607,38 @@ def build_parser() -> argparse.ArgumentParser: p_nav = sub.add_parser("navigate", help="Navigate to URL") p_nav.add_argument("session_id", help="Session ID") p_nav.add_argument("url", help="URL to navigate to") + p_nav.add_argument("--no-human", "--raw", action="store_true", dest="no_human", + help="Skip humanization for this call") p_click = sub.add_parser("click", help="Click at coordinates") p_click.add_argument("session_id", help="Session ID") p_click.add_argument("x", type=int, help="X coordinate") p_click.add_argument("y", type=int, help="Y coordinate") + p_click.add_argument("--no-human", "--raw", action="store_true", dest="no_human", + help="Skip humanization (mouse jitter) for this call") p_type = sub.add_parser("type", help="Type text") p_type.add_argument("session_id", help="Session ID") p_type.add_argument("text", help="Text to type") - p_type.add_argument("--natural", action="store_true", help="Enable human-like typing") + # task 429 — typing is humanized BY DEFAULT in both modes (revert of + # 428 opt-in). --no-human / --raw explicitly flattens THIS call only. + # --natural is a no-op alias kept for backwards compatibility. + p_type.add_argument("--natural", action="store_true", + help=argparse.SUPPRESS) + p_type.add_argument("--no-human", "--raw", action="store_true", dest="no_human", + help="Skip humanization (typing cadence) for this call") + p_type.add_argument( + "--selector", + help="CSS selector to focus before typing (e.g. 'input[type=email]')", + ) p_scroll = sub.add_parser("scroll", help="Scroll") p_scroll.add_argument("session_id", help="Session ID") p_scroll.add_argument("x", type=int, help="X origin") p_scroll.add_argument("y", type=int, help="Y origin") p_scroll.add_argument("dy", type=int, help="Delta Y (negative = scroll down)") + p_scroll.add_argument("--no-human", "--raw", action="store_true", dest="no_human", + help="Skip humanization for this call") p_chat = sub.add_parser("chat", help="Chat with provider") p_chat.add_argument("session_id", help="Session ID") @@ -552,6 +737,113 @@ def build_parser() -> argparse.ArgumentParser: p_cdp.add_argument("--method", required=True, help="CDP method name") p_cdp.add_argument("--params", help="CDP params as JSON string") + p_contract = sub.add_parser("contract", help="Participate in contracts via /mcp/agent") + csub = p_contract.add_subparsers(dest="contract_action", required=True) + + csub.add_parser("list", help="List my contracts (get-my-contracts)") + + p_cm = csub.add_parser("members", help="List contract members") + p_cm.add_argument("cid", type=int, help="Contract ID") + + p_ct = csub.add_parser("tasks", help="List contract events (default: CEKI_CONTRACT_IDS)") + p_ct.add_argument("cid", type=int, nargs="?", help="Contract ID") + + csub.add_parser("my-jobs", help="List events assigned to me") + + p_ctask = csub.add_parser("task", help="Get event") + p_ctask.add_argument("eid", type=int, help="Event ID") + + p_cch = csub.add_parser("children", help="Get event children") + p_cch.add_argument("eid", type=int, help="Event ID") + + p_chist = csub.add_parser("history", help="Get event audit history") + p_chist.add_argument("eid", type=int, help="Event ID") + p_chist.add_argument("--limit", type=int, help="Max entries") + + p_cc = csub.add_parser("create", help="Create contract event") + p_cc.add_argument( + "cid", + type=int, + nargs="?", + help="Contract ID (default: CEKI_CONTRACT_IDS[0])", + ) + p_cc.add_argument("--label", required=True) + p_cc.add_argument("--type", type=int) + p_cc.add_argument("--status", type=int) + p_cc.add_argument("--kal-schedule", type=int, dest="kal_schedule") + p_cc.add_argument("--start") + p_cc.add_argument("--end") + p_cc.add_argument("--timezone", help="IANA tz (e.g. Europe/Moscow)") + p_cc.add_argument("--date") + p_cc.add_argument("--duration", type=int) + p_cc.add_argument("--amount", type=int) + p_cc.add_argument("--currency") + p_cc.add_argument("--benefitable", help="agent:8 or user:61") + p_cc.add_argument("--desc") + p_cc.add_argument("--data", help="Extra JSON object passed through as `data`") + + p_cco = csub.add_parser("comment", help="Post comment on event") + p_cco.add_argument("eid", type=int) + p_cco.add_argument("--label") + p_cco.add_argument("--type", type=int) + p_cco.add_argument("--status", type=int) + p_cco.add_argument("--start") + p_cco.add_argument("--end") + p_cco.add_argument("--date") + p_cco.add_argument("--duration", type=int) + p_cco.add_argument("--amount", type=int) + p_cco.add_argument("--currency") + p_cco.add_argument("--benefitable") + p_cco.add_argument("--desc") + + p_cp = csub.add_parser("propose", help="Propose correction") + p_cp.add_argument("eid", type=int) + p_cp.add_argument("--status", type=int) + p_cp.add_argument("--label") + p_cp.add_argument("--desc") + p_cp.add_argument("--start") + p_cp.add_argument("--end") + p_cp.add_argument("--date") + p_cp.add_argument("--duration", type=int) + p_cp.add_argument("--amount", type=int) + p_cp.add_argument("--currency") + p_cp.add_argument("--benefitable") + + p_cv = csub.add_parser("vote", help="Vote on correction(s)") + p_cv.add_argument("eid", type=int) + p_cv.add_argument("--ids", required=True, help="Comma-separated correction IDs") + p_cv.add_argument("--vote", required=True, help="true|false") + + csub.add_parser("poll", help="Single agent polling tick") + + p_cw = csub.add_parser("watch", help="Continuous polling") + p_cw.add_argument("interval", type=int, nargs="?", default=8, help="Seconds, min 6") + + csub.add_parser("tools", help="List available MCP tools") + + p_craw = csub.add_parser("raw", help="Call raw MCP tool") + p_craw.add_argument("tool") + p_craw.add_argument("args", nargs="?", default="{}", help="JSON args") + + p_timelog = sub.add_parser( + "timelog", help="Time-tracking for events via /mcp/agent (start/stop/check)" + ) + tlsub = p_timelog.add_subparsers(dest="timelog_action", required=True) + + p_tls = tlsub.add_parser("start", help="Open timelog for event_id (timelog-start)") + p_tls.add_argument("event_id", type=int, help="Event ID") + + p_tlp = tlsub.add_parser( + "stop", help="Close open timelog for event_id (timelog-stop); duration computed server-side" + ) + p_tlp.add_argument("event_id", type=int, help="Event ID") + p_tlp.add_argument("--label", help="Label for the closing child event (e.g. 'что сделал')") + + p_tlc = tlsub.add_parser( + "check", help="Check whether an open timelog exists for event_id (timelog-check)" + ) + p_tlc.add_argument("event_id", type=int, help="Event ID") + return parser @@ -581,6 +873,12 @@ def main() -> None: "request-captcha": _cmd_request_captcha, } + if args.command == "contract": + sys.exit(_cmd_contract(args)) + + if args.command == "timelog": + sys.exit(_cmd_timelog(args)) + handler = handlers.get(args.command) if not handler: _err(f"Unknown command: {args.command}") diff --git a/ceki_sdk/contract.py b/ceki_sdk/contract.py new file mode 100644 index 0000000..c3bdde5 --- /dev/null +++ b/ceki_sdk/contract.py @@ -0,0 +1,321 @@ +"""Client for /mcp/agent contract tools (1:1 port of ceki-agent.js).""" + +from __future__ import annotations + +import json +import os +import time +from typing import Any + +import httpx + +from ._config import default_api_url + + +def _benefitable(value: str | None) -> dict[str, Any] | None: + if not value: + return None + parts = str(value).split(":", 1) + if len(parts) != 2: + raise ValueError(f"benefitable must be 'type:id', got: {value!r}") + btype, bid = parts + return {"type": btype, "value": int(bid)} + + +def _clean(args: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in args.items() if v is not None} + + +def contract_ids_from_env() -> list[str]: + raw = (os.getenv("CEKI_CONTRACT_IDS") or "").strip() + if not raw: + return [] + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(x) for x in parsed] + except Exception: + pass + return [s.strip() for s in raw.replace("[", "").replace("]", "").split(",") if s.strip()] + + +def _resolve_endpoint() -> str: + override = os.getenv("CEKI_AGENT_MCP_ENDPOINT") + if override: + return override.rstrip("/") + base = default_api_url().rstrip("/") + return f"{base}/mcp/agent" + + +def _resolve_api_base() -> str: + override = os.getenv("CEKI_API_BASE") + if override: + return override.rstrip("/") + base = default_api_url().rstrip("/") + return f"{base}/api" + + +def _resolve_token() -> str: + return os.getenv("CEKI_AGENT_TOKEN") or os.getenv("CEKI_API_KEY") or "" + + +_TOOL_MAP = { + "list": "get-my-contracts", + "members": "get-contract-members", + "tasks": "get-contract-events", + "my-jobs": "get-my-jobs", + "task": "get-event", + "children": "get-event-children", + "history": "get-event-history", + "create": "create-contract-event", + "comment": "comment", + "propose": "propose-correction", + "vote": "vote-correction", +} + + +class ContractError(Exception): + pass + + +class ContractClient: + """JSON-RPC MCP client for /mcp/agent + REST polling. + + Mirrors `ceki-agent.js` behavior. Reads env at construction unless + explicit values are passed. + """ + + def __init__( + self, + endpoint: str | None = None, + token: str | None = None, + api_base: str | None = None, + client: httpx.Client | None = None, + timeout: float = 30.0, + ) -> None: + self.endpoint = (endpoint or _resolve_endpoint()).rstrip("/") + self.api_base = (api_base or _resolve_api_base()).rstrip("/") + self.token = token if token is not None else _resolve_token() + self._timeout = timeout + self._client = client + self._own_client = client is None + + def close(self) -> None: + if self._own_client and self._client is not None: + self._client.close() + self._client = None + + def __enter__(self) -> "ContractClient": + return self + + def __exit__(self, *_exc: Any) -> None: + self.close() + + def _http(self) -> httpx.Client: + if self._client is None: + self._client = httpx.Client(timeout=self._timeout) + return self._client + + def _headers(self) -> dict[str, str]: + if not self.token: + raise ContractError("agent token not set (CEKI_AGENT_TOKEN or CEKI_API_KEY)") + return { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Bearer {self.token}", + } + + def _rpc(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + body = {"jsonrpc": "2.0", "id": int(time.time() * 1000), "method": method, "params": params} + resp = self._http().post(self.endpoint, headers=self._headers(), json=body) + try: + parsed = resp.json() + except Exception: + parsed = {"raw": resp.text} + if resp.status_code != 200: + snippet = json.dumps(parsed)[:400] + raise ContractError(f"HTTP {resp.status_code}: {snippet}") + return parsed + + def call(self, tool: str, args: dict[str, Any] | None = None) -> Any: + """Call MCP tool, unwrap content[].text (JSON-parsed) or structuredContent.""" + body = self._rpc("tools/call", {"name": tool, "arguments": args or {}}) + if body.get("error"): + raise ContractError(f"{tool} → {json.dumps(body['error'])[:400]}") + result = body.get("result") or {} + content = result.get("content") + if isinstance(content, list): + texts = [c.get("text", "") for c in content if c.get("type") == "text"] + joined = "\n".join(texts) + try: + return json.loads(joined) + except Exception: + return joined + return result.get("structuredContent", result) + + def tools(self) -> Any: + body = self._rpc("tools/list", {}) + result = body.get("result") or {} + tools = result.get("tools") + if isinstance(tools, list): + return [t.get("name") for t in tools] + return body + + def raw(self, tool: str, args: dict[str, Any] | None = None) -> Any: + return self.call(tool, args) + + # ── domain helpers ──────────────────────────────────────────── + + def list_contracts(self) -> Any: + return self.call(_TOOL_MAP["list"], {}) + + def members(self, contract_id: int) -> Any: + return self.call(_TOOL_MAP["members"], {"contract_id": int(contract_id)}) + + def tasks(self, contract_id: int) -> Any: + return self.call(_TOOL_MAP["tasks"], {"contract_id": int(contract_id)}) + + def my_jobs(self) -> Any: + return self.call(_TOOL_MAP["my-jobs"], {}) + + def task(self, event_id: int) -> Any: + return self.call(_TOOL_MAP["task"], {"event_id": int(event_id)}) + + def children(self, event_id: int) -> Any: + return self.call(_TOOL_MAP["children"], {"event_id": int(event_id)}) + + def history(self, event_id: int, *, limit: int | None = None) -> Any: + args = _clean({"event_id": int(event_id), "limit": limit}) + return self.call(_TOOL_MAP["history"], args) + + def create( + self, + contract_id: int, + *, + label: str, + type_id: int | None = None, + status_id: int | None = None, + kal_schedule_id: int | None = None, + start: str | None = None, + end: str | None = None, + timezone: str | None = None, + date: str | None = None, + duration: int | None = None, + amount: int | None = None, + currency: str | None = None, + description: str | None = None, + data: dict[str, Any] | None = None, + benefitable: str | None = None, + ) -> Any: + args = _clean({ + "contract_id": int(contract_id), + "label": label, + "type_id": type_id, + "status_id": status_id, + "kal_schedule_id": kal_schedule_id, + "start": start, + "end": end, + "timezone": timezone, + "date": date, + "duration": duration, + "amount": amount, + "currency": currency, + "description": description, + "data": data, + "benefitable": _benefitable(benefitable), + }) + return self.call(_TOOL_MAP["create"], args) + + def comment( + self, + event_id: int, + *, + label: str | None = None, + type_id: int | None = None, + status_id: int | None = None, + start: str | None = None, + end: str | None = None, + date: str | None = None, + duration: int | None = None, + amount: int | None = None, + currency: str | None = None, + description: str | None = None, + benefitable: str | None = None, + ) -> Any: + args = _clean({ + "event_id": int(event_id), + "label": label, + "type_id": type_id, + "status_id": status_id, + "start": start, + "end": end, + "date": date, + "duration": duration, + "amount": amount, + "currency": currency, + "description": description, + "benefitable": _benefitable(benefitable), + }) + return self.call(_TOOL_MAP["comment"], args) + + def propose( + self, + event_id: int, + *, + status_id: int | None = None, + label: str | None = None, + description: str | None = None, + start: str | None = None, + end: str | None = None, + date: str | None = None, + duration: int | None = None, + amount: int | None = None, + currency: str | None = None, + benefitable: str | None = None, + ) -> Any: + args = _clean({ + "event_id": int(event_id), + "status_id": status_id, + "label": label, + "description": description, + "start": start, + "end": end, + "date": date, + "duration": duration, + "amount": amount, + "currency": currency, + "benefitable": _benefitable(benefitable), + }) + return self.call(_TOOL_MAP["propose"], args) + + def vote(self, event_id: int, ids: list[int], vote: bool) -> Any: + return self.call(_TOOL_MAP["vote"], { + "event_id": int(event_id), + "ids": [int(i) for i in ids], + "vote": bool(vote), + }) + + # ── polling (REST, not MCP) ─────────────────────────────────── + + def poll(self) -> list[Any]: + """GET /agent/polling. Returns [] on 429 (rate-limit, 10/min/token).""" + resp = self._http().get( + f"{self.api_base}/agent/polling", + headers={"Accept": "application/json", "Authorization": f"Bearer {self.token}"}, + ) + if resp.status_code == 429: + return [] + if resp.status_code != 200: + try: + body = resp.json() + except Exception: + body = resp.text + raise ContractError(f"polling HTTP {resp.status_code}: {str(body)[:300]}") + body = resp.json() + if isinstance(body, list): + return body + if isinstance(body, dict): + for key in ("notifications", "data", "items"): + if key in body and isinstance(body[key], list): + return body[key] + return [] diff --git a/ceki_sdk/timelog.py b/ceki_sdk/timelog.py new file mode 100644 index 0000000..aaba533 --- /dev/null +++ b/ceki_sdk/timelog.py @@ -0,0 +1,65 @@ +"""Client for /mcp/agent timelog tools (start/stop/check by event_id). + +Thin wrapper around ContractClient — same transport, same auth, same env. +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from .contract import ContractClient, ContractError + +_TOOL_MAP = { + "start": "timelog-start", + "stop": "timelog-stop", + "check": "timelog-check", +} + + +class TimelogClient: + """MCP timelog tools bound to an event_id.""" + + def __init__( + self, + endpoint: str | None = None, + token: str | None = None, + api_base: str | None = None, + client: httpx.Client | None = None, + timeout: float = 30.0, + contract: ContractClient | None = None, + ) -> None: + self._owns_contract = contract is None + self._c = contract or ContractClient( + endpoint=endpoint, + token=token, + api_base=api_base, + client=client, + timeout=timeout, + ) + + def close(self) -> None: + if self._owns_contract: + self._c.close() + + def __enter__(self) -> "TimelogClient": + return self + + def __exit__(self, *_exc: Any) -> None: + self.close() + + def start(self, event_id: int) -> Any: + return self._c.call(_TOOL_MAP["start"], {"event_id": int(event_id)}) + + def stop(self, event_id: int, label: str | None = None) -> Any: + args: dict[str, Any] = {"event_id": int(event_id)} + if label is not None: + args["label"] = label + return self._c.call(_TOOL_MAP["stop"], args) + + def check(self, event_id: int) -> Any: + return self._c.call(_TOOL_MAP["check"], {"event_id": int(event_id)}) + + +__all__ = ["TimelogClient", "ContractError"] diff --git a/pyproject.toml b/pyproject.toml index 3860556..8b4794f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.18.0" +version = "2.26.0" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_browser_screenshot_format.py b/tests/test_browser_screenshot_format.py index 5356f3f..89f2597 100644 --- a/tests/test_browser_screenshot_format.py +++ b/tests/test_browser_screenshot_format.py @@ -2,7 +2,7 @@ import base64 import logging -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, patch import pytest @@ -70,7 +70,8 @@ async def test_screenshot_full_page_sends_layout_metrics_and_clip(browser: Brows result = await browser.screenshot(full_page=True) assert isinstance(result, dict) assert browser.send.call_count == 2 - assert browser.send.call_args_list[0] == call({"method": "Page.getLayoutMetrics"}) + metrics_call = browser.send.call_args_list[0] + assert metrics_call.args[0] == {"method": "Page.getLayoutMetrics"} capture_call = browser.send.call_args_list[1].args[0] assert capture_call["method"] == "Page.captureScreenshot" assert capture_call["params"]["captureBeyondViewport"] is True diff --git a/tests/test_cli.py b/tests/test_cli.py index 98d563c..043bef2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -92,6 +92,44 @@ def test_parser_type_natural(): assert args.natural is True +def test_parser_type_no_human(): + parser = build_parser() + args = parser.parse_args(["type", "ses-1", "hi", "--no-human"]) + assert args.no_human is True + + +# task 429 — typing humanized BY DEFAULT (revert of 428 opt-in). +# Default + --natural → human=None (SDK humanizer ON). +# --no-human / --raw → human=False (explicit flat for THIS call only). +# --natural is a no-op alias, not a switch. + +def _resolve_type_human(args): + """Mirror of cli._human_flag for `ceki type` post-429.""" + if getattr(args, "no_human", False) or getattr(args, "raw", False): + return False + return None + + +def test_type_default_is_humanized(): + a = build_parser().parse_args(["type", "ses-1", "hi"]) + assert _resolve_type_human(a) is None + + +def test_type_natural_is_noop_default_remains_on(): + a = build_parser().parse_args(["type", "ses-1", "hi", "--natural"]) + assert _resolve_type_human(a) is None + + +def test_type_no_human_explicit_flat(): + a = build_parser().parse_args(["type", "ses-1", "hi", "--no-human"]) + assert _resolve_type_human(a) is False + + +def test_type_no_human_wins_over_natural(): + a = build_parser().parse_args(["type", "ses-1", "hi", "--natural", "--no-human"]) + assert _resolve_type_human(a) is False + + def test_parser_scroll(): parser = build_parser() args = parser.parse_args(["scroll", "ses-1", "0", "0", "-300"]) diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..961df8b --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import httpx +import pytest + +from ceki_sdk.cli import build_parser +from ceki_sdk.contract import ( + ContractClient, + ContractError, + _benefitable, + _clean, + contract_ids_from_env, +) + +# ── helpers ─────────────────────────────────────────────────────── + + +def _http_mock(payload, status: int = 200): + """Build a MagicMock httpx.Client whose .post/.get return given payload.""" + resp = MagicMock(spec=httpx.Response) + resp.status_code = status + resp.json.return_value = payload + resp.text = json.dumps(payload) + client = MagicMock(spec=httpx.Client) + client.post.return_value = resp + client.get.return_value = resp + return client, resp + + +def _mcp_text(obj) -> dict: + return { + "result": { + "content": [{"type": "text", "text": json.dumps(obj)}] + } + } + + +# ── _benefitable / _clean ───────────────────────────────────────── + + +def test_benefitable_agent(): + assert _benefitable("agent:8") == {"type": "agent", "value": 8} + + +def test_benefitable_user(): + assert _benefitable("user:61") == {"type": "user", "value": 61} + + +def test_benefitable_none(): + assert _benefitable(None) is None + assert _benefitable("") is None + + +def test_benefitable_malformed_raises(): + with pytest.raises(ValueError): + _benefitable("agent_no_colon") + + +def test_clean_drops_none_only(): + assert _clean({"a": 0, "b": None, "c": "", "d": False, "e": []}) == { + "a": 0, "c": "", "d": False, "e": [], + } + + +# ── env resolution ──────────────────────────────────────────────── + + +def test_contract_ids_csv(monkeypatch): + monkeypatch.setenv("CEKI_CONTRACT_IDS", "14,21") + assert contract_ids_from_env() == ["14", "21"] + + +def test_contract_ids_bracketed(monkeypatch): + monkeypatch.setenv("CEKI_CONTRACT_IDS", "[14,21]") + assert contract_ids_from_env() == ["14", "21"] + + +def test_contract_ids_json(monkeypatch): + monkeypatch.setenv("CEKI_CONTRACT_IDS", "[14, 21]") + assert contract_ids_from_env() == ["14", "21"] + + +def test_contract_ids_empty(monkeypatch): + monkeypatch.delenv("CEKI_CONTRACT_IDS", raising=False) + assert contract_ids_from_env() == [] + + +def test_endpoint_override(monkeypatch): + monkeypatch.setenv("CEKI_AGENT_MCP_ENDPOINT", "https://x.example/mcp/agent") + monkeypatch.setenv("CEKI_AGENT_TOKEN", "tok") + c = ContractClient() + assert c.endpoint == "https://x.example/mcp/agent" + + +def test_endpoint_derived_from_api(monkeypatch): + monkeypatch.delenv("CEKI_AGENT_MCP_ENDPOINT", raising=False) + monkeypatch.setenv("CEKI_API_URL", "https://clawapi.ittribe.org") + monkeypatch.setenv("CEKI_AGENT_TOKEN", "tok") + c = ContractClient() + assert c.endpoint == "https://clawapi.ittribe.org/mcp/agent" + assert c.api_base == "https://clawapi.ittribe.org/api" + + +def test_token_agent_priority(monkeypatch): + monkeypatch.setenv("CEKI_AGENT_TOKEN", "ag_xxx") + monkeypatch.setenv("CEKI_API_KEY", "rental_yyy") + c = ContractClient() + assert c.token == "ag_xxx" + + +def test_token_fallback_to_api_key(monkeypatch): + monkeypatch.delenv("CEKI_AGENT_TOKEN", raising=False) + monkeypatch.setenv("CEKI_API_KEY", "rental_yyy") + c = ContractClient() + assert c.token == "rental_yyy" + + +def test_no_token_raises(monkeypatch): + monkeypatch.delenv("CEKI_AGENT_TOKEN", raising=False) + monkeypatch.delenv("CEKI_API_KEY", raising=False) + http, _ = _http_mock(_mcp_text({"ok": True})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="") + with pytest.raises(ContractError): + c.list_contracts() + + +# ── MCP unwrapping ──────────────────────────────────────────────── + + +def test_call_unwraps_text_json(): + http, _ = _http_mock(_mcp_text({"items": [1, 2]})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + assert c.list_contracts() == {"items": [1, 2]} + + +def test_call_returns_structured_content(): + http, _ = _http_mock({"result": {"structuredContent": {"k": "v"}}}) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + assert c.list_contracts() == {"k": "v"} + + +def test_call_non200_raises(): + http, _ = _http_mock({"error": "bad"}, status=500) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + with pytest.raises(ContractError): + c.list_contracts() + + +def test_call_jsonrpc_error_raises(): + http, _ = _http_mock({"error": {"code": -32000, "message": "nope"}}) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + with pytest.raises(ContractError): + c.list_contracts() + + +# ── tool calls + payloads ───────────────────────────────────────── + + +def _captured_body(http: MagicMock) -> dict: + return http.post.call_args.kwargs["json"] + + +def test_create_maps_tool_and_clean_payload(): + http, _ = _http_mock(_mcp_text({"id": 1})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create(14, label="hello", duration=60, benefitable="agent:8") + body = _captured_body(http) + assert body["method"] == "tools/call" + assert body["params"]["name"] == "create-contract-event" + args = body["params"]["arguments"] + assert args == { + "contract_id": 14, + "label": "hello", + "duration": 60, + "benefitable": {"type": "agent", "value": 8}, + } + + +def test_comment_strips_undefined(): + http, _ = _http_mock(_mcp_text({"id": 99})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.comment(99, label="done", duration=30) + args = _captured_body(http)["params"]["arguments"] + assert args == {"event_id": 99, "label": "done", "duration": 30} + assert "amount" not in args + assert "currency" not in args + assert "benefitable" not in args + + +def test_propose_maps_tool(): + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.propose(7, status_id=200, label="L") + body = _captured_body(http) + assert body["params"]["name"] == "propose-correction" + assert body["params"]["arguments"] == {"event_id": 7, "status_id": 200, "label": "L"} + + +def test_vote_payload_shape(): + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.vote(7, [1, 2], True) + body = _captured_body(http) + assert body["params"]["name"] == "vote-correction" + assert body["params"]["arguments"] == {"event_id": 7, "ids": [1, 2], "vote": True} + + +def test_history_tool_name(): + http, _ = _http_mock(_mcp_text([])) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.history(42) + body = _captured_body(http) + assert body["params"]["name"] == "get-event-history" + assert body["params"]["arguments"] == {"event_id": 42} + + +# ── polling ─────────────────────────────────────────────────────── + + +def test_poll_returns_list_directly(): + http, _ = _http_mock([{"x": 1}, {"x": 2}]) + c = ContractClient( + client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t" + ) + assert c.poll() == [{"x": 1}, {"x": 2}] + + +def test_poll_unwraps_notifications_key(): + http, _ = _http_mock({"notifications": [{"a": 1}]}) + c = ContractClient( + client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t" + ) + assert c.poll() == [{"a": 1}] + + +def test_poll_429_returns_empty(): + http, _ = _http_mock({"error": "rate"}, status=429) + c = ContractClient( + client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t" + ) + assert c.poll() == [] + + +def test_poll_other_error_raises(): + http, _ = _http_mock({"error": "boom"}, status=500) + c = ContractClient( + client=http, endpoint="http://x/mcp/agent", api_base="http://x/api", token="t" + ) + with pytest.raises(ContractError): + c.poll() + + +# ── CLI parser ──────────────────────────────────────────────────── + + +def test_parser_contract_list(): + a = build_parser().parse_args(["contract", "list"]) + assert a.command == "contract" and a.contract_action == "list" + + +def test_parser_contract_create_flags(): + a = build_parser().parse_args([ + "contract", "create", "14", + "--label", "X", "--status", "100", "--type", "2", + "--kal-schedule", "9", + "--duration", "60", "--amount", "1000", "--currency", "USD", + "--benefitable", "agent:8", "--desc", "d", + ]) + assert a.contract_action == "create" + assert a.cid == 14 + assert a.label == "X" + assert a.status == 100 + assert a.type == 2 + assert a.kal_schedule == 9 + assert a.duration == 60 + assert a.amount == 1000 + assert a.currency == "USD" + assert a.benefitable == "agent:8" + assert a.desc == "d" + + +def test_parser_contract_vote(): + a = build_parser().parse_args(["contract", "vote", "42", "--ids", "1,2", "--vote", "true"]) + assert a.eid == 42 and a.ids == "1,2" and a.vote == "true" + + +def test_parser_contract_watch_default(): + a = build_parser().parse_args(["contract", "watch"]) + assert a.interval == 8 + + +def test_parser_contract_raw(): + a = build_parser().parse_args(["contract", "raw", "get-event", '{"event_id":1}']) + assert a.tool == "get-event" and a.args == '{"event_id":1}' + + +def test_parser_contract_tasks_optional_cid(): + a = build_parser().parse_args(["contract", "tasks"]) + assert a.cid is None + a = build_parser().parse_args(["contract", "tasks", "14"]) + assert a.cid == 14 + + +# ── extended MCP schema coverage (timezone/data/start/end/date/limit) ── + + +def test_create_passes_timezone_and_data(): + http, _ = _http_mock(_mcp_text({"id": 5})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.create( + 14, label="L", timezone="Europe/Moscow", + data={"foo": "bar"}, start="2026-06-20 10:00:00", + ) + args = _captured_body(http)["params"]["arguments"] + assert args["timezone"] == "Europe/Moscow" + assert args["data"] == {"foo": "bar"} + assert args["start"] == "2026-06-20 10:00:00" + + +def test_comment_passes_start_end_date(): + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.comment(7, label="x", start="s", end="e", date="2026-06-18") + args = _captured_body(http)["params"]["arguments"] + assert args["start"] == "s" and args["end"] == "e" and args["date"] == "2026-06-18" + + +def test_propose_passes_start_end_date(): + http, _ = _http_mock(_mcp_text({})) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.propose(7, status_id=200, start="s", end="e", date="2026-06-18") + args = _captured_body(http)["params"]["arguments"] + assert args["start"] == "s" and args["end"] == "e" and args["date"] == "2026-06-18" + + +def test_history_passes_limit(): + http, _ = _http_mock(_mcp_text([])) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.history(42, limit=10) + args = _captured_body(http)["params"]["arguments"] + assert args == {"event_id": 42, "limit": 10} + + +def test_history_no_limit_omits_field(): + http, _ = _http_mock(_mcp_text([])) + c = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + c.history(42) + args = _captured_body(http)["params"]["arguments"] + assert args == {"event_id": 42} + + +def test_parser_history_limit(): + a = build_parser().parse_args(["contract", "history", "5", "--limit", "20"]) + assert a.eid == 5 and a.limit == 20 + + +def test_parser_create_timezone_data(): + a = build_parser().parse_args([ + "contract", "create", "14", "--label", "X", + "--timezone", "UTC", "--data", '{"k":1}', + ]) + assert a.timezone == "UTC" and a.data == '{"k":1}' + + +def test_parser_comment_start_end_date(): + a = build_parser().parse_args([ + "contract", "comment", "5", + "--start", "s", "--end", "e", "--date", "d", + ]) + assert a.start == "s" and a.end == "e" and a.date == "d" + + +def test_parser_propose_start_end_date(): + a = build_parser().parse_args([ + "contract", "propose", "5", + "--start", "s", "--end", "e", "--date", "d", + ]) + assert a.start == "s" and a.end == "e" and a.date == "d" diff --git a/tests/test_timelog.py b/tests/test_timelog.py new file mode 100644 index 0000000..75ddb06 --- /dev/null +++ b/tests/test_timelog.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import httpx +import pytest + +from ceki_sdk.cli import build_parser +from ceki_sdk.contract import ContractClient, ContractError +from ceki_sdk.timelog import TimelogClient + +# ── helpers ─────────────────────────────────────────────────────── + + +def _http_mock(payload, status: int = 200): + resp = MagicMock(spec=httpx.Response) + resp.status_code = status + resp.json.return_value = payload + resp.text = json.dumps(payload) + client = MagicMock(spec=httpx.Client) + client.post.return_value = resp + return client, resp + + +def _mcp_text(obj) -> dict: + return {"result": {"content": [{"type": "text", "text": json.dumps(obj)}]}} + + +def _captured_body(http: MagicMock) -> dict: + return http.post.call_args.kwargs["json"] + + +def _timelog(http: MagicMock) -> TimelogClient: + cc = ContractClient(client=http, endpoint="http://x/mcp/agent", token="t") + return TimelogClient(contract=cc) + + +# ── tool name mapping ───────────────────────────────────────────── + + +def test_start_calls_timelog_start(): + http, _ = _http_mock(_mcp_text({"id": 1})) + tl = _timelog(http) + tl.start(42) + body = _captured_body(http) + assert body["method"] == "tools/call" + assert body["params"]["name"] == "timelog-start" + assert body["params"]["arguments"] == {"event_id": 42} + + +def test_stop_without_label_omits_field(): + http, _ = _http_mock(_mcp_text({"id": 2})) + tl = _timelog(http) + tl.stop(42) + body = _captured_body(http) + assert body["params"]["name"] == "timelog-stop" + assert body["params"]["arguments"] == {"event_id": 42} + assert "label" not in body["params"]["arguments"] + + +def test_stop_with_label(): + http, _ = _http_mock(_mcp_text({"id": 3})) + tl = _timelog(http) + tl.stop(42, label="что сделал") + body = _captured_body(http) + assert body["params"]["name"] == "timelog-stop" + assert body["params"]["arguments"] == {"event_id": 42, "label": "что сделал"} + + +def test_check_calls_timelog_check(): + http, _ = _http_mock(_mcp_text({"open": False})) + tl = _timelog(http) + assert tl.check(7) == {"open": False} + body = _captured_body(http) + assert body["params"]["name"] == "timelog-check" + assert body["params"]["arguments"] == {"event_id": 7} + + +def test_start_unwraps_text_json(): + http, _ = _http_mock(_mcp_text({"started_at": "2026-06-17T10:00:00Z"})) + tl = _timelog(http) + assert tl.start(1) == {"started_at": "2026-06-17T10:00:00Z"} + + +def test_error_propagates_as_contract_error(): + http, _ = _http_mock({"error": {"code": -32000, "message": "no open log"}}) + tl = _timelog(http) + with pytest.raises(ContractError): + tl.stop(1) + + +def test_event_id_coerced_to_int(): + http, _ = _http_mock(_mcp_text({})) + tl = _timelog(http) + tl.start("42") # str passed through int() + args = _captured_body(http)["params"]["arguments"] + assert args == {"event_id": 42} + + +# ── CLI parser ──────────────────────────────────────────────────── + + +def test_parser_timelog_start(): + a = build_parser().parse_args(["timelog", "start", "42"]) + assert a.command == "timelog" + assert a.timelog_action == "start" + assert a.event_id == 42 + + +def test_parser_timelog_stop_no_label(): + a = build_parser().parse_args(["timelog", "stop", "42"]) + assert a.timelog_action == "stop" + assert a.event_id == 42 + assert a.label is None + + +def test_parser_timelog_stop_with_label(): + a = build_parser().parse_args(["timelog", "stop", "42", "--label", "fixed bug"]) + assert a.timelog_action == "stop" + assert a.label == "fixed bug" + + +def test_parser_timelog_check(): + a = build_parser().parse_args(["timelog", "check", "42"]) + assert a.timelog_action == "check" + assert a.event_id == 42 + + +def test_parser_timelog_is_top_level_not_under_contract(): + # ensure ceki contract timelog-start does NOT exist as a subcommand + p = build_parser() + with pytest.raises(SystemExit): + p.parse_args(["contract", "timelog-start", "42"]) + + +def test_parser_timelog_event_id_required(): + p = build_parser() + with pytest.raises(SystemExit): + p.parse_args(["timelog", "start"]) diff --git a/tests/test_type_keyboard_events.py b/tests/test_type_keyboard_events.py index 5ae5ed8..40cc8c9 100644 --- a/tests/test_type_keyboard_events.py +++ b/tests/test_type_keyboard_events.py @@ -87,3 +87,38 @@ async def fake_send(cdp, **kw): assert type_events[0]["params"]["text"] == "ы" # no fallback Input.insertText from SDK — extension handles non-ASCII assert not [s for s in sent if s["method"] == "Input.insertText"] + + +async def test_type_with_selector_forwards_selector_no_runtime_evaluate(browser: Browser): + # task 425 BUG-1 — selector must travel inside Ceki.typeText, never via + # Runtime.evaluate(document.querySelector). The bare CDP eval landed in + # service-worker context on SW-registered pages (signup.live.com) → + # "ReferenceError: document is not defined". Extension handles focus via + # chrome.scripting which is page-frame scoped. + sent: list[dict] = [] + async def fake_send(cdp, **kw): + sent.append(cdp) + return {} + browser.send = fake_send + + await browser.type("vc@ceki.me", selector="input[type=email]") + + assert not [s for s in sent if s["method"] == "Runtime.evaluate"] + type_events = [s for s in sent if s["method"] == "Ceki.typeText"] + assert len(type_events) == 1 + assert type_events[0]["params"]["text"] == "vc@ceki.me" + assert type_events[0]["params"]["selector"] == "input[type=email]" + + +async def test_type_without_selector_omits_selector_param(browser: Browser): + sent: list[dict] = [] + async def fake_send(cdp, **kw): + sent.append(cdp) + return {} + browser.send = fake_send + + await browser.type("hi") + + type_events = [s for s in sent if s["method"] == "Ceki.typeText"] + assert len(type_events) == 1 + assert "selector" not in type_events[0]["params"]