browser-ctl 0.2.8__tar.gz → 0.2.10__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.
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/PKG-INFO +5 -2
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/README.md +4 -1
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/SKILL.md +39 -17
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/cli.py +219 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/client.py +120 -0
- browser_ctl-0.2.10/browser_ctl/extension/actions.js +584 -0
- browser_ctl-0.2.10/browser_ctl/extension/background.js +406 -0
- browser_ctl-0.2.10/browser_ctl/extension/click.js +366 -0
- browser_ctl-0.2.10/browser_ctl/extension/content-script.js +1193 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/extension/manifest.json +3 -2
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/server.py +1 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/PKG-INFO +5 -2
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/SOURCES.txt +3 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/pyproject.toml +1 -1
- browser_ctl-0.2.8/browser_ctl/extension/background.js +0 -1396
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/LICENSE +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/__init__.py +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/__main__.py +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/extension/icon-128.png +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/extension/icon-16.png +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/extension/icon-32.png +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/extension/icon-48.png +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/dependency_links.txt +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/entry_points.txt +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/requires.txt +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/top_level.txt +0 -0
- {browser_ctl-0.2.8 → browser_ctl-0.2.10}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: browser-ctl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.10
|
|
4
4
|
Summary: Control your browser from the command line via a Chrome extension + WebSocket bridge
|
|
5
5
|
Author-email: geb <853934146@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -125,7 +125,7 @@ Then in Chrome: `chrome://extensions` → Enable **Developer mode** → **Load u
|
|
|
125
125
|
**Step 3** — Verify:
|
|
126
126
|
|
|
127
127
|
```bash
|
|
128
|
-
bctl
|
|
128
|
+
bctl ensure-ready
|
|
129
129
|
# {"success": true, "data": {"server": true, "extension": true}}
|
|
130
130
|
```
|
|
131
131
|
|
|
@@ -216,7 +216,10 @@ All `<sel>` arguments accept CSS selectors **or** element refs from `snapshot` (
|
|
|
216
216
|
|
|
217
217
|
| Command | Description |
|
|
218
218
|
|---------|-------------|
|
|
219
|
+
| `bctl ensure-ready` | Ensure server + extension are ready (auto-starts server, auto-launches Chrome if needed) |
|
|
219
220
|
| `bctl ping` | Check server & extension status |
|
|
221
|
+
| `bctl capabilities` | Show actions supported by the connected extension |
|
|
222
|
+
| `bctl self-test` | Run generic end-to-end smoke tests for core skill actions |
|
|
220
223
|
| `bctl serve` | Start server in foreground |
|
|
221
224
|
| `bctl stop` | Stop server |
|
|
222
225
|
| `bctl setup` | Install extension to `~/.browser-ctl/extension/` + open Chrome extensions page |
|
|
@@ -99,7 +99,7 @@ Then in Chrome: `chrome://extensions` → Enable **Developer mode** → **Load u
|
|
|
99
99
|
**Step 3** — Verify:
|
|
100
100
|
|
|
101
101
|
```bash
|
|
102
|
-
bctl
|
|
102
|
+
bctl ensure-ready
|
|
103
103
|
# {"success": true, "data": {"server": true, "extension": true}}
|
|
104
104
|
```
|
|
105
105
|
|
|
@@ -190,7 +190,10 @@ All `<sel>` arguments accept CSS selectors **or** element refs from `snapshot` (
|
|
|
190
190
|
|
|
191
191
|
| Command | Description |
|
|
192
192
|
|---------|-------------|
|
|
193
|
+
| `bctl ensure-ready` | Ensure server + extension are ready (auto-starts server, auto-launches Chrome if needed) |
|
|
193
194
|
| `bctl ping` | Check server & extension status |
|
|
195
|
+
| `bctl capabilities` | Show actions supported by the connected extension |
|
|
196
|
+
| `bctl self-test` | Run generic end-to-end smoke tests for core skill actions |
|
|
194
197
|
| `bctl serve` | Start server in foreground |
|
|
195
198
|
| `bctl stop` | Stop server |
|
|
196
199
|
| `bctl setup` | Install extension to `~/.browser-ctl/extension/` + open Chrome extensions page |
|
|
@@ -14,11 +14,20 @@ Control Chrome via CLI. All commands return JSON to stdout.
|
|
|
14
14
|
|
|
15
15
|
## Always Start With
|
|
16
16
|
|
|
17
|
+
```bash
|
|
18
|
+
bctl ensure-ready
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`ensure-ready` auto-starts the local bridge server and will try to launch Chrome
|
|
22
|
+
if the extension is not connected yet.
|
|
23
|
+
|
|
24
|
+
Fallback diagnostics:
|
|
25
|
+
|
|
17
26
|
```bash
|
|
18
27
|
bctl ping
|
|
19
28
|
```
|
|
20
29
|
|
|
21
|
-
If
|
|
30
|
+
If it still shows `"extension": false`, tell the user to check Chrome and the extension.
|
|
22
31
|
|
|
23
32
|
## Core Principle: Text-First Page Perception
|
|
24
33
|
|
|
@@ -58,6 +67,8 @@ bctl uncheck <sel> [-i N] [-t text] Uncheck checkbox
|
|
|
58
67
|
bctl scroll <dir|sel> [n] Scroll: up/down/top/bottom/<selector> [pixels]
|
|
59
68
|
bctl select-option <sel> <val> [--text] Select dropdown option (alias: sopt)
|
|
60
69
|
bctl drag <src> [target] [--dx N --dy N] Drag element to target or by offset
|
|
70
|
+
bctl set-field <sel> <value> [--no-clear] [--text] Generic field setter
|
|
71
|
+
bctl submit-and-assert [sel] [--assert-selector CSS] [--assert-url EXP] [--mode ...] [--timeout s]
|
|
61
72
|
```
|
|
62
73
|
|
|
63
74
|
### Query
|
|
@@ -71,6 +82,8 @@ bctl count <sel> Count matching elements
|
|
|
71
82
|
bctl status Current page URL and title
|
|
72
83
|
bctl is-visible <sel> Check if element is visible (returns rect)
|
|
73
84
|
bctl get-value <sel> Get form element value (input/select/textarea)
|
|
85
|
+
bctl assert-url <exp> [--mode equals|includes|regex] Assert current URL
|
|
86
|
+
bctl assert-field-value <sel> <exp> [--mode ...] [--by-text] Assert field value/text
|
|
74
87
|
```
|
|
75
88
|
|
|
76
89
|
### JavaScript
|
|
@@ -117,7 +130,10 @@ browser call, reducing overhead by ~90%.
|
|
|
117
130
|
|
|
118
131
|
### Server
|
|
119
132
|
```
|
|
133
|
+
bctl ensure-ready Ensure server + extension are ready (auto-launch Chrome)
|
|
120
134
|
bctl ping Check server and extension status
|
|
135
|
+
bctl capabilities Show extension-supported actions
|
|
136
|
+
bctl self-test Run generic end-to-end smoke checks
|
|
121
137
|
bctl serve Start server (foreground)
|
|
122
138
|
bctl stop Stop server
|
|
123
139
|
```
|
|
@@ -147,19 +163,13 @@ Use `-t` to filter by visible text — ideal for SPAs where class names are dyna
|
|
|
147
163
|
bctl click "button" -t "Submit" # click button containing "Submit"
|
|
148
164
|
```
|
|
149
165
|
|
|
150
|
-
###
|
|
151
|
-
|
|
152
|
-
target URL via `chrome.tabs.create`. Just click like a normal user:
|
|
166
|
+
### Generic Submit + Assertion Flow
|
|
167
|
+
Prefer assertions after every important state change:
|
|
153
168
|
```bash
|
|
154
|
-
bctl
|
|
155
|
-
bctl
|
|
156
|
-
bctl
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
Fallback — extract content ID and navigate directly:
|
|
160
|
-
```bash
|
|
161
|
-
bctl attr ".root.list-item [dt-eid='poster']" "dt-params" | grep -o 'cid=[^&]*'
|
|
162
|
-
bctl go "https://v.qq.com/x/cover/<cid>.html"
|
|
169
|
+
bctl click "button" -t "Open settings"
|
|
170
|
+
bctl set-field "input[name='displayName']" "Alice"
|
|
171
|
+
bctl submit-and-assert "button[type='submit']" --assert-selector ".toast-success" --timeout 8
|
|
172
|
+
bctl assert-field-value "input[name='displayName']" "Alice"
|
|
163
173
|
```
|
|
164
174
|
|
|
165
175
|
### Waiting Strategy
|
|
@@ -169,13 +179,13 @@ bctl go "https://v.qq.com/x/cover/<cid>.html"
|
|
|
169
179
|
- Pure sleep (`bctl wait N`) runs locally in Python — no extension round-trip, so it
|
|
170
180
|
never times out even on heavy pages.
|
|
171
181
|
|
|
172
|
-
### Heavy SPA Pages
|
|
173
|
-
Heavy
|
|
182
|
+
### Heavy SPA Pages
|
|
183
|
+
Heavy pages can cause the extension service worker to become unresponsive during
|
|
174
184
|
page load. To avoid timeouts:
|
|
175
185
|
- **Don't chain `bctl wait` with navigation via `&&`** — if the page is loading, the
|
|
176
186
|
wait command may timeout because the extension is busy. Instead, run them separately:
|
|
177
187
|
```bash
|
|
178
|
-
bctl go "https://
|
|
188
|
+
bctl go "https://example.com"
|
|
179
189
|
bctl wait 3
|
|
180
190
|
bctl status
|
|
181
191
|
```
|
|
@@ -245,10 +255,22 @@ and returns text/href/id/class/aria-label automatically.
|
|
|
245
255
|
7. **Prefer `bctl go` over `bctl new-tab`** for simple navigation — fewer failure modes.
|
|
246
256
|
8. **Never use `eval` to set input values or click buttons on SPA sites** — use `type`/`input-text`/`click`.
|
|
247
257
|
9. **Verify after complex UI interactions** — `snapshot` or `text` to confirm state changed.
|
|
258
|
+
10. **Use assertion primitives** — `assert-url`, `assert-field-value`, `submit-and-assert`.
|
|
259
|
+
|
|
260
|
+
## Universal Interaction Template
|
|
261
|
+
|
|
262
|
+
Use this sequence on any site:
|
|
263
|
+
|
|
264
|
+
1. `bctl ensure-ready`
|
|
265
|
+
2. `bctl status` and `bctl snapshot`
|
|
266
|
+
3. Prefer `eN` refs from snapshot when possible
|
|
267
|
+
4. Execute (`click`/`set-field`/`type`)
|
|
268
|
+
5. Assert (`assert-url` / `assert-field-value` / `count`)
|
|
269
|
+
6. Retry only when error is retriable; otherwise refresh snapshot and re-select
|
|
248
270
|
|
|
249
271
|
## Known Limitations
|
|
250
272
|
|
|
251
|
-
- `eval` blocked by Trusted Types on some
|
|
273
|
+
- `eval` may be blocked by Trusted Types/CSP on some pages — use `attr`/`select` instead
|
|
252
274
|
- `eval` with `input.value = ...` or `.click()` bypasses SPA framework state — use `type`/`click` instead
|
|
253
275
|
- `screenshot` captures visible viewport only — scroll for full-page capture
|
|
254
276
|
- Without `-i`, `click` always hits the FIRST match — use `count` to check first
|
|
@@ -62,6 +62,7 @@ CONTENT_SCRIPT_OPS = frozenset({
|
|
|
62
62
|
"text", "html", "attr", "select", "count", "snapshot",
|
|
63
63
|
"is-visible", "get-value",
|
|
64
64
|
"scroll", "select-option", "drag", "wait",
|
|
65
|
+
"assert-url", "assert-field-value", "set-field", "submit-and-assert",
|
|
65
66
|
})
|
|
66
67
|
|
|
67
68
|
|
|
@@ -175,6 +176,29 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
175
176
|
prog="bctl",
|
|
176
177
|
description="Control your browser from the command line",
|
|
177
178
|
)
|
|
179
|
+
parser.add_argument(
|
|
180
|
+
"--session",
|
|
181
|
+
default=None,
|
|
182
|
+
help="Execution session id (default: default)",
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument(
|
|
185
|
+
"--tab",
|
|
186
|
+
type=int,
|
|
187
|
+
default=None,
|
|
188
|
+
help="Target tab id for command execution",
|
|
189
|
+
)
|
|
190
|
+
parser.add_argument(
|
|
191
|
+
"--window",
|
|
192
|
+
type=int,
|
|
193
|
+
default=None,
|
|
194
|
+
help="Target window id (uses active tab in that window)",
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
"--world",
|
|
198
|
+
choices=["ISOLATED", "MAIN"],
|
|
199
|
+
default=None,
|
|
200
|
+
help="Script execution world override for DOM operations",
|
|
201
|
+
)
|
|
178
202
|
sub = parser.add_subparsers(dest="command", help="Available commands")
|
|
179
203
|
|
|
180
204
|
# -- Navigation --
|
|
@@ -261,6 +285,28 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
261
285
|
p.add_argument("selector", help="CSS selector or element ref")
|
|
262
286
|
p.add_argument("-i", "--index", type=int, default=None, help="Nth matching element (0-based)")
|
|
263
287
|
|
|
288
|
+
p = sub.add_parser("assert-url", help="Assert current URL matches expectation")
|
|
289
|
+
p.add_argument("expected", help="Expected URL value/pattern")
|
|
290
|
+
p.add_argument(
|
|
291
|
+
"--mode",
|
|
292
|
+
choices=["equals", "includes", "regex"],
|
|
293
|
+
default="includes",
|
|
294
|
+
help="Assertion mode (default: includes)",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
p = sub.add_parser("assert-field-value", help="Assert form field value/text")
|
|
298
|
+
p.add_argument("selector", help="CSS selector or element ref")
|
|
299
|
+
p.add_argument("expected", help="Expected field value/text")
|
|
300
|
+
p.add_argument(
|
|
301
|
+
"--mode",
|
|
302
|
+
choices=["equals", "includes", "regex"],
|
|
303
|
+
default="equals",
|
|
304
|
+
help="Assertion mode (default: equals)",
|
|
305
|
+
)
|
|
306
|
+
p.add_argument("--by-text", action="store_true", help="Assert selected option text instead of value")
|
|
307
|
+
p.add_argument("-i", "--index", type=int, default=None, help="Nth matching element (0-based)")
|
|
308
|
+
p.add_argument("-t", "--text", default=None, help="Filter by visible text content")
|
|
309
|
+
|
|
264
310
|
# -- JavaScript --
|
|
265
311
|
p = sub.add_parser("eval", help="Execute JavaScript in page context")
|
|
266
312
|
p.add_argument("code", help="JavaScript code to execute")
|
|
@@ -297,6 +343,27 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
297
343
|
p.add_argument("value", help="Option value or text to select")
|
|
298
344
|
p.add_argument("--text", action="store_true", help="Match by visible text instead of value")
|
|
299
345
|
|
|
346
|
+
p = sub.add_parser("set-field", help="Set field value in a generic way")
|
|
347
|
+
p.add_argument("selector", help="CSS selector or element ref")
|
|
348
|
+
p.add_argument("value", help="Field value to set")
|
|
349
|
+
p.add_argument("--no-clear", action="store_true", help="Do not clear before setting value")
|
|
350
|
+
p.add_argument("-i", "--index", type=int, default=None, help="Nth matching element (0-based)")
|
|
351
|
+
p.add_argument("-t", "--text", default=None, help="Filter by visible text content")
|
|
352
|
+
|
|
353
|
+
p = sub.add_parser("submit-and-assert", help="Submit and assert URL/selector outcome")
|
|
354
|
+
p.add_argument("selector", nargs="?", default=None, help="Submit target selector (form/button)")
|
|
355
|
+
p.add_argument("--assert-selector", default=None, help="Selector that should appear after submit")
|
|
356
|
+
p.add_argument("--assert-url", default=None, help="URL value/pattern expected after submit")
|
|
357
|
+
p.add_argument(
|
|
358
|
+
"--mode",
|
|
359
|
+
choices=["equals", "includes", "regex"],
|
|
360
|
+
default="includes",
|
|
361
|
+
help="URL assertion mode (default: includes)",
|
|
362
|
+
)
|
|
363
|
+
p.add_argument("--timeout", type=float, default=5, help="Max wait seconds for assertion")
|
|
364
|
+
p.add_argument("-i", "--index", type=int, default=None, help="Nth matching element (0-based)")
|
|
365
|
+
p.add_argument("-t", "--text", default=None, help="Filter by visible text content")
|
|
366
|
+
|
|
300
367
|
# -- File upload --
|
|
301
368
|
p = sub.add_parser("upload", help="Upload file(s) to file input")
|
|
302
369
|
p.add_argument("selector", help="CSS selector for file input")
|
|
@@ -322,8 +389,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
322
389
|
# -- Server management --
|
|
323
390
|
sub.add_parser("serve", help="Start bridge server (foreground)")
|
|
324
391
|
sub.add_parser("ping", help="Check server and extension status")
|
|
392
|
+
sub.add_parser("capabilities", help="Show extension-supported actions")
|
|
393
|
+
p = sub.add_parser("ensure-ready", help="Ensure server + extension are ready (auto-launch Chrome)")
|
|
394
|
+
p.add_argument("--timeout", type=float, default=20, help="Max seconds to wait for extension connection")
|
|
395
|
+
p.add_argument("--no-launch", action="store_true", help="Do not auto-launch Chrome")
|
|
325
396
|
sub.add_parser("stop", help="Stop bridge server")
|
|
326
397
|
|
|
398
|
+
p = sub.add_parser("self-test", help="Run generic skill smoke tests")
|
|
399
|
+
p.add_argument("--timeout", type=float, default=20, help="ensure-ready timeout in seconds")
|
|
400
|
+
|
|
327
401
|
# -- Batch / Pipe --
|
|
328
402
|
p = sub.add_parser("pipe", help="Read commands from stdin (one per line, JSONL output)")
|
|
329
403
|
p.add_argument("--continue-on-error", action="store_true", help="Don't stop on first error")
|
|
@@ -422,6 +496,101 @@ def handle_serve(args):
|
|
|
422
496
|
os.execvp(sys.executable, [sys.executable, "-m", "browser_ctl.server", "--port", str(DEFAULT_PORT)])
|
|
423
497
|
|
|
424
498
|
|
|
499
|
+
def handle_ensure_ready(args):
|
|
500
|
+
"""Ensure bridge server and extension are connected."""
|
|
501
|
+
result = _client().ensure_ready(timeout=args.timeout, launch_browser=not args.no_launch)
|
|
502
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
503
|
+
if not result.get("success"):
|
|
504
|
+
sys.exit(1)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def handle_capabilities(_args):
|
|
508
|
+
"""Query extension capabilities."""
|
|
509
|
+
_client().ensure_server_optimistic()
|
|
510
|
+
result = _client().send_raw("capabilities", {})
|
|
511
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
512
|
+
if not result.get("success"):
|
|
513
|
+
sys.exit(1)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def handle_self_test(args):
|
|
517
|
+
"""Run generic, site-agnostic smoke tests."""
|
|
518
|
+
def fail(msg: str):
|
|
519
|
+
print(json.dumps({"success": False, "error": msg}, ensure_ascii=False))
|
|
520
|
+
sys.exit(1)
|
|
521
|
+
|
|
522
|
+
ready = _client().ensure_ready(timeout=args.timeout, launch_browser=True)
|
|
523
|
+
if not ready.get("success"):
|
|
524
|
+
print(json.dumps(ready, ensure_ascii=False))
|
|
525
|
+
sys.exit(1)
|
|
526
|
+
|
|
527
|
+
caps = _client().send_raw("capabilities", {})
|
|
528
|
+
if not caps.get("success"):
|
|
529
|
+
err = str(caps.get("error", ""))
|
|
530
|
+
if "Unknown action: capabilities" in err:
|
|
531
|
+
fail("Connected extension is outdated. Please run `bctl setup` and reload extension from ~/.browser-ctl/extension.")
|
|
532
|
+
fail(f"capabilities check failed: {err}")
|
|
533
|
+
|
|
534
|
+
actions = set(caps.get("data", {}).get("actions", []))
|
|
535
|
+
required = {"assert-url", "assert-field-value", "set-field", "submit-and-assert"}
|
|
536
|
+
missing = sorted(required - actions)
|
|
537
|
+
if missing:
|
|
538
|
+
fail(f"Extension missing required actions: {missing}. Reload extension from ~/.browser-ctl/extension.")
|
|
539
|
+
|
|
540
|
+
html = (
|
|
541
|
+
"<html><body>"
|
|
542
|
+
"<h1 id='title'>Skill Smoke</h1>"
|
|
543
|
+
"<input id='name' />"
|
|
544
|
+
"<button id='save' onclick=\"document.getElementById('out').textContent=document.getElementById('name').value\">Save</button>"
|
|
545
|
+
"<div id='out'></div>"
|
|
546
|
+
"<form id='f' onsubmit=\"event.preventDefault(); location.hash='submitted';\">"
|
|
547
|
+
"<input id='email' />"
|
|
548
|
+
"<button id='submit' type='submit'>Submit</button>"
|
|
549
|
+
"</form>"
|
|
550
|
+
"</body></html>"
|
|
551
|
+
)
|
|
552
|
+
import urllib.parse
|
|
553
|
+
url = "https://example.com"
|
|
554
|
+
status_result = _client().send_raw("status", {})
|
|
555
|
+
if not status_result.get("success"):
|
|
556
|
+
fail(f"self-test failed at status: {status_result.get('error')}")
|
|
557
|
+
tab_id = status_result.get("data", {}).get("id")
|
|
558
|
+
if tab_id is None:
|
|
559
|
+
fail("self-test failed: status returned no tab id")
|
|
560
|
+
|
|
561
|
+
steps = [
|
|
562
|
+
("navigate", {"url": url}),
|
|
563
|
+
("assert-url", {"expected": "example.com", "mode": "includes"}),
|
|
564
|
+
("eval", {"code": f"document.body.innerHTML = {json.dumps(html)}; 'ok'"}),
|
|
565
|
+
("snapshot", {"interactive": True}),
|
|
566
|
+
("set-field", {"selector": "#name", "value": "Alice", "clear": True}),
|
|
567
|
+
("assert-field-value", {"selector": "#name", "expected": "Alice", "mode": "equals"}),
|
|
568
|
+
("click", {"selector": "#save"}),
|
|
569
|
+
("text", {"selector": "#out"}),
|
|
570
|
+
("submit-and-assert", {"selector": "#submit", "assertUrl": "#submitted", "mode": "includes", "timeout": 5}),
|
|
571
|
+
("assert-url", {"expected": "#submitted", "mode": "includes"}),
|
|
572
|
+
]
|
|
573
|
+
|
|
574
|
+
for action, params in steps:
|
|
575
|
+
p = dict(params)
|
|
576
|
+
p["tabId"] = tab_id
|
|
577
|
+
result = _client().send_raw(action, p)
|
|
578
|
+
if not result.get("success"):
|
|
579
|
+
fail(f"self-test failed at {action}: {result.get('error')}")
|
|
580
|
+
if action == "text":
|
|
581
|
+
text = str(result.get("data", {}).get("text", "")).strip()
|
|
582
|
+
if text != "Alice":
|
|
583
|
+
fail(f"self-test failed: expected #out text 'Alice', got '{text}'")
|
|
584
|
+
|
|
585
|
+
print(json.dumps({
|
|
586
|
+
"success": True,
|
|
587
|
+
"data": {
|
|
588
|
+
"selfTest": "passed",
|
|
589
|
+
"checks": [s[0] for s in steps],
|
|
590
|
+
},
|
|
591
|
+
}, ensure_ascii=False))
|
|
592
|
+
|
|
593
|
+
|
|
425
594
|
def _get_extension_source_dir() -> str | None:
|
|
426
595
|
"""Locate the extension source directory.
|
|
427
596
|
|
|
@@ -593,6 +762,8 @@ _ALIASES = {
|
|
|
593
762
|
"dl": "download",
|
|
594
763
|
"sopt": "select-option",
|
|
595
764
|
"snap": "snapshot",
|
|
765
|
+
"ready": "ensure-ready",
|
|
766
|
+
"caps": "capabilities",
|
|
596
767
|
}
|
|
597
768
|
|
|
598
769
|
|
|
@@ -645,6 +816,17 @@ def args_to_action_params(cmd: str, args) -> tuple[str, dict]:
|
|
|
645
816
|
params = {"selector": args.selector, "index": args.index}
|
|
646
817
|
elif cmd == "get-value":
|
|
647
818
|
params = {"selector": args.selector, "index": args.index}
|
|
819
|
+
elif cmd == "assert-url":
|
|
820
|
+
params = {"expected": args.expected, "mode": args.mode}
|
|
821
|
+
elif cmd == "assert-field-value":
|
|
822
|
+
params = {
|
|
823
|
+
"selector": args.selector,
|
|
824
|
+
"expected": args.expected,
|
|
825
|
+
"mode": args.mode,
|
|
826
|
+
"byText": args.by_text,
|
|
827
|
+
"index": args.index,
|
|
828
|
+
"text": args.text,
|
|
829
|
+
}
|
|
648
830
|
elif cmd == "eval":
|
|
649
831
|
params = {"code": args.code}
|
|
650
832
|
elif cmd == "tab":
|
|
@@ -657,6 +839,24 @@ def args_to_action_params(cmd: str, args) -> tuple[str, dict]:
|
|
|
657
839
|
params = {"target": args.target, "amount": args.amount}
|
|
658
840
|
elif cmd == "select-option":
|
|
659
841
|
params = {"selector": args.selector, "value": args.value, "byText": args.text}
|
|
842
|
+
elif cmd == "set-field":
|
|
843
|
+
params = {
|
|
844
|
+
"selector": args.selector,
|
|
845
|
+
"value": args.value,
|
|
846
|
+
"clear": not args.no_clear,
|
|
847
|
+
"index": args.index,
|
|
848
|
+
"text": args.text,
|
|
849
|
+
}
|
|
850
|
+
elif cmd == "submit-and-assert":
|
|
851
|
+
params = {
|
|
852
|
+
"selector": args.selector,
|
|
853
|
+
"assertSelector": args.assert_selector,
|
|
854
|
+
"assertUrl": args.assert_url,
|
|
855
|
+
"mode": args.mode,
|
|
856
|
+
"timeout": args.timeout,
|
|
857
|
+
"index": args.index,
|
|
858
|
+
"text": args.text,
|
|
859
|
+
}
|
|
660
860
|
elif cmd == "upload":
|
|
661
861
|
files = [os.path.abspath(f) for f in args.files]
|
|
662
862
|
params = {"selector": args.selector, "files": files}
|
|
@@ -670,6 +870,16 @@ def args_to_action_params(cmd: str, args) -> tuple[str, dict]:
|
|
|
670
870
|
params = {"seconds": seconds}
|
|
671
871
|
except ValueError:
|
|
672
872
|
params = {"selector": args.target, "timeout": args.timeout}
|
|
873
|
+
|
|
874
|
+
# Optional execution context (applies to all server-side actions)
|
|
875
|
+
if getattr(args, "session", None):
|
|
876
|
+
params["sessionId"] = args.session
|
|
877
|
+
if getattr(args, "tab", None) is not None:
|
|
878
|
+
params["tabId"] = args.tab
|
|
879
|
+
if getattr(args, "window", None) is not None:
|
|
880
|
+
params["windowId"] = args.window
|
|
881
|
+
if getattr(args, "world", None):
|
|
882
|
+
params["world"] = args.world
|
|
673
883
|
return cmd, params
|
|
674
884
|
|
|
675
885
|
|
|
@@ -713,6 +923,15 @@ def main():
|
|
|
713
923
|
if cmd == "serve":
|
|
714
924
|
handle_serve(args)
|
|
715
925
|
return
|
|
926
|
+
if cmd == "capabilities":
|
|
927
|
+
handle_capabilities(args)
|
|
928
|
+
return
|
|
929
|
+
if cmd == "ensure-ready":
|
|
930
|
+
handle_ensure_ready(args)
|
|
931
|
+
return
|
|
932
|
+
if cmd == "self-test":
|
|
933
|
+
handle_self_test(args)
|
|
934
|
+
return
|
|
716
935
|
if cmd == "stop":
|
|
717
936
|
_client().stop_server()
|
|
718
937
|
return
|
|
@@ -9,8 +9,12 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
12
14
|
import socket
|
|
15
|
+
import subprocess
|
|
13
16
|
import sys
|
|
17
|
+
import time
|
|
14
18
|
|
|
15
19
|
DEFAULT_PORT = 19876
|
|
16
20
|
_HOST = "127.0.0.1"
|
|
@@ -192,3 +196,119 @@ def ensure_server_optimistic() -> None:
|
|
|
192
196
|
result = send_raw("ping", {})
|
|
193
197
|
if not result.get("success") and "Cannot connect" in result.get("error", ""):
|
|
194
198
|
start_server()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _launch_chrome() -> tuple[bool, str]:
|
|
202
|
+
"""Try to launch Chrome/Chromium and return (started, method)."""
|
|
203
|
+
system = platform.system()
|
|
204
|
+
|
|
205
|
+
if system == "Darwin":
|
|
206
|
+
try:
|
|
207
|
+
subprocess.Popen(
|
|
208
|
+
["open", "-a", "Google Chrome"],
|
|
209
|
+
stdout=subprocess.DEVNULL,
|
|
210
|
+
stderr=subprocess.DEVNULL,
|
|
211
|
+
)
|
|
212
|
+
return True, "open -a Google Chrome"
|
|
213
|
+
except Exception:
|
|
214
|
+
return False, "open -a Google Chrome"
|
|
215
|
+
|
|
216
|
+
if system == "Windows":
|
|
217
|
+
candidates = [
|
|
218
|
+
"chrome",
|
|
219
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
220
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
221
|
+
]
|
|
222
|
+
for cmd in candidates:
|
|
223
|
+
try:
|
|
224
|
+
subprocess.Popen(
|
|
225
|
+
[cmd],
|
|
226
|
+
stdout=subprocess.DEVNULL,
|
|
227
|
+
stderr=subprocess.DEVNULL,
|
|
228
|
+
creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
|
229
|
+
)
|
|
230
|
+
return True, cmd
|
|
231
|
+
except Exception:
|
|
232
|
+
continue
|
|
233
|
+
return False, "chrome"
|
|
234
|
+
|
|
235
|
+
# Linux / other unix-like: try common Chrome/Chromium binaries
|
|
236
|
+
for cmd in ("google-chrome", "chromium", "chromium-browser"):
|
|
237
|
+
if shutil.which(cmd) is None:
|
|
238
|
+
continue
|
|
239
|
+
try:
|
|
240
|
+
subprocess.Popen(
|
|
241
|
+
[cmd],
|
|
242
|
+
stdout=subprocess.DEVNULL,
|
|
243
|
+
stderr=subprocess.DEVNULL,
|
|
244
|
+
)
|
|
245
|
+
return True, cmd
|
|
246
|
+
except Exception:
|
|
247
|
+
continue
|
|
248
|
+
return False, "google-chrome/chromium"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def ensure_ready(timeout: float = 20.0, launch_browser: bool = True) -> dict:
|
|
252
|
+
"""Ensure bridge server + extension are ready.
|
|
253
|
+
|
|
254
|
+
Flow:
|
|
255
|
+
1) Start server if needed
|
|
256
|
+
2) Check ping
|
|
257
|
+
3) If extension disconnected and launch enabled, attempt to launch Chrome
|
|
258
|
+
4) Poll until timeout for extension connection
|
|
259
|
+
"""
|
|
260
|
+
ensure_server_optimistic()
|
|
261
|
+
result = send_raw("ping", {})
|
|
262
|
+
if result.get("success") and result.get("data", {}).get("extension"):
|
|
263
|
+
return {
|
|
264
|
+
"success": True,
|
|
265
|
+
"data": {
|
|
266
|
+
"server": True,
|
|
267
|
+
"extension": True,
|
|
268
|
+
"launchedBrowser": False,
|
|
269
|
+
"waitedSeconds": 0.0,
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if not launch_browser:
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
started, method = _launch_chrome()
|
|
277
|
+
if not started:
|
|
278
|
+
return {
|
|
279
|
+
"success": False,
|
|
280
|
+
"error": (
|
|
281
|
+
"Chrome extension not connected and failed to launch browser. "
|
|
282
|
+
f"Tried: {method}. Run `bctl setup` and ensure extension is loaded."
|
|
283
|
+
),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
deadline = time.time() + max(timeout, 1.0)
|
|
287
|
+
last = result
|
|
288
|
+
while time.time() < deadline:
|
|
289
|
+
time.sleep(0.5)
|
|
290
|
+
last = send_raw("ping", {})
|
|
291
|
+
if last.get("success") and last.get("data", {}).get("extension"):
|
|
292
|
+
return {
|
|
293
|
+
"success": True,
|
|
294
|
+
"data": {
|
|
295
|
+
"server": True,
|
|
296
|
+
"extension": True,
|
|
297
|
+
"launchedBrowser": True,
|
|
298
|
+
"launchMethod": method,
|
|
299
|
+
"waitedSeconds": round(max(0.0, timeout - max(0.0, deadline - time.time())), 1),
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
"success": False,
|
|
305
|
+
"error": (
|
|
306
|
+
"Chrome launched but extension is still not connected. "
|
|
307
|
+
"Open chrome://extensions and ensure Browser-Ctl extension is loaded."
|
|
308
|
+
),
|
|
309
|
+
"data": {
|
|
310
|
+
"launchedBrowser": True,
|
|
311
|
+
"launchMethod": method,
|
|
312
|
+
"lastPing": last,
|
|
313
|
+
},
|
|
314
|
+
}
|