browser-ctl 0.2.12__tar.gz → 0.2.13__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.12 → browser_ctl-0.2.13}/PKG-INFO +1 -1
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/SKILL.md +18 -1
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/actions.js +81 -13
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/click.js +51 -3
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/content-script.js +31 -11
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/manifest.json +1 -1
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/PKG-INFO +1 -1
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/pyproject.toml +1 -1
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/LICENSE +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/README.md +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/__init__.py +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/__main__.py +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/cli.py +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/client.py +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/background.js +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/icon-128.png +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/icon-16.png +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/icon-32.png +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/extension/icon-48.png +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl/server.py +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/SOURCES.txt +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/dependency_links.txt +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/entry_points.txt +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/requires.txt +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/browser_ctl.egg-info/top_level.txt +0 -0
- {browser_ctl-0.2.12 → browser_ctl-0.2.13}/setup.cfg +0 -0
|
@@ -59,9 +59,10 @@ bctl click <sel> [-i N] [-t text] Click element; -t filters by visible text
|
|
|
59
59
|
bctl dblclick <sel> [-i N] [-t text] Double-click
|
|
60
60
|
bctl hover <sel> [-i N] [-t text] Hover (triggers mouseover)
|
|
61
61
|
bctl focus <sel> [-i N] [-t text] Focus element
|
|
62
|
-
bctl type <sel> <text> Type text (replaces existing; React
|
|
62
|
+
bctl type <sel> <text> Type text (replaces existing; React/Vue compatible)
|
|
63
63
|
bctl input-text <sel> <text> [--clear] [--delay ms] Char-by-char (rich editors)
|
|
64
64
|
bctl press <key> Press key: Enter, Escape, Tab, ArrowDown, etc.
|
|
65
|
+
(includes keyCode/which for Vue 2 & jQuery compat)
|
|
65
66
|
bctl check <sel> [-i N] [-t text] Check checkbox/radio
|
|
66
67
|
bctl uncheck <sel> [-i N] [-t text] Uncheck checkbox
|
|
67
68
|
bctl scroll <dir|sel> [n] Scroll: up/down/top/bottom/<selector> [pixels]
|
|
@@ -186,6 +187,15 @@ bctl assert-url "/releases/tag/vX.Y.Z" --mode includes
|
|
|
186
187
|
Then confirm automation side effects separately (e.g. Actions run appears/completes,
|
|
187
188
|
and PyPI shows the new version).
|
|
188
189
|
|
|
190
|
+
### Chat / AI UIs
|
|
191
|
+
For chat interfaces (ChatGPT, Gemini, internal AI tools, etc.), use `type` + `press Enter`
|
|
192
|
+
as the simplest send pattern — no need to find the send button:
|
|
193
|
+
```bash
|
|
194
|
+
bctl type "#input" "your prompt here"
|
|
195
|
+
bctl press Enter
|
|
196
|
+
```
|
|
197
|
+
Then poll for completion: `bctl wait 5 && bctl count ".response"` in a loop.
|
|
198
|
+
|
|
189
199
|
### Waiting Strategy
|
|
190
200
|
- After navigation: `bctl wait 2-3` or `bctl wait "<selector>" 10`
|
|
191
201
|
- After hover for overlay: `bctl wait 1`
|
|
@@ -289,6 +299,13 @@ Use this sequence on any site:
|
|
|
289
299
|
- `screenshot` captures visible viewport only — scroll for full-page capture
|
|
290
300
|
- Without `-i`, `click` always hits the FIRST match — use `count` to check first
|
|
291
301
|
|
|
302
|
+
## Resilience Notes
|
|
303
|
+
|
|
304
|
+
- **`click` auto force-clicks** when the element is visible and enabled but obscured by
|
|
305
|
+
an overlay or fails hit-test. No need for `eval` workarounds on tricky UIs.
|
|
306
|
+
- **`press` includes `keyCode`/`which`** for all common keys, so `@keydown.enter` handlers
|
|
307
|
+
in Vue 2, jQuery `.on("keydown")`, and legacy `e.keyCode` checks all work correctly.
|
|
308
|
+
|
|
292
309
|
## Error Handling
|
|
293
310
|
|
|
294
311
|
- `bctl ping` shows `"extension": false` → user must check Chrome and the extension
|
|
@@ -11,6 +11,22 @@ import { contentScriptHandler } from "./content-script.js";
|
|
|
11
11
|
// Shared helpers
|
|
12
12
|
// =========================================================================
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Get the ID of the last-focused normal Chrome window.
|
|
16
|
+
* Returns null when Chrome has no normal windows (e.g. background-only on macOS).
|
|
17
|
+
*/
|
|
18
|
+
async function getLastFocusedNormalWindowId() {
|
|
19
|
+
try {
|
|
20
|
+
const win = await chrome.windows.getLastFocused({ windowTypes: ["normal"] });
|
|
21
|
+
if (win && win.id !== chrome.windows.WINDOW_ID_NONE) return win.id;
|
|
22
|
+
} catch (_) {}
|
|
23
|
+
try {
|
|
24
|
+
const all = await chrome.windows.getAll({ windowTypes: ["normal"] });
|
|
25
|
+
if (all.length > 0) return all[0].id;
|
|
26
|
+
} catch (_) {}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
/** Get the currently active tab. */
|
|
15
31
|
export async function activeTab(context = {}) {
|
|
16
32
|
if (context.tabId !== undefined && context.tabId !== null) {
|
|
@@ -21,10 +37,9 @@ export async function activeTab(context = {}) {
|
|
|
21
37
|
if (context.windowId !== undefined && context.windowId !== null) {
|
|
22
38
|
query.windowId = context.windowId;
|
|
23
39
|
} else {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
query.windowId = focusedWindow.id;
|
|
40
|
+
const windowId = await getLastFocusedNormalWindowId();
|
|
41
|
+
if (windowId !== null) {
|
|
42
|
+
query.windowId = windowId;
|
|
28
43
|
} else {
|
|
29
44
|
query.currentWindow = true;
|
|
30
45
|
}
|
|
@@ -196,9 +211,26 @@ export async function doBatch(params, context = extractExecutionContext(params))
|
|
|
196
211
|
export async function doNavigate(params, context = extractExecutionContext(params)) {
|
|
197
212
|
const url = params.url;
|
|
198
213
|
if (!url) throw new Error("Missing 'url' parameter");
|
|
199
|
-
|
|
214
|
+
|
|
215
|
+
let tab;
|
|
216
|
+
try {
|
|
217
|
+
tab = await activeTab(context);
|
|
218
|
+
} catch (_) {
|
|
219
|
+
// No active tab / no window — create a new window with the URL
|
|
220
|
+
const newWindow = await chrome.windows.create({ url });
|
|
221
|
+
const tabs = await chrome.tabs.query({ active: true, windowId: newWindow.id });
|
|
222
|
+
tab = tabs[0];
|
|
223
|
+
if (tab) {
|
|
224
|
+
const loadResult = await waitForTabLoad(tab.id, 5000);
|
|
225
|
+
if (loadResult.loaded) {
|
|
226
|
+
const updated = await getTabOrThrow(tab.id);
|
|
227
|
+
return { url: updated.url, title: updated.title };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { url, title: "" };
|
|
231
|
+
}
|
|
232
|
+
|
|
200
233
|
await chrome.tabs.update(tab.id, { url });
|
|
201
|
-
// Short wait — avoid long blocking that causes SW termination
|
|
202
234
|
const loadResult = await waitForTabLoad(tab.id, 5000);
|
|
203
235
|
if (!loadResult.loaded) {
|
|
204
236
|
return { url, title: "" };
|
|
@@ -246,7 +278,11 @@ export async function doReload(params = {}, context = extractExecutionContext(pa
|
|
|
246
278
|
|
|
247
279
|
export async function doTabs() {
|
|
248
280
|
const tabs = await chrome.tabs.query({});
|
|
249
|
-
|
|
281
|
+
let focusedWindowId = null;
|
|
282
|
+
try {
|
|
283
|
+
const focusedWindow = await chrome.windows.getLastFocused();
|
|
284
|
+
focusedWindowId = focusedWindow?.id ?? null;
|
|
285
|
+
} catch (_) {}
|
|
250
286
|
return {
|
|
251
287
|
tabs: tabs.map((t) => ({
|
|
252
288
|
id: t.id,
|
|
@@ -255,7 +291,7 @@ export async function doTabs() {
|
|
|
255
291
|
active: t.active,
|
|
256
292
|
windowId: t.windowId,
|
|
257
293
|
})),
|
|
258
|
-
focusedWindowId
|
|
294
|
+
focusedWindowId,
|
|
259
295
|
};
|
|
260
296
|
}
|
|
261
297
|
|
|
@@ -275,11 +311,38 @@ export async function doSwitchTab(params) {
|
|
|
275
311
|
}
|
|
276
312
|
|
|
277
313
|
export async function doNewTab(params) {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
314
|
+
const url = params.url || "about:blank";
|
|
315
|
+
const createOpts = { url };
|
|
316
|
+
if (params.windowId) {
|
|
317
|
+
createOpts.windowId = params.windowId;
|
|
318
|
+
} else {
|
|
319
|
+
const windowId = await getLastFocusedNormalWindowId();
|
|
320
|
+
if (windowId !== null) {
|
|
321
|
+
createOpts.windowId = windowId;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let tab;
|
|
326
|
+
try {
|
|
327
|
+
tab = await chrome.tabs.create(createOpts);
|
|
328
|
+
} catch (_) {
|
|
329
|
+
// No window available — create a new window with the tab
|
|
330
|
+
const newWindow = await chrome.windows.create({ url });
|
|
331
|
+
const tabs = await chrome.tabs.query({ active: true, windowId: newWindow.id });
|
|
332
|
+
tab = tabs[0];
|
|
333
|
+
if (!tab) throw new Error("Failed to create new window");
|
|
334
|
+
if (params.url) {
|
|
335
|
+
const loadResult = await waitForTabLoad(tab.id, 5000);
|
|
336
|
+
if (!loadResult.loaded) {
|
|
337
|
+
return { id: tab.id, url: params.url, title: tab.title || "" };
|
|
338
|
+
}
|
|
339
|
+
const updated = await getTabOrThrow(tab.id);
|
|
340
|
+
return { id: updated.id, url: updated.url, title: updated.title };
|
|
341
|
+
}
|
|
342
|
+
return { id: tab.id, url: tab.url, title: tab.title || "" };
|
|
343
|
+
}
|
|
344
|
+
|
|
281
345
|
if (params.url) {
|
|
282
|
-
// Short wait only — avoid long blocking that causes SW termination
|
|
283
346
|
const loadResult = await waitForTabLoad(tab.id, 5000);
|
|
284
347
|
if (!loadResult.loaded) {
|
|
285
348
|
return { id: tab.id, url: params.url, title: tab.title || "" };
|
|
@@ -300,7 +363,12 @@ export async function doCloseTab(params, context = extractExecutionContext(param
|
|
|
300
363
|
// =========================================================================
|
|
301
364
|
|
|
302
365
|
export async function doStatus(params = {}, context = extractExecutionContext(params)) {
|
|
303
|
-
|
|
366
|
+
let tab;
|
|
367
|
+
try {
|
|
368
|
+
tab = await activeTab(context);
|
|
369
|
+
} catch (_) {
|
|
370
|
+
return { url: null, title: null, id: null, noWindow: true };
|
|
371
|
+
}
|
|
304
372
|
return { url: tab.url, title: tab.title, id: tab.id };
|
|
305
373
|
}
|
|
306
374
|
|
|
@@ -26,8 +26,12 @@ async function doClick(params, context = {}) {
|
|
|
26
26
|
|
|
27
27
|
const result = results[0]?.result;
|
|
28
28
|
if (!result || !result.success) {
|
|
29
|
-
// If actionability failed
|
|
30
|
-
|
|
29
|
+
// If actionability failed due to hit-test (obscured / no element at coords),
|
|
30
|
+
// try CDP fallback for fully trusted events
|
|
31
|
+
const isHitTestFailure = result?.error?.includes("pointer events") ||
|
|
32
|
+
result?.error?.includes("obscured") ||
|
|
33
|
+
result?.error?.includes("no element at coordinates");
|
|
34
|
+
if (isHitTestFailure && result?.cx !== undefined && result?.cy !== undefined) {
|
|
31
35
|
try {
|
|
32
36
|
return await clickViaCDP(tab.id, result.cx, result.cy, params);
|
|
33
37
|
} catch (_) {
|
|
@@ -365,7 +369,51 @@ async function clickHandler(selector, index, textFilter) {
|
|
|
365
369
|
return { success: true, total, capturedUrl };
|
|
366
370
|
}
|
|
367
371
|
|
|
368
|
-
// All retries exhausted
|
|
372
|
+
// All retries exhausted — if failure is purely hit-test related,
|
|
373
|
+
// force-click anyway (the element is visible, stable, enabled, just obscured)
|
|
374
|
+
const isHitTestOnly = lastReason.includes("pointer events") ||
|
|
375
|
+
lastReason.includes("obscured") ||
|
|
376
|
+
lastReason.includes("no element at coordinates");
|
|
377
|
+
|
|
378
|
+
if (isHitTestOnly) {
|
|
379
|
+
const origOpen = window.open;
|
|
380
|
+
let capturedUrl = null;
|
|
381
|
+
window.open = function (url) {
|
|
382
|
+
if (url && typeof url === "string" && url.startsWith("http")) {
|
|
383
|
+
capturedUrl = url;
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const rect = el.getBoundingClientRect();
|
|
390
|
+
const cx = rect.left + rect.width / 2;
|
|
391
|
+
const cy = rect.top + rect.height / 2;
|
|
392
|
+
const mOpts = {
|
|
393
|
+
bubbles: true,
|
|
394
|
+
cancelable: true,
|
|
395
|
+
clientX: cx,
|
|
396
|
+
clientY: cy,
|
|
397
|
+
button: 0,
|
|
398
|
+
view: window,
|
|
399
|
+
};
|
|
400
|
+
el.dispatchEvent(
|
|
401
|
+
new PointerEvent("pointerdown", { ...mOpts, pointerId: 1 })
|
|
402
|
+
);
|
|
403
|
+
el.dispatchEvent(new MouseEvent("mousedown", mOpts));
|
|
404
|
+
el.dispatchEvent(
|
|
405
|
+
new PointerEvent("pointerup", { ...mOpts, pointerId: 1 })
|
|
406
|
+
);
|
|
407
|
+
el.dispatchEvent(new MouseEvent("mouseup", mOpts));
|
|
408
|
+
el.click();
|
|
409
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
410
|
+
} finally {
|
|
411
|
+
window.open = origOpen;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return { success: true, total, capturedUrl, forced: true };
|
|
415
|
+
}
|
|
416
|
+
|
|
369
417
|
return {
|
|
370
418
|
success: false,
|
|
371
419
|
error: `click: actionability check failed for "${selector}": ${lastReason}`,
|
|
@@ -408,7 +408,16 @@ async function contentScriptHandler(commands) {
|
|
|
408
408
|
while (target.shadowRoot && target.shadowRoot.activeElement) {
|
|
409
409
|
target = target.shadowRoot.activeElement;
|
|
410
410
|
}
|
|
411
|
-
|
|
411
|
+
// Map key names to legacy keyCode values for framework compatibility
|
|
412
|
+
// (Vue 2 @keydown.enter checks e.keyCode === 13, not e.key)
|
|
413
|
+
const KEY_CODES = {
|
|
414
|
+
Enter: 13, Tab: 9, Escape: 27, Backspace: 8, Delete: 46,
|
|
415
|
+
ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39,
|
|
416
|
+
Home: 36, End: 35, PageUp: 33, PageDown: 34,
|
|
417
|
+
" ": 32, Space: 32,
|
|
418
|
+
};
|
|
419
|
+
const keyCode = KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
|
|
420
|
+
const opts = { key, keyCode, which: keyCode, bubbles: true, cancelable: true };
|
|
412
421
|
const cancelled = !target.dispatchEvent(
|
|
413
422
|
new KeyboardEvent("keydown", opts)
|
|
414
423
|
);
|
|
@@ -1139,11 +1148,17 @@ async function contentScriptHandler(commands) {
|
|
|
1139
1148
|
enabled: true,
|
|
1140
1149
|
});
|
|
1141
1150
|
if (!check.pass) {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1151
|
+
// If failure is only hit-test related, force-click anyway
|
|
1152
|
+
const isHitTestOnly = check.reason.includes("pointer events") ||
|
|
1153
|
+
check.reason.includes("obscured") ||
|
|
1154
|
+
check.reason.includes("no element at coordinates");
|
|
1155
|
+
if (!isHitTestOnly) {
|
|
1156
|
+
results.push({
|
|
1157
|
+
success: false,
|
|
1158
|
+
error: `${op}: ${check.reason} for "${params.selector}"`,
|
|
1159
|
+
});
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1147
1162
|
}
|
|
1148
1163
|
const data = executeOp(op, params);
|
|
1149
1164
|
results.push({ success: true, data });
|
|
@@ -1183,11 +1198,16 @@ async function contentScriptHandler(commands) {
|
|
|
1183
1198
|
receivesEvents: true,
|
|
1184
1199
|
});
|
|
1185
1200
|
if (!check.pass) {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1201
|
+
const isHitTestOnly = check.reason.includes("pointer events") ||
|
|
1202
|
+
check.reason.includes("obscured") ||
|
|
1203
|
+
check.reason.includes("no element at coordinates");
|
|
1204
|
+
if (!isHitTestOnly) {
|
|
1205
|
+
results.push({
|
|
1206
|
+
success: false,
|
|
1207
|
+
error: `${op}: ${check.reason} for "${params.selector || params.source}"`,
|
|
1208
|
+
});
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1191
1211
|
}
|
|
1192
1212
|
const data = executeOp(op, params);
|
|
1193
1213
|
results.push({ success: true, data });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Browser-Ctl",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.13",
|
|
5
5
|
"description": "Developer tool for CLI-driven browser automation. Control Chrome via command-line — navigate, click, type, query DOM, capture screenshots, and download files, all through a local WebSocket bridge.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"tabs",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|