Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1417c3c70 | ||
|
|
7cb69bd444 | ||
|
|
fb27835ebc | ||
|
|
2859da7b7c |
@@ -205,6 +205,47 @@ chrome-use --launch --profile auto open https://x.com/home
|
||||
|
||||
In CI environments, standalone mode is used automatically.
|
||||
|
||||
## Automated testing (`chrome-use test`)
|
||||
|
||||
Turn the repetitive "open it, click around, check it's right" work into a
|
||||
**re-runnable suite** — unit tests for the frontend. Write cases in YAML; steps
|
||||
reuse chrome-use's own commands and assertions compile to a single check:
|
||||
|
||||
```yaml
|
||||
# smoke.yaml
|
||||
suite: chatgpt smoke
|
||||
setup:
|
||||
- account: chatgpt/huayue # inject a cookie-use login (optional)
|
||||
cases:
|
||||
- name: home loads logged in
|
||||
steps:
|
||||
- open: https://chatgpt.com/
|
||||
- wait: { load: networkidle }
|
||||
assert:
|
||||
- url: { contains: chatgpt.com }
|
||||
- visible: "#prompt-textarea"
|
||||
```
|
||||
|
||||
```bash
|
||||
chrome-use test smoke.yaml # launches an isolated browser, runs cases
|
||||
chrome-use test smoke.yaml --session default # …or against your connected Chrome
|
||||
```
|
||||
|
||||
```
|
||||
suite: chatgpt smoke (session cu-test)
|
||||
✓ home loads logged in 1.2s
|
||||
✗ composer takes text 0.8s
|
||||
assert text "#prompt-textarea" contains "hi" → got ""
|
||||
↳ cu-test-artifacts/composer-takes-text.png
|
||||
2 cases · 1 passed · 1 failed
|
||||
```
|
||||
|
||||
Exit code is non-zero if any case fails (drop it into CI), and failed cases save
|
||||
a screenshot. Assertions: `url` · `visible` · `hidden` · `text` · `count` ·
|
||||
`eval`. Steps: `open` · `click` · `fill` · `type` · `press` · `wait` · `scroll`
|
||||
· `eval`. Full guide: `chrome-use skills get test`. Found a regression? Add a
|
||||
case — the suite gets more valuable the more you use it.
|
||||
|
||||
## Anti-detection
|
||||
|
||||
<img src="assets/shield.png" alt="stealth shield" width="320" align="right" />
|
||||
|
||||
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrome-use"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -136,6 +136,24 @@ fn active_page_index_after_removal(
|
||||
active_page_index
|
||||
}
|
||||
|
||||
/// Resolve the session's active page index: prefer the pinned `active_target_id`
|
||||
/// (stable across tab reorder / passive discovery / removal), falling back to the
|
||||
/// raw `active_page_index` only when nothing is pinned or the pin is gone. Keeping
|
||||
/// commands anchored to the pinned target is what stops `eval`/`get url`/`snapshot`
|
||||
/// from drifting onto a foreign tab between commands (issue #14).
|
||||
fn resolve_active_index(
|
||||
pages: &[PageInfo],
|
||||
active_target_id: Option<&str>,
|
||||
active_page_index: usize,
|
||||
) -> usize {
|
||||
if let Some(tid) = active_target_id {
|
||||
if let Some(i) = pages.iter().position(|p| p.target_id == tid) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
active_page_index
|
||||
}
|
||||
|
||||
/// Converts common error messages into AI-friendly, actionable descriptions.
|
||||
pub fn to_ai_friendly_error(error: &str) -> String {
|
||||
let lower = error.to_lowercase();
|
||||
@@ -762,12 +780,11 @@ impl BrowserManager {
|
||||
/// falling back to `active_page_index` when nothing is pinned or the pin is
|
||||
/// gone. This is what keeps commands on the tab the agent actually opened.
|
||||
fn resolved_active_index(&self) -> usize {
|
||||
if let Some(tid) = &self.active_target_id {
|
||||
if let Some(i) = self.pages.iter().position(|p| &p.target_id == tid) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
self.active_page_index
|
||||
resolve_active_index(
|
||||
&self.pages,
|
||||
self.active_target_id.as_deref(),
|
||||
self.active_page_index,
|
||||
)
|
||||
}
|
||||
|
||||
/// Pin the current active page by target_id so later commands stick to it.
|
||||
@@ -854,10 +871,20 @@ impl BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit `open`/navigate IS the "explicit open" the pin invariant is
|
||||
// built around (see `active_target_id`). On the relay path `open` reuses an
|
||||
// existing tab via this method rather than `add_page`, so without pinning
|
||||
// here `active_target_id` stayed `None` and the session rode the fragile
|
||||
// `active_page_index` — a later passive tab close/reorder then drifted
|
||||
// `eval`/`get url`/`snapshot` onto a foreign tab between commands (issue
|
||||
// #14). Sync the index to the resolved active page, then pin it by stable
|
||||
// target_id so subsequent commands stick to the tab we just navigated.
|
||||
self.active_page_index = self.resolved_active_index();
|
||||
if let Some(page) = self.pages.get_mut(self.active_page_index) {
|
||||
page.url = page_url.clone();
|
||||
page.title = title.clone();
|
||||
}
|
||||
self.pin_active_target();
|
||||
|
||||
let mut out = json!({ "url": page_url, "title": title });
|
||||
if let Some(w) = nav_warning {
|
||||
@@ -1124,6 +1151,9 @@ impl BrowserManager {
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = 0;
|
||||
// Pin this freshly-created tab (matches `add_page`) so it's a stable
|
||||
// anchor from the first command, not a bare index (issue #14).
|
||||
self.pin_active_target();
|
||||
self.enable_domains(&attach_result.session_id).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -2175,6 +2205,55 @@ mod tests {
|
||||
assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
|
||||
}
|
||||
|
||||
fn page(target_id: &str) -> PageInfo {
|
||||
PageInfo {
|
||||
tab_id: 1,
|
||||
label: None,
|
||||
target_id: target_id.to_string(),
|
||||
session_id: format!("session-{target_id}"),
|
||||
url: String::new(),
|
||||
title: String::new(),
|
||||
target_type: "page".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- issue #14: a pinned target must keep commands on the right tab ---
|
||||
|
||||
#[test]
|
||||
fn resolve_active_index_prefers_pin_over_stale_index() {
|
||||
// The tab we opened ("A") is at index 0, but `active_page_index` is stale
|
||||
// and points at a foreign tab ("B"). With the pin set, resolution sticks
|
||||
// to A — the drift that bit issue #14 (eval landing on /notifications).
|
||||
let pages = vec![page("A"), page("B")];
|
||||
assert_eq!(resolve_active_index(&pages, Some("A"), 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_active_index_unpinned_drifts_with_index() {
|
||||
// Documents the pre-fix hazard: with no pin, resolution blindly trusts
|
||||
// `active_page_index`, so a clamp/reorder from passive tab discovery lands
|
||||
// commands on a foreign tab. This is exactly what pinning on `open` avoids.
|
||||
let pages = vec![page("A"), page("B")];
|
||||
assert_eq!(resolve_active_index(&pages, None, 1), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_active_index_falls_back_when_pin_is_gone() {
|
||||
// If the pinned tab was closed (target_id no longer present), fall back to
|
||||
// the index rather than panicking or returning a bogus slot.
|
||||
let pages = vec![page("A"), page("B")];
|
||||
assert_eq!(resolve_active_index(&pages, Some("CLOSED"), 1), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_active_index_pin_survives_passive_background_tab() {
|
||||
// A foreign tab ("Z") gets appended by passive discovery after we pinned
|
||||
// "A". The append doesn't shift A's position, and the pin keeps us on A
|
||||
// regardless of what `active_page_index` happens to be.
|
||||
let pages = vec![page("A"), page("B"), page("Z")];
|
||||
assert_eq!(resolve_active_index(&pages, Some("A"), 2), 0);
|
||||
}
|
||||
|
||||
// issue #7: removing the pinned active target must re-anchor the pin to a
|
||||
// surviving page. Models `remove_page_by_target_id`'s index + re-pin steps
|
||||
// purely (BrowserManager needs a live CDP client, so the method itself can't
|
||||
|
||||
@@ -330,6 +330,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reattach_with_same_session_restores_target() {
|
||||
// Issue #17 recovery contract. A tab's chrome.debugger session is torn
|
||||
// down (cross-process nav, SW restart, …) then re-attached. The fix has
|
||||
// the extension reuse the SAME `cb-tab-<tabId>` id across that churn, so
|
||||
// after detach+reattach the relay must expose the NEW target under the
|
||||
// SAME session — which is exactly the session the daemon is still bound
|
||||
// to, so its eval/snapshot auto-follow the new page instead of going stale.
|
||||
let mut s = RelayState::new();
|
||||
s.handle_ext_message(&attached_event("T_old", "cb-tab-42"), "tok");
|
||||
s.handle_ext_message(
|
||||
&json!({
|
||||
"method": "forwardCDPEvent",
|
||||
"params": { "method": "Target.detachedFromTarget", "params": { "sessionId": "cb-tab-42" } }
|
||||
}),
|
||||
"tok",
|
||||
);
|
||||
s.handle_ext_message(&attached_event("T_new", "cb-tab-42"), "tok");
|
||||
|
||||
let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" }));
|
||||
match route {
|
||||
ClientRoute::Local(v) => {
|
||||
let infos = v["result"]["targetInfos"].as_array().unwrap();
|
||||
assert_eq!(infos.len(), 1, "only the new target should remain");
|
||||
assert_eq!(infos[0]["targetId"], "T_new");
|
||||
}
|
||||
_ => panic!("getTargets must be local"),
|
||||
}
|
||||
// The daemon's existing session id still resolves — to the new target.
|
||||
let route = s.route_client_command(
|
||||
1,
|
||||
&json!({ "id": 2, "method": "Target.attachToTarget", "params": { "targetId": "T_new" } }),
|
||||
);
|
||||
assert_eq!(
|
||||
route,
|
||||
ClientRoute::Local(json!({ "id": 2, "result": { "sessionId": "cb-tab-42" } }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_get_version_is_answered_locally() {
|
||||
// Liveness probe must NOT be forwarded (the extension can't do
|
||||
|
||||
@@ -24,7 +24,6 @@ let port = null
|
||||
/** Whether the native-messaging host (the local chrome-use CLI) is linked.
|
||||
* Read by the popup status page. */
|
||||
let hostConnected = false
|
||||
let nextSession = 1
|
||||
/** tabId -> { sessionId, targetId } */
|
||||
const tabs = new Map()
|
||||
/** sessionId -> tabId (main session per tab) */
|
||||
@@ -261,7 +260,16 @@ async function attachTab(tabId) {
|
||||
const targetInfo = info?.targetInfo
|
||||
const targetId = String(targetInfo?.targetId || '')
|
||||
if (!targetId) throw new Error('attachTab: no targetId')
|
||||
const sessionId = `cb-tab-${nextSession++}`
|
||||
// Derive the session id from the STABLE Chrome tabId, not a monotonic counter
|
||||
// (issue #17). A tab's chrome.debugger session can be torn down and
|
||||
// re-established — cross-process navigation, a service-worker restart wiping
|
||||
// these in-memory maps, DevTools stealing the debugger — and each time the tab
|
||||
// re-attaches. With a counter, re-attach minted a BRAND-NEW `cb-tab-N`, which
|
||||
// orphaned the daemon's binding (it's still pinned to the old id and the relay
|
||||
// never tells it to rebind) → permanent "stale sessionId / tab is gone". The
|
||||
// tabId is stable across all of that, so `cb-tab-<tabId>` restores the SAME
|
||||
// session the daemon already holds → eval/snapshot auto-follow the new page.
|
||||
const sessionId = `cb-tab-${tabId}`
|
||||
const entry = { sessionId, targetId }
|
||||
tabs.set(tabId, entry)
|
||||
sessionToTab.set(sessionId, tabId)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "chrome-use",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||
"icons": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chrome-use",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
|
||||
@@ -672,6 +672,8 @@ and [references/authentication.md](references/authentication.md).
|
||||
`chrome-use skills get electron`
|
||||
- **Slack workspace automation**: `chrome-use skills get slack`
|
||||
- **Exploratory testing / QA / bug hunts**: `chrome-use skills get dogfood`
|
||||
- **Re-runnable test suites (frontend "unit tests")**: `chrome-use skills get test`
|
||||
— turn repeated checks into a `chrome-use test <suite.yaml>` regression suite
|
||||
- **Vercel Sandbox microVMs**: `chrome-use skills get vercel-sandbox`
|
||||
- **AWS Bedrock AgentCore cloud browser**: `chrome-use skills get agentcore`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user