unchainedsky-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unchainedsky_cli-0.1.0/PKG-INFO +8 -0
- unchainedsky_cli-0.1.0/README.md +182 -0
- unchainedsky_cli-0.1.0/pyproject.toml +24 -0
- unchainedsky_cli-0.1.0/setup.cfg +4 -0
- unchainedsky_cli-0.1.0/tests/test_bin.py +53 -0
- unchainedsky_cli-0.1.0/tests/test_chrome.py +123 -0
- unchainedsky_cli-0.1.0/tests/test_cli.py +195 -0
- unchainedsky_cli-0.1.0/tests/test_ddm.py +32 -0
- unchainedsky_cli-0.1.0/tests/test_launch.py +299 -0
- unchainedsky_cli-0.1.0/unchained_cli/__init__.py +3 -0
- unchainedsky_cli-0.1.0/unchained_cli/__main__.py +4 -0
- unchainedsky_cli-0.1.0/unchained_cli/agent.py +487 -0
- unchainedsky_cli-0.1.0/unchained_cli/chrome.py +586 -0
- unchainedsky_cli-0.1.0/unchained_cli/cli.py +672 -0
- unchainedsky_cli-0.1.0/unchained_cli/ddm.py +52 -0
- unchainedsky_cli-0.1.0/unchained_cli/ddm_engine.py +2782 -0
- unchainedsky_cli-0.1.0/unchained_cli/intel.py +51 -0
- unchainedsky_cli-0.1.0/unchained_cli/intel_engine.py +1431 -0
- unchainedsky_cli-0.1.0/unchained_cli/launch.py +428 -0
- unchainedsky_cli-0.1.0/unchained_cli/stealth.py +123 -0
- unchainedsky_cli-0.1.0/unchainedsky_cli.egg-info/PKG-INFO +8 -0
- unchainedsky_cli-0.1.0/unchainedsky_cli.egg-info/SOURCES.txt +24 -0
- unchainedsky_cli-0.1.0/unchainedsky_cli.egg-info/dependency_links.txt +1 -0
- unchainedsky_cli-0.1.0/unchainedsky_cli.egg-info/entry_points.txt +4 -0
- unchainedsky_cli-0.1.0/unchainedsky_cli.egg-info/requires.txt +4 -0
- unchainedsky_cli-0.1.0/unchainedsky_cli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# unchainedsky-cli
|
|
2
|
+
|
|
3
|
+
Browser automation CLI over local Chrome CDP — DDM-first methodology for LLM agents.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# With uv (recommended)
|
|
9
|
+
uv tool install unchainedsky-cli[agent]
|
|
10
|
+
|
|
11
|
+
# Or pip
|
|
12
|
+
pip install unchainedsky-cli[agent]
|
|
13
|
+
|
|
14
|
+
# Or brew
|
|
15
|
+
brew install --HEAD unchainedsky/tap/unchainedsky-cli
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The `[agent]` extra includes the Anthropic SDK for the interactive Claude agent. Omit it if you only need the CLI tools.
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Launch Chrome and browse
|
|
24
|
+
unchained launch https://news.ycombinator.com
|
|
25
|
+
|
|
26
|
+
# Navigate returns page layout + intel probe automatically
|
|
27
|
+
unchained navigate https://example.com
|
|
28
|
+
|
|
29
|
+
# Search page text
|
|
30
|
+
unchained ddm --text --find "price"
|
|
31
|
+
|
|
32
|
+
# Element details at coordinates
|
|
33
|
+
unchained ddm --at 694,584
|
|
34
|
+
|
|
35
|
+
# Start the interactive Claude agent
|
|
36
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
37
|
+
unchained agent "find the cheapest flight to NYC"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Launch Options
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Sandboxed profile (default — clean, isolated)
|
|
44
|
+
unchained launch
|
|
45
|
+
|
|
46
|
+
# Existing Chrome profile (with cookies, logins, extensions)
|
|
47
|
+
unchained --port 9333 launch --use-profile --profile "Profile 3" https://x.com
|
|
48
|
+
|
|
49
|
+
# Headless with stealth (auto-enables fingerprint evasion)
|
|
50
|
+
unchained launch --headless https://example.com
|
|
51
|
+
|
|
52
|
+
# Stealth mode (visible window, bot detection evasion)
|
|
53
|
+
unchained launch --stealth https://example.com
|
|
54
|
+
|
|
55
|
+
# Multiple profiles on different ports
|
|
56
|
+
unchained --port 9222 launch --profile work
|
|
57
|
+
unchained --port 9333 launch --use-profile --profile "Profile 3"
|
|
58
|
+
unchained --port 9444 launch --use-profile --profile "Profile 8"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
### Browser Lifecycle
|
|
64
|
+
|
|
65
|
+
| Command | Description |
|
|
66
|
+
|---------|-------------|
|
|
67
|
+
| `launch [url]` | Launch Chrome with CDP (`--profile`, `--use-profile`, `--stealth`, `--headless`) |
|
|
68
|
+
| `status` | Check if Chrome is alive |
|
|
69
|
+
| `kill` | Kill Chrome on this port |
|
|
70
|
+
|
|
71
|
+
### Navigation & Interaction
|
|
72
|
+
|
|
73
|
+
| Command | Description |
|
|
74
|
+
|---------|-------------|
|
|
75
|
+
| `navigate <url>` | Navigate (returns inline DDM layout + Intel probe) |
|
|
76
|
+
| `click --x X --y Y` | Click at pixel coordinates from DDM |
|
|
77
|
+
| `click --selector CSS` | Click by CSS selector |
|
|
78
|
+
| `type <text>` | Type into focused element |
|
|
79
|
+
| `press_enter` | Press Enter key |
|
|
80
|
+
| `key <key> [--modifiers N]` | Press any key (Escape, Tab, ArrowDown, etc.) |
|
|
81
|
+
| `scroll [--direction DIR]` | Scroll page (up/down/left/right, default: 500px) |
|
|
82
|
+
| `submit_form [--selector]` | Submit a form |
|
|
83
|
+
| `set_file --selector CSS --files PATH...` | Set files on file input |
|
|
84
|
+
|
|
85
|
+
### Page Intelligence
|
|
86
|
+
|
|
87
|
+
| Command | Description |
|
|
88
|
+
|---------|-------------|
|
|
89
|
+
| `ddm` | DOM Density Map — text layout with interactive elements |
|
|
90
|
+
| `ddm --text` | Extract page text (innerText) |
|
|
91
|
+
| `ddm --text --find "keyword"` | Search page text, show nearby elements |
|
|
92
|
+
| `ddm --at 694,584` | Element details at pixel coordinates |
|
|
93
|
+
| `ddm --sparse` | RLE-compressed output (~100-400 tokens) |
|
|
94
|
+
| `ddm --interactive` | Interactive elements only (smallest output) |
|
|
95
|
+
| `ddm --llm-2pass` | LLM-optimized layout (default for navigate) |
|
|
96
|
+
| `ddm --tabs` | List open tabs |
|
|
97
|
+
| `ddm --forms` | Detect forms as callable contracts |
|
|
98
|
+
| `intel --probe` | Fingerprint page + rank 8 extraction strategies |
|
|
99
|
+
| `intel --extract` | Full extraction pipeline (auto-selects strategy) |
|
|
100
|
+
| `intel --stores` | List JS data stores (YouTube, Next.js, Nuxt, etc.) |
|
|
101
|
+
| `intel --shape <global>` | Map JS global variable structure |
|
|
102
|
+
| `intel --find-paths <global> <pattern>` | Search paths in JS globals |
|
|
103
|
+
|
|
104
|
+
### Data & Tabs
|
|
105
|
+
|
|
106
|
+
| Command | Description |
|
|
107
|
+
|---------|-------------|
|
|
108
|
+
| `js <expression>` | Evaluate JavaScript |
|
|
109
|
+
| `js_frame <frame> <expr>` | Evaluate JS in a specific iframe |
|
|
110
|
+
| `screenshot [--output FILE]` | Save screenshot (last resort — use DDM first) |
|
|
111
|
+
| `tabs` | List open tabs |
|
|
112
|
+
| `create_tab [url]` | Open a new tab |
|
|
113
|
+
| `close_tab <tab_id>` | Close a tab |
|
|
114
|
+
| `cookies get [--urls URL...]` | Get cookies |
|
|
115
|
+
| `cookies set <json>` | Inject cookies |
|
|
116
|
+
| `frames` | List iframes |
|
|
117
|
+
| `cdp <method> [params]` | Send raw CDP command |
|
|
118
|
+
| `wait [--strategy dom\|network\|both]` | Wait for page load |
|
|
119
|
+
| `alias set <name> <tab_id>` | Set a tab alias |
|
|
120
|
+
| `alias list` | List tab aliases |
|
|
121
|
+
|
|
122
|
+
### Agent
|
|
123
|
+
|
|
124
|
+
| Command | Description |
|
|
125
|
+
|---------|-------------|
|
|
126
|
+
| `agent [task]` | Interactive Claude browser agent |
|
|
127
|
+
| `agent --model opus "task"` | Use a specific model (sonnet/opus/haiku) |
|
|
128
|
+
|
|
129
|
+
## DDM-First Methodology
|
|
130
|
+
|
|
131
|
+
Every browsing task follows this pipeline:
|
|
132
|
+
|
|
133
|
+
1. **ORIENT** — `navigate` and `click` return DDM layout inline. Read it. Don't call `ddm` separately.
|
|
134
|
+
2. **IDENTIFY** — `ddm --at x,y` on elements you need details about.
|
|
135
|
+
3. **CLASSIFY** — Check the Intel probe in navigate output. If `js_global > 50%`, use `intel --stores`. If `host_attrs > 50%`, use `intel --extract`.
|
|
136
|
+
4. **ACT** — Click coordinates from DDM, type text, or run JS.
|
|
137
|
+
5. **VERIFY** — Check the layout changed after actions.
|
|
138
|
+
6. **EXTRACT** — Use `ddm --text`, `intel --extract`, or `js` based on page type.
|
|
139
|
+
|
|
140
|
+
### Token Comparison
|
|
141
|
+
|
|
142
|
+
| Method | Tokens | Info |
|
|
143
|
+
|--------|--------|------|
|
|
144
|
+
| Screenshot (base64 PNG) | ~2,100 | Visual only, no selectors |
|
|
145
|
+
| `ddm --llm-2pass` | ~500 | Layout + interactive elements |
|
|
146
|
+
| `ddm --sparse` | ~100-400 | RLE-compressed, varies by complexity |
|
|
147
|
+
| `intel --probe` | ~50 | Fingerprint + strategy ranking |
|
|
148
|
+
|
|
149
|
+
## Global Options
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
--port PORT Chrome remote debugging port (default: 9222, env: UNCHAINED_PORT)
|
|
153
|
+
--tab TAB_ID Target tab ID, alias, or 'auto' (default: auto)
|
|
154
|
+
--json Output raw JSON
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Environment Variables
|
|
158
|
+
|
|
159
|
+
| Variable | Default | Description |
|
|
160
|
+
|----------|---------|-------------|
|
|
161
|
+
| `UNCHAINED_PORT` | `9222` | Chrome remote debugging port |
|
|
162
|
+
| `UNCHAINED_DATA_DIR` | `~/.unchained` | Base directory for Chrome profiles |
|
|
163
|
+
| `UNCHAINED_CHROME_BIN` | — | Chrome/Chromium binary override |
|
|
164
|
+
| `UNCHAINED_DDM_BIN` | — | DDM binary path override |
|
|
165
|
+
| `UNCHAINED_INTEL_BIN` | — | Intel binary path override |
|
|
166
|
+
| `ANTHROPIC_API_KEY` | — | Required for `agent` command |
|
|
167
|
+
|
|
168
|
+
## Build Binaries
|
|
169
|
+
|
|
170
|
+
DDM and Intel can be compiled to native binaries (protects proprietary algorithms):
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
pip install nuitka ordered-set
|
|
174
|
+
python build_binaries.py --install
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Binaries are installed to `~/.unchained/bin/`. The CLI automatically uses them when available, falling back to the Python engine otherwise.
|
|
178
|
+
|
|
179
|
+
## Requirements
|
|
180
|
+
|
|
181
|
+
- Python 3.10+
|
|
182
|
+
- Google Chrome, Chromium, or Microsoft Edge
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=70"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unchainedsky-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Browser automation CLI over local Chrome CDP"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"websockets>=12.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
agent = ["anthropic>=0.49"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
unchained = "unchained_cli.cli:main"
|
|
19
|
+
unchained-ddm = "unchained_cli.ddm_engine:main"
|
|
20
|
+
unchained-intel = "unchained_cli.intel_engine:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["."]
|
|
24
|
+
include = ["unchained_cli*"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
10
|
+
BIN = ROOT / "bin" / "unchained"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LauncherTests(unittest.TestCase):
|
|
14
|
+
def test_launcher_smoke_with_supported_python(self):
|
|
15
|
+
env = os.environ.copy()
|
|
16
|
+
env["UNCHAINED_PYTHON"] = sys.executable
|
|
17
|
+
|
|
18
|
+
result = subprocess.run(
|
|
19
|
+
[str(BIN), "--help"],
|
|
20
|
+
cwd=ROOT,
|
|
21
|
+
env=env,
|
|
22
|
+
text=True,
|
|
23
|
+
capture_output=True,
|
|
24
|
+
check=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
28
|
+
self.assertIn("Browser automation over local Chrome CDP.", result.stdout)
|
|
29
|
+
|
|
30
|
+
def test_launcher_rejects_unsupported_python(self):
|
|
31
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
32
|
+
fake_python = Path(tmpdir) / "python-old"
|
|
33
|
+
fake_python.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
|
|
34
|
+
fake_python.chmod(0o755)
|
|
35
|
+
|
|
36
|
+
env = os.environ.copy()
|
|
37
|
+
env["UNCHAINED_PYTHON"] = str(fake_python)
|
|
38
|
+
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
[str(BIN), "--help"],
|
|
41
|
+
cwd=ROOT,
|
|
42
|
+
env=env,
|
|
43
|
+
text=True,
|
|
44
|
+
capture_output=True,
|
|
45
|
+
check=False,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self.assertEqual(result.returncode, 1)
|
|
49
|
+
self.assertIn("requires Python 3.10+", result.stderr)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
unittest.main()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
from unchained_cli.chrome import CDPError, ChromeClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeWebSocket:
|
|
10
|
+
def __init__(self, messages):
|
|
11
|
+
self.messages = asyncio.Queue()
|
|
12
|
+
for message in messages:
|
|
13
|
+
self.messages.put_nowait(json.dumps(message))
|
|
14
|
+
self.sent = []
|
|
15
|
+
|
|
16
|
+
async def __aenter__(self):
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
async def send(self, raw):
|
|
23
|
+
self.sent.append(json.loads(raw))
|
|
24
|
+
|
|
25
|
+
async def recv(self):
|
|
26
|
+
return await self.messages.get()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FakeWebsocketsModule:
|
|
30
|
+
def __init__(self, websocket):
|
|
31
|
+
self.websocket = websocket
|
|
32
|
+
|
|
33
|
+
def connect(self, ws_url, ping_timeout=None):
|
|
34
|
+
self.ws_url = ws_url
|
|
35
|
+
self.ping_timeout = ping_timeout
|
|
36
|
+
return self.websocket
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ChromeClientTests(unittest.TestCase):
|
|
40
|
+
def test_resolve_tab_auto_returns_first_page_tab(self):
|
|
41
|
+
client = ChromeClient()
|
|
42
|
+
with mock.patch.object(
|
|
43
|
+
client,
|
|
44
|
+
"list_tabs",
|
|
45
|
+
return_value=[{"id": "tab-1"}, {"id": "tab-2"}],
|
|
46
|
+
):
|
|
47
|
+
self.assertEqual(client.resolve_tab("auto"), "tab-1")
|
|
48
|
+
|
|
49
|
+
def test_js_eval_frame_uses_isolated_world_context(self):
|
|
50
|
+
client = ChromeClient()
|
|
51
|
+
with mock.patch.object(
|
|
52
|
+
client,
|
|
53
|
+
"list_frames",
|
|
54
|
+
return_value=[{"index": 0, "frameId": "frame-1", "url": "", "name": ""}],
|
|
55
|
+
), mock.patch.object(
|
|
56
|
+
client,
|
|
57
|
+
"send",
|
|
58
|
+
side_effect=[
|
|
59
|
+
{"executionContextId": 77},
|
|
60
|
+
{"result": {"type": "number", "value": 5}},
|
|
61
|
+
],
|
|
62
|
+
) as send:
|
|
63
|
+
result = client.js_eval_frame("tab-1", "0", "2 + 3")
|
|
64
|
+
|
|
65
|
+
self.assertEqual(result, 5)
|
|
66
|
+
self.assertEqual(send.call_args_list[0].args[1], "Page.createIsolatedWorld")
|
|
67
|
+
self.assertEqual(send.call_args_list[1].args[1], "Runtime.evaluate")
|
|
68
|
+
self.assertEqual(send.call_args_list[1].args[2]["contextId"], 77)
|
|
69
|
+
|
|
70
|
+
def test_wait_ready_dom_strategy_polls_document_ready_state(self):
|
|
71
|
+
websocket = FakeWebSocket([
|
|
72
|
+
{"id": 1, "result": {"result": {"type": "string", "value": "complete"}}},
|
|
73
|
+
])
|
|
74
|
+
client = ChromeClient()
|
|
75
|
+
|
|
76
|
+
async def run_test():
|
|
77
|
+
with mock.patch.object(
|
|
78
|
+
client,
|
|
79
|
+
"_require_websockets",
|
|
80
|
+
return_value=FakeWebsocketsModule(websocket),
|
|
81
|
+
):
|
|
82
|
+
return await client._async_wait_ready("ws://example", "dom", timeout=0.1)
|
|
83
|
+
|
|
84
|
+
result = asyncio.run(run_test())
|
|
85
|
+
|
|
86
|
+
self.assertEqual(result, "ready")
|
|
87
|
+
self.assertEqual(websocket.sent[0]["method"], "Runtime.enable")
|
|
88
|
+
self.assertEqual(websocket.sent[1]["method"], "Runtime.evaluate")
|
|
89
|
+
|
|
90
|
+
def test_wait_ready_network_strategy_waits_for_request_completion(self):
|
|
91
|
+
websocket = FakeWebSocket([
|
|
92
|
+
{"method": "Network.requestWillBeSent", "params": {"requestId": "req-1"}},
|
|
93
|
+
{"method": "Network.loadingFinished", "params": {"requestId": "req-1"}},
|
|
94
|
+
])
|
|
95
|
+
client = ChromeClient()
|
|
96
|
+
|
|
97
|
+
async def run_test():
|
|
98
|
+
with mock.patch("unchained_cli.chrome._NETWORK_IDLE_WINDOW", 0.0), mock.patch.object(
|
|
99
|
+
client,
|
|
100
|
+
"_require_websockets",
|
|
101
|
+
return_value=FakeWebsocketsModule(websocket),
|
|
102
|
+
):
|
|
103
|
+
return await client._async_wait_ready("ws://example", "network", timeout=0.1)
|
|
104
|
+
|
|
105
|
+
result = asyncio.run(run_test())
|
|
106
|
+
|
|
107
|
+
self.assertEqual(result, "ready")
|
|
108
|
+
self.assertEqual(websocket.sent[0]["method"], "Runtime.enable")
|
|
109
|
+
self.assertEqual(websocket.sent[1]["method"], "Network.enable")
|
|
110
|
+
|
|
111
|
+
def test_wait_ready_timeout_raises_cdp_error(self):
|
|
112
|
+
client = ChromeClient()
|
|
113
|
+
with mock.patch.object(client, "_ws_url_for", return_value="ws://example"), mock.patch.object(
|
|
114
|
+
client,
|
|
115
|
+
"_async_wait_ready",
|
|
116
|
+
new=mock.AsyncMock(side_effect=asyncio.TimeoutError),
|
|
117
|
+
):
|
|
118
|
+
with self.assertRaisesRegex(CDPError, "strategy=dom"):
|
|
119
|
+
client.wait_ready("tab-1", "dom", timeout=0.1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
unittest.main()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
from contextlib import redirect_stdout
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
from unchained_cli import cli
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeClient:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.cookies_args = None
|
|
20
|
+
|
|
21
|
+
def list_tabs(self):
|
|
22
|
+
return [{"id": "tab-1", "title": "Example", "url": "https://example.com"}]
|
|
23
|
+
|
|
24
|
+
def resolve_tab(self, tab_id):
|
|
25
|
+
return "tab-1"
|
|
26
|
+
|
|
27
|
+
def navigate(self, tab_id, url):
|
|
28
|
+
return {"frameId": "frame-1"}
|
|
29
|
+
|
|
30
|
+
def js_eval(self, tab_id, expression):
|
|
31
|
+
return "https://example.com/final"
|
|
32
|
+
|
|
33
|
+
def get_cookies(self, tab_id, urls):
|
|
34
|
+
self.cookies_args = (tab_id, urls)
|
|
35
|
+
return [{"name": "session", "value": "abc", "domain": ".example.com"}]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CliCommandTests(unittest.TestCase):
|
|
39
|
+
def test_cmd_launch_reports_existing_chrome_with_unknown_profile_dir(self):
|
|
40
|
+
args = SimpleNamespace(
|
|
41
|
+
port=9222,
|
|
42
|
+
profile="alt",
|
|
43
|
+
headless=False,
|
|
44
|
+
url="https://example.com",
|
|
45
|
+
timeout=15.0,
|
|
46
|
+
chrome_args=[],
|
|
47
|
+
use_profile=False,
|
|
48
|
+
stealth=False,
|
|
49
|
+
json=False,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
stdout = io.StringIO()
|
|
53
|
+
with mock.patch.object(
|
|
54
|
+
cli._launch,
|
|
55
|
+
"launch_chrome",
|
|
56
|
+
return_value={
|
|
57
|
+
"already_running": True,
|
|
58
|
+
"host": "127.0.0.1",
|
|
59
|
+
"port": 9222,
|
|
60
|
+
"profile_dir": None,
|
|
61
|
+
"startup_url": "https://example.com",
|
|
62
|
+
},
|
|
63
|
+
), redirect_stdout(stdout):
|
|
64
|
+
cli.cmd_launch(args)
|
|
65
|
+
|
|
66
|
+
text = stdout.getvalue()
|
|
67
|
+
self.assertIn("Chrome ready → http://127.0.0.1:9222 (already running)", text)
|
|
68
|
+
self.assertIn("Profile dir → unknown (attached to existing Chrome on this port)", text)
|
|
69
|
+
self.assertIn("Startup URL → https://example.com", text)
|
|
70
|
+
|
|
71
|
+
def test_cmd_launch_reports_started_chrome(self):
|
|
72
|
+
args = SimpleNamespace(
|
|
73
|
+
port=9222,
|
|
74
|
+
profile="default",
|
|
75
|
+
headless=False,
|
|
76
|
+
url="https://example.com",
|
|
77
|
+
timeout=15.0,
|
|
78
|
+
chrome_args=[],
|
|
79
|
+
use_profile=False,
|
|
80
|
+
stealth=False,
|
|
81
|
+
json=False,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
stdout = io.StringIO()
|
|
85
|
+
with mock.patch.object(
|
|
86
|
+
cli._launch,
|
|
87
|
+
"launch_chrome",
|
|
88
|
+
return_value={
|
|
89
|
+
"already_running": False,
|
|
90
|
+
"host": "127.0.0.1",
|
|
91
|
+
"port": 9222,
|
|
92
|
+
"pid": 1234,
|
|
93
|
+
"profile_dir": "/tmp/profile",
|
|
94
|
+
"startup_url": "https://example.com",
|
|
95
|
+
},
|
|
96
|
+
), redirect_stdout(stdout):
|
|
97
|
+
cli.cmd_launch(args)
|
|
98
|
+
|
|
99
|
+
text = stdout.getvalue()
|
|
100
|
+
self.assertIn("Chrome started → http://127.0.0.1:9222 (PID 1234)", text)
|
|
101
|
+
self.assertIn("Profile dir → /tmp/profile", text)
|
|
102
|
+
self.assertIn("Startup URL → https://example.com", text)
|
|
103
|
+
|
|
104
|
+
def test_cmd_launch_omits_pid_when_unavailable(self):
|
|
105
|
+
args = SimpleNamespace(
|
|
106
|
+
port=9222,
|
|
107
|
+
profile="default",
|
|
108
|
+
headless=False,
|
|
109
|
+
url="https://example.com",
|
|
110
|
+
timeout=15.0,
|
|
111
|
+
chrome_args=[],
|
|
112
|
+
use_profile=False,
|
|
113
|
+
stealth=False,
|
|
114
|
+
json=False,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
stdout = io.StringIO()
|
|
118
|
+
with mock.patch.object(
|
|
119
|
+
cli._launch,
|
|
120
|
+
"launch_chrome",
|
|
121
|
+
return_value={
|
|
122
|
+
"already_running": False,
|
|
123
|
+
"host": "127.0.0.1",
|
|
124
|
+
"port": 9222,
|
|
125
|
+
"profile_dir": "/tmp/profile",
|
|
126
|
+
"startup_url": "https://example.com",
|
|
127
|
+
},
|
|
128
|
+
), redirect_stdout(stdout):
|
|
129
|
+
cli.cmd_launch(args)
|
|
130
|
+
|
|
131
|
+
text = stdout.getvalue()
|
|
132
|
+
self.assertIn("Chrome started → http://127.0.0.1:9222", text)
|
|
133
|
+
self.assertNotIn("(PID", text)
|
|
134
|
+
self.assertIn("Profile dir → /tmp/profile", text)
|
|
135
|
+
|
|
136
|
+
def test_cmd_tabs_outputs_json(self):
|
|
137
|
+
client = FakeClient()
|
|
138
|
+
args = SimpleNamespace(json=True)
|
|
139
|
+
|
|
140
|
+
stdout = io.StringIO()
|
|
141
|
+
with redirect_stdout(stdout):
|
|
142
|
+
cli.cmd_tabs(client, args)
|
|
143
|
+
|
|
144
|
+
self.assertEqual(
|
|
145
|
+
json.loads(stdout.getvalue()),
|
|
146
|
+
[{"id": "tab-1", "title": "Example", "url": "https://example.com"}],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def test_cmd_navigate_reports_final_url(self):
|
|
150
|
+
client = FakeClient()
|
|
151
|
+
args = SimpleNamespace(tab="auto", url="https://example.com", json=False)
|
|
152
|
+
|
|
153
|
+
stdout = io.StringIO()
|
|
154
|
+
with redirect_stdout(stdout):
|
|
155
|
+
cli.cmd_navigate(client, args)
|
|
156
|
+
|
|
157
|
+
self.assertIn("Navigated → https://example.com/final", stdout.getvalue())
|
|
158
|
+
|
|
159
|
+
def test_cmd_cookies_get_passes_multiple_urls(self):
|
|
160
|
+
client = FakeClient()
|
|
161
|
+
args = SimpleNamespace(
|
|
162
|
+
tab="auto",
|
|
163
|
+
urls=["https://example.com", "https://api.example.com"],
|
|
164
|
+
json=False,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
stdout = io.StringIO()
|
|
168
|
+
with redirect_stdout(stdout):
|
|
169
|
+
cli.cmd_cookies_get(client, args)
|
|
170
|
+
|
|
171
|
+
self.assertEqual(
|
|
172
|
+
client.cookies_args,
|
|
173
|
+
("tab-1", ["https://example.com", "https://api.example.com"]),
|
|
174
|
+
)
|
|
175
|
+
self.assertIn("(1 cookies)", stdout.getvalue())
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class CliSmokeTests(unittest.TestCase):
|
|
179
|
+
def test_module_help_smoke(self):
|
|
180
|
+
result = subprocess.run(
|
|
181
|
+
[sys.executable, "-m", "unchained_cli", "--help"],
|
|
182
|
+
cwd=ROOT,
|
|
183
|
+
text=True,
|
|
184
|
+
capture_output=True,
|
|
185
|
+
check=False,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
189
|
+
self.assertIn("Browser automation over local Chrome CDP.", result.stdout)
|
|
190
|
+
self.assertIn("launch", result.stdout)
|
|
191
|
+
self.assertIn("navigate", result.stdout)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
unittest.main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import unittest
|
|
3
|
+
from contextlib import redirect_stderr
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
from unchained_cli import ddm
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DdmTests(unittest.TestCase):
|
|
10
|
+
def test_run_ddm_falls_back_to_engine_when_no_binary(self):
|
|
11
|
+
"""When no binary is found, DDM falls back to the Python engine."""
|
|
12
|
+
with mock.patch.object(ddm, "_find_binary", return_value=None), \
|
|
13
|
+
mock.patch("unchained_cli.ddm_engine.run", side_effect=SystemExit(0)) as engine_run:
|
|
14
|
+
code = ddm.run_ddm(9222, "auto", ["--help"])
|
|
15
|
+
|
|
16
|
+
self.assertEqual(code, 0)
|
|
17
|
+
|
|
18
|
+
def test_run_ddm_invokes_binary(self):
|
|
19
|
+
completed = mock.Mock(returncode=0)
|
|
20
|
+
with mock.patch.object(ddm, "_find_binary", return_value="/tmp/ddm"), mock.patch.object(
|
|
21
|
+
ddm.subprocess,
|
|
22
|
+
"run",
|
|
23
|
+
return_value=completed,
|
|
24
|
+
) as run:
|
|
25
|
+
code = ddm.run_ddm(9222, "tab-1", ["--text"])
|
|
26
|
+
|
|
27
|
+
self.assertEqual(code, 0)
|
|
28
|
+
run.assert_called_once_with(["/tmp/ddm", "--port", "9222", "--tab", "tab-1", "--text"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
unittest.main()
|