Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1417c3c70 | ||
|
|
7cb69bd444 | ||
|
|
fb27835ebc | ||
|
|
2859da7b7c | ||
|
|
9ba43e0cbd | ||
|
|
d740884299 | ||
|
|
db484f2ac9 | ||
|
|
b475038e25 | ||
|
|
545e2545b4 | ||
|
|
5f342e34a2 | ||
|
|
4106a151a1 | ||
|
|
eb60053183 | ||
|
|
2aa216dd7a | ||
|
|
b6febbef39 | ||
|
|
5addb94dc4 | ||
|
|
f76ed1ddf5 | ||
|
|
7ba82bc6cc |
@@ -75,3 +75,4 @@ out/
|
|||||||
# extension signing key (never commit) + local-only id record
|
# extension signing key (never commit) + local-only id record
|
||||||
.secrets/
|
.secrets/
|
||||||
*.pem
|
*.pem
|
||||||
|
/cu-test-artifacts
|
||||||
|
|||||||
@@ -205,6 +205,47 @@ chrome-use --launch --profile auto open https://x.com/home
|
|||||||
|
|
||||||
In CI environments, standalone mode is used automatically.
|
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
|
## Anti-detection
|
||||||
|
|
||||||
<img src="assets/shield.png" alt="stealth shield" width="320" align="right" />
|
<img src="assets/shield.png" alt="stealth shield" width="320" align="right" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1023 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 888 KiB |
@@ -210,6 +210,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-padding"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "built"
|
name = "built"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -246,6 +255,15 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.56"
|
version = "1.2.56"
|
||||||
@@ -272,11 +290,13 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.0.0"
|
version = "1.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
|
"cbc",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -286,11 +306,14 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"libc",
|
"libc",
|
||||||
|
"pbkdf2",
|
||||||
"regex-lite",
|
"regex-lite",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"similar",
|
"similar",
|
||||||
"socket2",
|
"socket2",
|
||||||
@@ -1086,6 +1109,7 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"block-padding",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1376,6 +1400,16 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pbkdf2"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
"hmac",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -1949,6 +1983,19 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -2386,6 +2433,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.0.0"
|
version = "1.2.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
@@ -38,9 +38,14 @@ zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
|
|||||||
time = { version = "0.3", features = ["formatting"] }
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
aes = "0.8"
|
||||||
|
cbc = "0.1"
|
||||||
|
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||||
|
sha1 = "0.10"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
rust-embed = "8"
|
rust-embed = "8"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -1289,6 +1289,51 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
"cookies" => {
|
"cookies" => {
|
||||||
let op = rest.first().unwrap_or(&"get");
|
let op = rest.first().unwrap_or(&"get");
|
||||||
match *op {
|
match *op {
|
||||||
|
"transfer" => {
|
||||||
|
// Copy a logged-in session between Chrome profiles: decrypt
|
||||||
|
// the SOURCE profile's on-disk cookie store and inject the
|
||||||
|
// cookies into the active (connected) session — no CDP access
|
||||||
|
// to the source, no Chrome restart.
|
||||||
|
// cookies transfer --from <profile> [--domain <d>[,<d>]]
|
||||||
|
// `--from` wins; otherwise the global `--profile` is used.
|
||||||
|
let from = rest
|
||||||
|
.iter()
|
||||||
|
.position(|a| *a == "--from")
|
||||||
|
.and_then(|i| rest.get(i + 1).copied())
|
||||||
|
.or(flags.profile.as_deref());
|
||||||
|
let from = from.ok_or_else(|| ParseError::MissingArguments {
|
||||||
|
context: "cookies transfer".to_string(),
|
||||||
|
usage: "cookies transfer --from <profile> [--domain <domain>[,<domain>]]",
|
||||||
|
})?;
|
||||||
|
let domain = rest
|
||||||
|
.iter()
|
||||||
|
.position(|a| *a == "--domain")
|
||||||
|
.and_then(|i| rest.get(i + 1).copied());
|
||||||
|
let cookies =
|
||||||
|
crate::cookie_export::export_cookies(from, domain).map_err(|e| {
|
||||||
|
ParseError::InvalidValue {
|
||||||
|
message: format!("cookies transfer: {}", e),
|
||||||
|
usage: "cookies transfer --from <profile> [--domain <domain>]",
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if cookies.is_empty() {
|
||||||
|
return Err(ParseError::InvalidValue {
|
||||||
|
message: format!(
|
||||||
|
"cookies transfer: no cookies found in profile \"{}\"{}",
|
||||||
|
from,
|
||||||
|
domain
|
||||||
|
.map(|d| format!(" for domain {}", d))
|
||||||
|
.unwrap_or_default()
|
||||||
|
),
|
||||||
|
usage: "cookies transfer --from <profile> [--domain <domain>]",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(json!({
|
||||||
|
"id": id,
|
||||||
|
"action": "cookies_set",
|
||||||
|
"cookies": cookies,
|
||||||
|
}))
|
||||||
|
}
|
||||||
"set" => {
|
"set" => {
|
||||||
// --curl <file> mode: import cookies from a JSON array,
|
// --curl <file> mode: import cookies from a JSON array,
|
||||||
// raw cURL dump, or bare Cookie header. Scoped to the
|
// raw cURL dump, or bare Cookie header. Scoped to the
|
||||||
|
|||||||
@@ -16,8 +16,18 @@ use std::io::Write;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Native-messaging host name; must match `HOST_NAME` in the extension and the
|
/// Native-messaging host name; must match `HOST_NAME` in the extension and the
|
||||||
/// manifest filename.
|
/// manifest filename. `com.agent_browser.connect` is the original name, used by
|
||||||
pub const HOST_NAME: &str = "com.leeguoo.chrome_use";
|
/// every shipped extension up to ab-connect 0.4.2.
|
||||||
|
pub const HOST_NAME: &str = "com.agent_browser.connect";
|
||||||
|
|
||||||
|
/// Alternate host name for the chrome-use rebrand era (ab-connect 0.5.0+). We
|
||||||
|
/// install AND recognize both names so the relay works regardless of which
|
||||||
|
/// extension version a user has — old (0.4.2) or new — with no forced
|
||||||
|
/// re-install. See [`install_native_host`] / [`host_installed`].
|
||||||
|
pub const HOST_NAME_ALT: &str = "com.leeguoo.chrome_use";
|
||||||
|
|
||||||
|
/// Every native-messaging host name this CLI installs and accepts.
|
||||||
|
pub const HOST_NAMES: &[&str] = &[HOST_NAME, HOST_NAME_ALT];
|
||||||
|
|
||||||
/// Stable id of the `ab-connect` extension, pinned by the `key` in its
|
/// Stable id of the `ab-connect` extension, pinned by the `key` in its
|
||||||
/// manifest.json (and the signing key of the published `.crx`). Chrome only lets
|
/// manifest.json (and the signing key of the published `.crx`). Chrome only lets
|
||||||
@@ -182,18 +192,9 @@ fn install_native_host() -> Result<Vec<String>, String> {
|
|||||||
let _ = std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755));
|
let _ = std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755));
|
||||||
}
|
}
|
||||||
|
|
||||||
let manifest = serde_json::json!({
|
// Write a manifest under EVERY accepted host name (both point to the same
|
||||||
"name": HOST_NAME,
|
// launcher + allowed extensions), so any extension version's
|
||||||
"description": "chrome-use connect — native messaging host",
|
// `connectNative(<its host name>)` finds a matching host json.
|
||||||
"path": launcher.display().to_string(),
|
|
||||||
"type": "stdio",
|
|
||||||
"allowed_origins": [
|
|
||||||
format!("chrome-extension://{EXTENSION_ID}/"),
|
|
||||||
format!("chrome-extension://{STORE_EXTENSION_ID}/"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
let body = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let mut written = Vec::new();
|
let mut written = Vec::new();
|
||||||
for dir in native_messaging_dirs() {
|
for dir in native_messaging_dirs() {
|
||||||
if let Some(parent) = dir.parent() {
|
if let Some(parent) = dir.parent() {
|
||||||
@@ -202,9 +203,22 @@ fn install_native_host() -> Result<Vec<String>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
let path = dir.join(format!("{HOST_NAME}.json"));
|
for host in HOST_NAMES {
|
||||||
std::fs::write(&path, &body).map_err(|e| e.to_string())?;
|
let manifest = serde_json::json!({
|
||||||
written.push(path.display().to_string());
|
"name": host,
|
||||||
|
"description": "chrome-use connect — native messaging host",
|
||||||
|
"path": launcher.display().to_string(),
|
||||||
|
"type": "stdio",
|
||||||
|
"allowed_origins": [
|
||||||
|
format!("chrome-extension://{EXTENSION_ID}/"),
|
||||||
|
format!("chrome-extension://{STORE_EXTENSION_ID}/"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let body = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
|
||||||
|
let path = dir.join(format!("{host}.json"));
|
||||||
|
std::fs::write(&path, &body).map_err(|e| e.to_string())?;
|
||||||
|
written.push(path.display().to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if written.is_empty() {
|
if written.is_empty() {
|
||||||
return Err("no Chrome/Chromium NativeMessagingHosts directory found".into());
|
return Err("no Chrome/Chromium NativeMessagingHosts directory found".into());
|
||||||
@@ -289,9 +303,11 @@ fn remove_force_install_profile() -> bool {
|
|||||||
fn remove_host_manifests() -> usize {
|
fn remove_host_manifests() -> usize {
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
for dir in native_messaging_dirs() {
|
for dir in native_messaging_dirs() {
|
||||||
let path = dir.join(format!("{HOST_NAME}.json"));
|
for host in HOST_NAMES {
|
||||||
if path.exists() && std::fs::remove_file(&path).is_ok() {
|
let path = dir.join(format!("{host}.json"));
|
||||||
n += 1;
|
if path.exists() && std::fs::remove_file(&path).is_ok() {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
n
|
n
|
||||||
@@ -334,7 +350,7 @@ fn native_messaging_dirs() -> Vec<PathBuf> {
|
|||||||
fn host_manifest_path_for_chrome() -> Option<PathBuf> {
|
fn host_manifest_path_for_chrome() -> Option<PathBuf> {
|
||||||
native_messaging_dirs()
|
native_messaging_dirs()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|d| d.join(format!("{HOST_NAME}.json")))
|
.flat_map(|d| HOST_NAMES.iter().map(move |h| d.join(format!("{h}.json"))))
|
||||||
.find(|p| p.exists())
|
.find(|p| p.exists())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
native_messaging_dirs()
|
native_messaging_dirs()
|
||||||
@@ -352,9 +368,11 @@ fn host_manifest_path_for_chrome() -> Option<PathBuf> {
|
|||||||
/// service worker; this manifest is the durable signal that the extension is
|
/// service worker; this manifest is the durable signal that the extension is
|
||||||
/// the chosen path.
|
/// the chosen path.
|
||||||
pub fn host_installed() -> bool {
|
pub fn host_installed() -> bool {
|
||||||
native_messaging_dirs()
|
native_messaging_dirs().into_iter().any(|d| {
|
||||||
.into_iter()
|
HOST_NAMES
|
||||||
.any(|d| d.join(format!("{HOST_NAME}.json")).exists())
|
.iter()
|
||||||
|
.any(|h| d.join(format!("{h}.json")).exists())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn report(json: bool, ok: bool, msg: &str) {
|
fn report(json: bool, ok: bool, msg: &str) {
|
||||||
@@ -399,10 +417,23 @@ fn random_guid() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Where the daemon/CLI reads the relay's CDP WebSocket URL (perms 600).
|
/// Where the daemon/CLI reads the relay's CDP WebSocket URL (perms 600).
|
||||||
|
///
|
||||||
|
/// Cross-binary handoff: the native-messaging *host* writes it and the CLI reads
|
||||||
|
/// it, but the two may be different binaries under different brand dirs after
|
||||||
|
/// the agent-browser → chrome-use rename. Read from whichever brand dir actually
|
||||||
|
/// has the file (an old `agent-browser` host writes `~/.agent-browser`; a
|
||||||
|
/// `chrome-use` host writes `~/.chrome-use`); default to [`config_home`].
|
||||||
fn relay_url_path() -> PathBuf {
|
fn relay_url_path() -> PathBuf {
|
||||||
dirs::home_dir()
|
if let Some(home) = dirs::home_dir() {
|
||||||
.map(|h| h.join(".chrome-use").join("relay-cdp-url"))
|
for base in [".chrome-use", ".agent-browser"] {
|
||||||
.unwrap_or_else(|| PathBuf::from("/tmp/ab-relay-cdp-url"))
|
let p = home.join(base).join("relay-cdp-url");
|
||||||
|
if p.exists() {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crate::connection::config_home().join("relay-cdp-url");
|
||||||
|
}
|
||||||
|
PathBuf::from("/tmp/ab-relay-cdp-url")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The live relay CDP WebSocket URL, if the native-messaging host is running
|
/// The live relay CDP WebSocket URL, if the native-messaging host is running
|
||||||
|
|||||||
@@ -88,8 +88,39 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Brand-compat config directory basename. The project renamed
|
||||||
|
/// `agent-browser` → `chrome-use`, but this dotfile dir is invisible internal
|
||||||
|
/// plumbing: it's shared with the native-messaging host (the `relay-cdp-url`
|
||||||
|
/// handoff) and holds saved auth/daemon state. Renaming it would break existing
|
||||||
|
/// installs and re-pop the "Allow remote debugging?" dialog when the relay
|
||||||
|
/// can't be located. So decide ONCE per run: prefer the new `.chrome-use`, but
|
||||||
|
/// keep using an existing `.agent-browser` install if that's the only one
|
||||||
|
/// present; fresh installs get `.chrome-use`. `dotted` picks the home-dir form
|
||||||
|
/// (`.chrome-use`) vs the XDG/tmp subdir form (`chrome-use`); both agree.
|
||||||
|
pub fn config_dir_basename(dotted: bool) -> &'static str {
|
||||||
|
let prefer_old = dirs::home_dir()
|
||||||
|
.map(|h| !h.join(".chrome-use").exists() && h.join(".agent-browser").exists())
|
||||||
|
.unwrap_or(false);
|
||||||
|
match (prefer_old, dotted) {
|
||||||
|
(true, true) => ".agent-browser",
|
||||||
|
(true, false) => "agent-browser",
|
||||||
|
(false, true) => ".chrome-use",
|
||||||
|
(false, false) => "chrome-use",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The home-based config dir (`~/.chrome-use`, or `~/.agent-browser` on an
|
||||||
|
/// existing install — see [`config_dir_basename`]). Single source of truth so
|
||||||
|
/// sockets, auth, and the relay handoff all agree within one run.
|
||||||
|
pub fn config_home() -> PathBuf {
|
||||||
|
match dirs::home_dir() {
|
||||||
|
Some(home) => home.join(config_dir_basename(true)),
|
||||||
|
None => env::temp_dir().join(config_dir_basename(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the base directory for socket/pid files.
|
/// Get the base directory for socket/pid files.
|
||||||
/// Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.chrome-use > tmpdir
|
/// Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > config_home() > tmpdir
|
||||||
pub fn get_socket_dir() -> PathBuf {
|
pub fn get_socket_dir() -> PathBuf {
|
||||||
// 1. Explicit override (ignore empty string)
|
// 1. Explicit override (ignore empty string)
|
||||||
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
|
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
|
||||||
@@ -101,17 +132,17 @@ pub fn get_socket_dir() -> PathBuf {
|
|||||||
// 2. XDG_RUNTIME_DIR (Linux standard, ignore empty string)
|
// 2. XDG_RUNTIME_DIR (Linux standard, ignore empty string)
|
||||||
if let Ok(runtime_dir) = env::var("XDG_RUNTIME_DIR") {
|
if let Ok(runtime_dir) = env::var("XDG_RUNTIME_DIR") {
|
||||||
if !runtime_dir.is_empty() {
|
if !runtime_dir.is_empty() {
|
||||||
return PathBuf::from(runtime_dir).join("chrome-use");
|
return PathBuf::from(runtime_dir).join(config_dir_basename(false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Home directory fallback (like Docker Desktop's ~/.docker/run/)
|
// 3. Home directory fallback (like Docker Desktop's ~/.docker/run/)
|
||||||
if let Some(home) = dirs::home_dir() {
|
if dirs::home_dir().is_some() {
|
||||||
return home.join(".chrome-use");
|
return config_home();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Last resort: temp dir
|
// 4. Last resort: temp dir
|
||||||
env::temp_dir().join("chrome-use")
|
env::temp_dir().join(config_dir_basename(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
//! Offline export of a Chrome profile's cookies.
|
||||||
|
//!
|
||||||
|
//! Reads a profile's on-disk cookie store, decrypts the values with the OS
|
||||||
|
//! credential-store key, and returns CDP `Network.setCookie`-shaped objects —
|
||||||
|
//! the same shape `cookies set --curl` accepts. This is what powers
|
||||||
|
//! `cookies transfer`: it moves a logged-in session (whose auth cookies are
|
||||||
|
//! httpOnly + secure and span several hosts) from one profile to another
|
||||||
|
//! without the source profile being reachable over CDP, and without restarting
|
||||||
|
//! Chrome.
|
||||||
|
//!
|
||||||
|
//! Currently macOS-only. There, value encryption uses the `v10` scheme:
|
||||||
|
//! AES-128-CBC with a key derived (PBKDF2-HMAC-SHA1, 1003 iterations) from the
|
||||||
|
//! "Chrome Safe Storage" Keychain entry, shared by every profile of one Chrome
|
||||||
|
//! install. Other platforms return a clear error.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Resolve, read, and decrypt a Chrome profile's cookies.
|
||||||
|
///
|
||||||
|
/// `profile` accepts a directory name ("Default", "Profile 14"), a display name
|
||||||
|
/// ("Davian", case-insensitive), or "auto" (last-used profile). `domain`, when
|
||||||
|
/// set, is a comma-separated host-suffix filter (e.g. "claude.ai,anthropic.com")
|
||||||
|
/// matched against `host_key`; pass `None` to export every cookie.
|
||||||
|
pub fn export_cookies(profile: &str, domain: Option<&str>) -> Result<Vec<Value>, String> {
|
||||||
|
let db = resolve_cookie_db(profile)?;
|
||||||
|
let rows = read_cookie_rows(&db, domain)?;
|
||||||
|
let key = safe_storage_key()?;
|
||||||
|
let mut out = Vec::with_capacity(rows.len());
|
||||||
|
for r in &rows {
|
||||||
|
if let Some(value) = decrypt_value(&r.encrypted_value, &key) {
|
||||||
|
out.push(to_cdp_cookie(r, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_cookie_db(profile: &str) -> Result<PathBuf, String> {
|
||||||
|
use crate::native::cdp::chrome::{find_chrome_user_data_dir, resolve_chrome_profile};
|
||||||
|
let udd = find_chrome_user_data_dir()
|
||||||
|
.ok_or_else(|| "No Chrome user data directory found".to_string())?;
|
||||||
|
let dir = resolve_chrome_profile(&udd, profile)?;
|
||||||
|
let base = udd.join(&dir);
|
||||||
|
// Chrome >=96 keeps cookies under Network/; older builds at the profile root.
|
||||||
|
let net = base.join("Network").join("Cookies");
|
||||||
|
if net.is_file() {
|
||||||
|
return Ok(net);
|
||||||
|
}
|
||||||
|
let root = base.join("Cookies");
|
||||||
|
if root.is_file() {
|
||||||
|
return Ok(root);
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"no cookie store found for profile \"{}\" (looked in {} and {})",
|
||||||
|
profile,
|
||||||
|
net.display(),
|
||||||
|
root.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CookieRow {
|
||||||
|
host_key: String,
|
||||||
|
name: String,
|
||||||
|
encrypted_value: Vec<u8>,
|
||||||
|
path: String,
|
||||||
|
is_secure: bool,
|
||||||
|
is_httponly: bool,
|
||||||
|
samesite: i64,
|
||||||
|
expires_utc: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a temp directory when dropped.
|
||||||
|
struct TempGuard(PathBuf);
|
||||||
|
impl Drop for TempGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_cookie_rows(db: &Path, domain: Option<&str>) -> Result<Vec<CookieRow>, String> {
|
||||||
|
// Copy the store (plus any -wal/-shm) to a temp file so a running Chrome's
|
||||||
|
// lock / hot journal can't block the read or be disturbed by it.
|
||||||
|
let tmp_dir = std::env::temp_dir().join(format!("chrome-use-cookies-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&tmp_dir).map_err(|e| format!("temp dir: {}", e))?;
|
||||||
|
let _guard = TempGuard(tmp_dir.clone());
|
||||||
|
let tmp_db = tmp_dir.join("Cookies");
|
||||||
|
copy_db(db, &tmp_db)?;
|
||||||
|
|
||||||
|
let where_clause = build_where(domain)?;
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT json_group_array(json_object(\
|
||||||
|
'h',host_key,'n',name,'e',hex(encrypted_value),'p',path,\
|
||||||
|
'sec',is_secure,'ho',is_httponly,'ss',samesite,'x',expires_utc)) \
|
||||||
|
FROM cookies{};",
|
||||||
|
where_clause
|
||||||
|
);
|
||||||
|
let output = std::process::Command::new("sqlite3")
|
||||||
|
.arg(tmp_db.to_string_lossy().to_string())
|
||||||
|
.arg(&sql)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"could not run sqlite3 (required to read the cookie store): {}",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"sqlite3 failed reading the cookie store: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let trimmed = stdout.trim();
|
||||||
|
if trimmed.is_empty() || trimmed == "null" {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let arr: Vec<Value> =
|
||||||
|
serde_json::from_str(trimmed).map_err(|e| format!("parsing cookie rows: {}", e))?;
|
||||||
|
let mut rows = Vec::with_capacity(arr.len());
|
||||||
|
for v in arr {
|
||||||
|
let enc_hex = v.get("e").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
|
let path = v.get("p").and_then(|x| x.as_str()).unwrap_or("/");
|
||||||
|
rows.push(CookieRow {
|
||||||
|
host_key: v
|
||||||
|
.get("h")
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
name: v
|
||||||
|
.get("n")
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
encrypted_value: hex::decode(enc_hex).unwrap_or_default(),
|
||||||
|
path: if path.is_empty() {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
},
|
||||||
|
is_secure: v.get("sec").and_then(|x| x.as_i64()).unwrap_or(0) != 0,
|
||||||
|
is_httponly: v.get("ho").and_then(|x| x.as_i64()).unwrap_or(0) != 0,
|
||||||
|
samesite: v.get("ss").and_then(|x| x.as_i64()).unwrap_or(-1),
|
||||||
|
expires_utc: v.get("x").and_then(|x| x.as_i64()).unwrap_or(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_db(src: &Path, dst: &Path) -> Result<(), String> {
|
||||||
|
std::fs::copy(src, dst).map_err(|e| format!("copying cookie store: {}", e))?;
|
||||||
|
for suffix in ["-wal", "-shm"] {
|
||||||
|
let s = path_with_suffix(src, suffix);
|
||||||
|
if s.is_file() {
|
||||||
|
let _ = std::fs::copy(&s, path_with_suffix(dst, suffix));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_with_suffix(p: &Path, suffix: &str) -> PathBuf {
|
||||||
|
let mut s = p.as_os_str().to_os_string();
|
||||||
|
s.push(suffix);
|
||||||
|
PathBuf::from(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `WHERE host_key LIKE '%domain'` clause from a comma-separated filter.
|
||||||
|
/// Domains are validated (alnum/./-) so they can be inlined without injection.
|
||||||
|
fn build_where(domain: Option<&str>) -> Result<String, String> {
|
||||||
|
let Some(domain) = domain else {
|
||||||
|
return Ok(String::new());
|
||||||
|
};
|
||||||
|
let mut clauses = Vec::new();
|
||||||
|
for d in domain.split(',') {
|
||||||
|
let d = d.trim();
|
||||||
|
if d.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !d
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||||
|
{
|
||||||
|
return Err(format!("invalid domain filter \"{}\"", d));
|
||||||
|
}
|
||||||
|
clauses.push(format!("host_key LIKE '%{}'", d));
|
||||||
|
}
|
||||||
|
if clauses.is_empty() {
|
||||||
|
Ok(String::new())
|
||||||
|
} else {
|
||||||
|
Ok(format!(" WHERE {}", clauses.join(" OR ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cdp_cookie(r: &CookieRow, value: String) -> Value {
|
||||||
|
let mut o = serde_json::Map::new();
|
||||||
|
o.insert("name".into(), json!(r.name));
|
||||||
|
o.insert("value".into(), json!(value));
|
||||||
|
o.insert("domain".into(), json!(r.host_key));
|
||||||
|
o.insert("path".into(), json!(r.path));
|
||||||
|
o.insert("secure".into(), json!(r.is_secure));
|
||||||
|
o.insert("httpOnly".into(), json!(r.is_httponly));
|
||||||
|
// Chrome SameSite: -1 unspecified, 0 None, 1 Lax, 2 Strict.
|
||||||
|
let same_site = match r.samesite {
|
||||||
|
0 => Some("None"),
|
||||||
|
1 => Some("Lax"),
|
||||||
|
2 => Some("Strict"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(ss) = same_site {
|
||||||
|
// CDP rejects SameSite=None without Secure; downgrade rather than fail.
|
||||||
|
if ss == "None" && !r.is_secure {
|
||||||
|
o.insert("sameSite".into(), json!("Lax"));
|
||||||
|
} else {
|
||||||
|
o.insert("sameSite".into(), json!(ss));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(unix) = chrome_epoch_to_unix(r.expires_utc) {
|
||||||
|
o.insert("expires".into(), json!(unix));
|
||||||
|
}
|
||||||
|
Value::Object(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chrome stores `expires_utc` as microseconds since 1601-01-01 (0 = session
|
||||||
|
/// cookie). CDP wants seconds since the Unix epoch. Returns None for session
|
||||||
|
/// cookies and anything that converts to a non-positive time.
|
||||||
|
fn chrome_epoch_to_unix(expires_utc: i64) -> Option<f64> {
|
||||||
|
if expires_utc <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let unix = expires_utc as f64 / 1_000_000.0 - 11_644_473_600.0;
|
||||||
|
if unix > 0.0 {
|
||||||
|
Some(unix)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a Chrome `v10` cookie value (AES-128-CBC, IV = 16 spaces, PKCS7).
|
||||||
|
/// Returns None for unrecognized schemes or undecryptable values.
|
||||||
|
fn decrypt_value(enc: &[u8], key: &[u8; 16]) -> Option<String> {
|
||||||
|
if enc.len() < 3 || &enc[0..3] != b"v10" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
|
||||||
|
type Dec = cbc::Decryptor<aes::Aes128>;
|
||||||
|
let iv = [0x20u8; 16];
|
||||||
|
let mut buf = enc[3..].to_vec();
|
||||||
|
let pt = Dec::new(key.into(), &iv.into())
|
||||||
|
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
||||||
|
.ok()?;
|
||||||
|
// Chrome >=24 prepends a 32-byte SHA256(host) domain hash to the plaintext.
|
||||||
|
match std::str::from_utf8(pt) {
|
||||||
|
Ok(s) => Some(s.to_string()),
|
||||||
|
Err(_) if pt.len() > 32 => Some(String::from_utf8_lossy(&pt[32..]).into_owned()),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn safe_storage_key() -> Result<[u8; 16], String> {
|
||||||
|
use pbkdf2::pbkdf2_hmac;
|
||||||
|
use sha1::Sha1;
|
||||||
|
let out = std::process::Command::new("security")
|
||||||
|
.args(["find-generic-password", "-ws", "Chrome Safe Storage"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("could not read Keychain (security command): {}", e))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(
|
||||||
|
"could not read the 'Chrome Safe Storage' key from Keychain \
|
||||||
|
(you may be prompted to allow access — approve it and retry)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let pw = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let pw = pw.trim_end_matches('\n');
|
||||||
|
let mut key = [0u8; 16];
|
||||||
|
pbkdf2_hmac::<Sha1>(pw.as_bytes(), b"saltysalt", 1003, &mut key);
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn safe_storage_key() -> Result<[u8; 16], String> {
|
||||||
|
Err("cookies export/transfer is currently supported on macOS only".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn where_clause_filters_and_validates() {
|
||||||
|
assert_eq!(build_where(None).unwrap(), "");
|
||||||
|
assert_eq!(
|
||||||
|
build_where(Some("claude.ai")).unwrap(),
|
||||||
|
" WHERE host_key LIKE '%claude.ai'"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_where(Some("claude.ai, anthropic.com")).unwrap(),
|
||||||
|
" WHERE host_key LIKE '%claude.ai' OR host_key LIKE '%anthropic.com'"
|
||||||
|
);
|
||||||
|
assert!(build_where(Some("evil' OR 1=1 --")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn epoch_conversion() {
|
||||||
|
assert_eq!(chrome_epoch_to_unix(0), None);
|
||||||
|
assert_eq!(chrome_epoch_to_unix(-5), None);
|
||||||
|
// 13380163200000000 us since 1601 == 2025-01-01T00:00:00Z (1735689600 unix)
|
||||||
|
assert_eq!(
|
||||||
|
chrome_epoch_to_unix(13_380_163_200_000_000),
|
||||||
|
Some(1_735_689_600.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_cdp_downgrades_samesite_none_without_secure() {
|
||||||
|
let row = CookieRow {
|
||||||
|
host_key: ".claude.ai".into(),
|
||||||
|
name: "x".into(),
|
||||||
|
encrypted_value: vec![],
|
||||||
|
path: "/".into(),
|
||||||
|
is_secure: false,
|
||||||
|
is_httponly: true,
|
||||||
|
samesite: 0, // None
|
||||||
|
expires_utc: 0,
|
||||||
|
};
|
||||||
|
let c = to_cdp_cookie(&row, "v".into());
|
||||||
|
assert_eq!(c["sameSite"], "Lax");
|
||||||
|
assert_eq!(c["httpOnly"], true);
|
||||||
|
assert_eq!(c.get("expires"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ mod color;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod connect;
|
mod connect;
|
||||||
mod connection;
|
mod connection;
|
||||||
|
mod cookie_export;
|
||||||
mod doctor;
|
mod doctor;
|
||||||
mod findurl;
|
mod findurl;
|
||||||
mod flags;
|
mod flags;
|
||||||
@@ -10,6 +11,7 @@ mod install;
|
|||||||
mod native;
|
mod native;
|
||||||
mod output;
|
mod output;
|
||||||
mod skills;
|
mod skills;
|
||||||
|
mod test_runner;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test_utils;
|
mod test_utils;
|
||||||
mod upgrade;
|
mod upgrade;
|
||||||
@@ -200,6 +202,64 @@ fn run_profiles(json_mode: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_cookies_export(args: &[String], flags: &Flags) {
|
||||||
|
// Source profile comes from `--from <profile>`, falling back to the global
|
||||||
|
// `--profile` (which the flag parser has already moved into flags.profile).
|
||||||
|
let from = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--from")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.or(flags.profile.as_deref());
|
||||||
|
let profile = match from {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
let msg = "cookies export needs a source profile: cookies export --from <profile> [--domain <d>]";
|
||||||
|
if flags.json {
|
||||||
|
print_json_error(msg);
|
||||||
|
} else {
|
||||||
|
eprintln!("{} {}", color::error_indicator(), msg);
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let domain = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--domain")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.map(|s| s.as_str());
|
||||||
|
|
||||||
|
match cookie_export::export_cookies(profile, domain) {
|
||||||
|
Ok(cookies) => {
|
||||||
|
if flags.json {
|
||||||
|
print_json_value(json!({ "success": true, "data": cookies }));
|
||||||
|
} else {
|
||||||
|
// A JSON array ready for `cookies set --curl <file>`.
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&cookies).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
color::dim(&format!(
|
||||||
|
"{} cookies exported from \"{}\"",
|
||||||
|
cookies.len(),
|
||||||
|
profile
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if flags.json {
|
||||||
|
print_json_error(&e);
|
||||||
|
} else {
|
||||||
|
eprintln!("{} {}", color::error_indicator(), e);
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||||
let subcommand = args.get(1).map(|s| s.as_str());
|
let subcommand = args.get(1).map(|s| s.as_str());
|
||||||
|
|
||||||
@@ -210,13 +270,19 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|s| s.name)
|
.map(|s| s.name)
|
||||||
.collect();
|
.collect();
|
||||||
|
// The extension relay drives the user's live Chrome but isn't always
|
||||||
|
// registered as a launched daemon session — without surfacing it,
|
||||||
|
// `session list` says "No active sessions" while open/tab work fine,
|
||||||
|
// and agents misjudge the connection as down (issue #15).
|
||||||
|
let relay_up = connect::relay_url().is_some();
|
||||||
|
|
||||||
if json_mode {
|
if json_mode {
|
||||||
println!(
|
println!(
|
||||||
r#"{{"success":true,"data":{{"sessions":{}}}}}"#,
|
r#"{{"success":true,"data":{{"sessions":{},"relay":{}}}}}"#,
|
||||||
serde_json::to_string(&sessions).unwrap_or_default()
|
serde_json::to_string(&sessions).unwrap_or_default(),
|
||||||
|
relay_up
|
||||||
);
|
);
|
||||||
} else if sessions.is_empty() {
|
} else if sessions.is_empty() && !relay_up {
|
||||||
println!("No active sessions");
|
println!("No active sessions");
|
||||||
} else {
|
} else {
|
||||||
println!("Active sessions:");
|
println!("Active sessions:");
|
||||||
@@ -228,6 +294,14 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
|||||||
};
|
};
|
||||||
println!("{} {}", marker, s);
|
println!("{} {}", marker, s);
|
||||||
}
|
}
|
||||||
|
if relay_up && !sessions.iter().any(|s| s == session) {
|
||||||
|
println!(
|
||||||
|
"{} {} {}",
|
||||||
|
color::cyan("→"),
|
||||||
|
session,
|
||||||
|
color::dim("(relay/extension → live Chrome)")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None | Some(_) => {
|
None | Some(_) => {
|
||||||
@@ -639,6 +713,28 @@ fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle `cookies export` (doesn't need daemon): decrypt an on-disk Chrome
|
||||||
|
// profile's cookies and print them as JSON for `cookies set --curl`.
|
||||||
|
if clean.first().map(|s| s.as_str()) == Some("cookies")
|
||||||
|
&& clean.get(1).map(|s| s.as_str()) == Some("export")
|
||||||
|
{
|
||||||
|
run_cookies_export(&clean, &flags);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle `test <suite.yaml>`: run a browser test suite. It orchestrates by
|
||||||
|
// re-invoking this binary per step, so it lives outside the normal dispatch.
|
||||||
|
if clean.first().map(|s| s.as_str()) == Some("test") {
|
||||||
|
let Some(suite) = clean.get(1) else {
|
||||||
|
eprintln!(
|
||||||
|
"{} usage: chrome-use test <suite.yaml> [--launch | --session <name>]",
|
||||||
|
color::error_indicator()
|
||||||
|
);
|
||||||
|
exit(2);
|
||||||
|
};
|
||||||
|
exit(test_runner::run_test(suite, &flags));
|
||||||
|
}
|
||||||
|
|
||||||
// Handle skills command (doesn't need daemon)
|
// Handle skills command (doesn't need daemon)
|
||||||
if clean.first().map(|s| s.as_str()) == Some("skills") {
|
if clean.first().map(|s| s.as_str()) == Some("skills") {
|
||||||
skills::run_skills(&clean, flags.json);
|
skills::run_skills(&clean, flags.json);
|
||||||
|
|||||||
@@ -2877,6 +2877,15 @@ async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
|||||||
Ok(json!({ "snapshot": tree, "origin": url, "refs": refs }))
|
Ok(json!({ "snapshot": tree, "origin": url, "refs": refs }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a (possibly relative) saved-file path to an absolute one so the CLI
|
||||||
|
/// echoes a path the agent can read regardless of the process cwd (issue #16).
|
||||||
|
/// Falls back to the original string if the file can't be canonicalized.
|
||||||
|
fn absolutize_saved_path(p: &str) -> String {
|
||||||
|
std::fs::canonicalize(p)
|
||||||
|
.map(|c| c.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|_| p.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let annotate = cmd
|
let annotate = cmd
|
||||||
.get("annotate")
|
.get("annotate")
|
||||||
@@ -2902,7 +2911,7 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
.map_err(|e| format!("Base64 decode error: {}", e))?;
|
.map_err(|e| format!("Base64 decode error: {}", e))?;
|
||||||
std::fs::write(p, bytes)
|
std::fs::write(p, bytes)
|
||||||
.map_err(|e| format!("Failed to write screenshot: {}", e))?;
|
.map_err(|e| format!("Failed to write screenshot: {}", e))?;
|
||||||
return Ok(json!({ "path": p }));
|
return Ok(json!({ "path": absolutize_saved_path(p) }));
|
||||||
}
|
}
|
||||||
let tmp = format!(
|
let tmp = format!(
|
||||||
"/tmp/screenshot-{}.png",
|
"/tmp/screenshot-{}.png",
|
||||||
@@ -2976,7 +2985,7 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut response = json!({ "path": result.path });
|
let mut response = json!({ "path": absolutize_saved_path(&result.path) });
|
||||||
if !result.annotations.is_empty() {
|
if !result.annotations.is_empty() {
|
||||||
response["annotations"] = serde_json::to_value(&result.annotations)
|
response["annotations"] = serde_json::to_value(&result.annotations)
|
||||||
.map_err(|e| format!("Failed to serialize annotations: {}", e))?;
|
.map_err(|e| format!("Failed to serialize annotations: {}", e))?;
|
||||||
|
|||||||
@@ -136,6 +136,24 @@ fn active_page_index_after_removal(
|
|||||||
active_page_index
|
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.
|
/// Converts common error messages into AI-friendly, actionable descriptions.
|
||||||
pub fn to_ai_friendly_error(error: &str) -> String {
|
pub fn to_ai_friendly_error(error: &str) -> String {
|
||||||
let lower = error.to_lowercase();
|
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
|
/// 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.
|
/// gone. This is what keeps commands on the tab the agent actually opened.
|
||||||
fn resolved_active_index(&self) -> usize {
|
fn resolved_active_index(&self) -> usize {
|
||||||
if let Some(tid) = &self.active_target_id {
|
resolve_active_index(
|
||||||
if let Some(i) = self.pages.iter().position(|p| &p.target_id == tid) {
|
&self.pages,
|
||||||
return i;
|
self.active_target_id.as_deref(),
|
||||||
}
|
self.active_page_index,
|
||||||
}
|
)
|
||||||
self.active_page_index
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pin the current active page by target_id so later commands stick to it.
|
/// 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) {
|
if let Some(page) = self.pages.get_mut(self.active_page_index) {
|
||||||
page.url = page_url.clone();
|
page.url = page_url.clone();
|
||||||
page.title = title.clone();
|
page.title = title.clone();
|
||||||
}
|
}
|
||||||
|
self.pin_active_target();
|
||||||
|
|
||||||
let mut out = json!({ "url": page_url, "title": title });
|
let mut out = json!({ "url": page_url, "title": title });
|
||||||
if let Some(w) = nav_warning {
|
if let Some(w) = nav_warning {
|
||||||
@@ -1124,6 +1151,9 @@ impl BrowserManager {
|
|||||||
target_type: "page".to_string(),
|
target_type: "page".to_string(),
|
||||||
});
|
});
|
||||||
self.active_page_index = 0;
|
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?;
|
self.enable_domains(&attach_result.session_id).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1593,7 +1623,25 @@ impl BrowserManager {
|
|||||||
})),
|
})),
|
||||||
Some(&effective_session_id),
|
Some(&effective_session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
// Chrome's chrome.debugger API (the extension-relay transport)
|
||||||
|
// forbids DOM.setFileInputFiles for security, surfacing as an
|
||||||
|
// opaque `-32000 "Not allowed"`. Translate it into an actionable
|
||||||
|
// message rather than leaking the raw CDP error (issue #13).
|
||||||
|
if e.contains("Not allowed") || e.contains("-32000") {
|
||||||
|
"file upload isn't supported over the extension relay — \
|
||||||
|
Chrome's chrome.debugger API forbids DOM.setFileInputFiles. \
|
||||||
|
Use a direct-CDP session instead: \
|
||||||
|
`chrome-use --session up --launch open <url>` (carry your \
|
||||||
|
login over with `cookies export` | `cookies set --curl`), \
|
||||||
|
then run `upload` in that session. \
|
||||||
|
See https://github.com/leeguooooo/chrome-use/issues/13"
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
e
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2157,6 +2205,55 @@ mod tests {
|
|||||||
assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
|
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
|
// 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
|
// 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
|
// purely (BrowserManager needs a live CDP client, so the method itself can't
|
||||||
|
|||||||
@@ -341,6 +341,46 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cross-process advisory lock that serializes concurrent launches of the SAME
|
||||||
|
/// Chrome profile (issue #11). Held via `flock` on a per-profile lock file; the
|
||||||
|
/// kernel releases it automatically when the holding process exits, so a crash
|
||||||
|
/// can't wedge the queue. Best-effort: if the lock can't be acquired the launch
|
||||||
|
/// proceeds unlocked rather than failing.
|
||||||
|
struct ProfileLaunchLock {
|
||||||
|
#[cfg(unix)]
|
||||||
|
_file: std::fs::File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileLaunchLock {
|
||||||
|
fn acquire(profile: &str) -> Option<Self> {
|
||||||
|
let safe: String = profile
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() { c } else { '_' })
|
||||||
|
.collect();
|
||||||
|
let path = std::env::temp_dir().join(format!("chrome-use-launch-{safe}.lock"));
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(&path)
|
||||||
|
.ok()?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
// Blocking exclusive lock: concurrent same-profile launches queue.
|
||||||
|
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ProfileLaunchLock { _file: file })
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = file;
|
||||||
|
Some(ProfileLaunchLock {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||||
let chrome_path = match &options.executable_path {
|
let chrome_path = match &options.executable_path {
|
||||||
Some(p) => PathBuf::from(p),
|
Some(p) => PathBuf::from(p),
|
||||||
@@ -363,6 +403,13 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
|||||||
// rewrite options so the retry loop uses the copied profile.
|
// rewrite options so the retry loop uses the copied profile.
|
||||||
let mut resolved_options: Option<LaunchOptions> = None;
|
let mut resolved_options: Option<LaunchOptions> = None;
|
||||||
let mut profile_temp_dir: Option<PathBuf> = None;
|
let mut profile_temp_dir: Option<PathBuf> = None;
|
||||||
|
// Serialize concurrent launches of the SAME named profile across processes
|
||||||
|
// (issue #11). Without this, N parallel `open --profile <same>` collide on
|
||||||
|
// the profile-copy disk I/O / Chrome's profile lock, every candidate burns
|
||||||
|
// its full launch timeout, and all fail. The flock queues them instead and
|
||||||
|
// auto-releases on process exit, so a crash can't wedge the queue. Held
|
||||||
|
// until Chrome is up (function return).
|
||||||
|
let mut _launch_lock: Option<ProfileLaunchLock> = None;
|
||||||
|
|
||||||
if let Some(ref profile) = options.profile {
|
if let Some(ref profile) = options.profile {
|
||||||
if is_chrome_profile_name(profile) {
|
if is_chrome_profile_name(profile) {
|
||||||
@@ -372,6 +419,7 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
|||||||
.to_string()
|
.to_string()
|
||||||
})?;
|
})?;
|
||||||
let resolved = resolve_chrome_profile(&user_data_dir, profile)?;
|
let resolved = resolve_chrome_profile(&user_data_dir, profile)?;
|
||||||
|
_launch_lock = ProfileLaunchLock::acquire(&resolved);
|
||||||
let temp_path = copy_chrome_profile(&user_data_dir, &resolved)?;
|
let temp_path = copy_chrome_profile(&user_data_dir, &resolved)?;
|
||||||
|
|
||||||
let mut opts = options.clone();
|
let mut opts = options.clone();
|
||||||
@@ -1886,6 +1934,17 @@ mod tests {
|
|||||||
assert!(is_chrome_profile_name(""));
|
assert!(is_chrome_profile_name(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_launch_lock_acquires_and_sanitizes() {
|
||||||
|
// Uncontended acquire succeeds and writes a sanitized per-profile lock
|
||||||
|
// file (issue #11: serialize concurrent same-profile launches).
|
||||||
|
let lock = ProfileLaunchLock::acquire("Profile 5/weird:name");
|
||||||
|
assert!(lock.is_some(), "uncontended lock should acquire");
|
||||||
|
let expected = std::env::temp_dir().join("chrome-use-launch-Profile_5_weird_name.lock");
|
||||||
|
assert!(expected.exists(), "lock file should exist at {expected:?}");
|
||||||
|
drop(lock);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_chrome_profile_name_paths() {
|
fn test_is_chrome_profile_name_paths() {
|
||||||
assert!(!is_chrome_profile_name("/tmp/dir"));
|
assert!(!is_chrome_profile_name("/tmp/dir"));
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn browser_get_version_is_answered_locally() {
|
fn browser_get_version_is_answered_locally() {
|
||||||
// Liveness probe must NOT be forwarded (the extension can't do
|
// Liveness probe must NOT be forwarded (the extension can't do
|
||||||
|
|||||||
@@ -3101,6 +3101,10 @@ Network: chrome-use network <action>
|
|||||||
Storage:
|
Storage:
|
||||||
cookies [get|set|clear] Manage cookies (set supports --url, --domain, --path, --httpOnly, --secure, --sameSite, --expires)
|
cookies [get|set|clear] Manage cookies (set supports --url, --domain, --path, --httpOnly, --secure, --sameSite, --expires)
|
||||||
Or: cookies set --curl <file> [--domain <host>] (auto-detects JSON/cURL/Cookie-header files)
|
Or: cookies set --curl <file> [--domain <host>] (auto-detects JSON/cURL/Cookie-header files)
|
||||||
|
cookies export --from <profile> [--domain <d>[,<d>]]
|
||||||
|
Decrypt another Chrome profile's cookies -> JSON for `cookies set --curl` (macOS)
|
||||||
|
cookies transfer --from <profile> [--domain <d>[,<d>]]
|
||||||
|
Copy a logged-in session from another profile into the connected browser (macOS)
|
||||||
storage <local|session> Manage web storage
|
storage <local|session> Manage web storage
|
||||||
|
|
||||||
Tabs:
|
Tabs:
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
//! `chrome-use test <suite.yaml>` — a tiny, re-runnable browser test runner.
|
||||||
|
//!
|
||||||
|
//! Turns repetitive browser checks into unit-test-style suites for the frontend.
|
||||||
|
//! A suite is a YAML file of cases; each case is a list of `steps` (which reuse
|
||||||
|
//! chrome-use's own commands) followed by `assert`s (which compile to a single
|
||||||
|
//! `eval` expression read back as a boolean). The runner drives the session by
|
||||||
|
//! re-invoking the chrome-use binary per step, so it inherits every flag /
|
||||||
|
//! launch / daemon / `@ref` semantic for free; the daemon stays up for the
|
||||||
|
//! session, so each step is just a fast socket round-trip.
|
||||||
|
//!
|
||||||
|
//! ```yaml
|
||||||
|
//! suite: chatgpt smoke
|
||||||
|
//! setup:
|
||||||
|
//! - account: chatgpt/huayue # cookie-use injects this login (optional)
|
||||||
|
//! cases:
|
||||||
|
//! - name: home loads logged in
|
||||||
|
//! steps:
|
||||||
|
//! - open: https://chatgpt.com/
|
||||||
|
//! - wait: { load: networkidle }
|
||||||
|
//! assert:
|
||||||
|
//! - url: { contains: chatgpt.com }
|
||||||
|
//! - visible: "#prompt-textarea"
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::flags::Flags;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub fn run_test(suite_path: &str, flags: &Flags) -> i32 {
|
||||||
|
let text = match std::fs::read_to_string(suite_path) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{} cannot read suite '{}': {}", err(), suite_path, e);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// YAML deserializes straight into serde_json::Value (maps→objects, etc.).
|
||||||
|
let suite: Value = match serde_yaml::from_str(&text) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{} invalid YAML in '{}': {}", err(), suite_path, e);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let cases = match suite.get("cases").and_then(|c| c.as_array()) {
|
||||||
|
Some(c) if !c.is_empty() => c.clone(),
|
||||||
|
_ => {
|
||||||
|
eprintln!("{} suite has no `cases`", err());
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let suite_name = suite
|
||||||
|
.get("suite")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("suite");
|
||||||
|
|
||||||
|
let exe = match std::env::current_exe() {
|
||||||
|
Ok(p) => p.to_string_lossy().into_owned(),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{} cannot find own binary: {}", err(), e);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A dedicated launched browser by default (deterministic, re-runnable). If
|
||||||
|
// the user named a --session, target that existing one instead.
|
||||||
|
let (session, do_launch) = if flags.session == "default" {
|
||||||
|
("cu-test".to_string(), true)
|
||||||
|
} else {
|
||||||
|
(flags.session.clone(), flags.force_launch)
|
||||||
|
};
|
||||||
|
let owns_session = session == "cu-test";
|
||||||
|
|
||||||
|
let mut base: Vec<String> = vec!["--session".into(), session.clone()];
|
||||||
|
if do_launch {
|
||||||
|
base.push("--launch".into());
|
||||||
|
}
|
||||||
|
if let Some(p) = &flags.profile {
|
||||||
|
base.push("--profile".into());
|
||||||
|
base.push(p.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let artifacts_dir = flags
|
||||||
|
.download_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "cu-test-artifacts".to_string());
|
||||||
|
|
||||||
|
let runner = Runner {
|
||||||
|
exe,
|
||||||
|
base,
|
||||||
|
artifacts_dir,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- setup (runs once) ---
|
||||||
|
if let Some(setup) = suite.get("setup").and_then(|s| s.as_array()) {
|
||||||
|
for item in setup {
|
||||||
|
if let Err(e) = runner.run_setup_item(item, &session) {
|
||||||
|
eprintln!("{} setup failed: {}", err(), e);
|
||||||
|
if owns_session {
|
||||||
|
runner.close();
|
||||||
|
}
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cases ---
|
||||||
|
println!("suite: {} (session {})", suite_name, session);
|
||||||
|
let mut passed = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
for case in &cases {
|
||||||
|
let name = case
|
||||||
|
.get("name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("(unnamed)");
|
||||||
|
let start = Instant::now();
|
||||||
|
let outcome = runner.run_case(case);
|
||||||
|
let secs = start.elapsed().as_secs_f64();
|
||||||
|
match outcome {
|
||||||
|
Ok(()) => {
|
||||||
|
passed += 1;
|
||||||
|
println!(" {} {} {:.1}s", ok(), name, secs);
|
||||||
|
}
|
||||||
|
Err(failure) => {
|
||||||
|
failed += 1;
|
||||||
|
println!(" {} {} {:.1}s", cross(), name, secs);
|
||||||
|
println!(" {}", failure.reason);
|
||||||
|
if let Some(shot) = runner.capture_artifact(name) {
|
||||||
|
println!(" ↳ {}", shot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if owns_session {
|
||||||
|
runner.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} cases · {} passed · {} failed",
|
||||||
|
cases.len(),
|
||||||
|
passed,
|
||||||
|
failed
|
||||||
|
);
|
||||||
|
i32::from(failed > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Failure {
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Runner {
|
||||||
|
exe: String,
|
||||||
|
base: Vec<String>,
|
||||||
|
artifacts_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Runner {
|
||||||
|
/// Run one chrome-use sub-command. Returns the `data` object on success.
|
||||||
|
fn cli(&self, args: &[String]) -> Result<Option<Value>, String> {
|
||||||
|
let out = Command::new(&self.exe)
|
||||||
|
.args(&self.base)
|
||||||
|
.args(args)
|
||||||
|
.arg("--json")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("spawning chrome-use: {}", e))?;
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
if let Ok(v) = serde_json::from_str::<Value>(stdout.trim()) {
|
||||||
|
let success = v
|
||||||
|
.get("success")
|
||||||
|
.and_then(|b| b.as_bool())
|
||||||
|
.unwrap_or(out.status.success());
|
||||||
|
if !success {
|
||||||
|
return Err(v
|
||||||
|
.get("error")
|
||||||
|
.and_then(|e| e.as_str())
|
||||||
|
.unwrap_or("command failed")
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
return Ok(v.get("data").cloned());
|
||||||
|
}
|
||||||
|
if out.status.success() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&self) {
|
||||||
|
let _ = self.cli(&["close".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_setup_item(&self, item: &Value, session: &str) -> Result<(), String> {
|
||||||
|
// `account: <id>` injects a stored cookie-use login into this session.
|
||||||
|
if let Some(acct) = item.get("account").and_then(|a| a.as_str()) {
|
||||||
|
let target = format!("session:{}", session);
|
||||||
|
let out = Command::new("cookie-use")
|
||||||
|
.args(["use", acct, "--target", &target, "--no-open"])
|
||||||
|
.output();
|
||||||
|
return match out {
|
||||||
|
Ok(o) if o.status.success() => Ok(()),
|
||||||
|
Ok(o) => Err(format!(
|
||||||
|
"cookie-use use {} failed: {}",
|
||||||
|
acct,
|
||||||
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
|
)),
|
||||||
|
Err(e) => Err(format!(
|
||||||
|
"cookie-use not available ({}); skip `account:` or install it",
|
||||||
|
e
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Otherwise it's a normal step.
|
||||||
|
let args = step_to_args(item)?;
|
||||||
|
self.cli(&args).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_case(&self, case: &Value) -> Result<(), Failure> {
|
||||||
|
if let Some(steps) = case.get("steps").and_then(|s| s.as_array()) {
|
||||||
|
for step in steps {
|
||||||
|
let args = step_to_args(step).map_err(|e| Failure {
|
||||||
|
reason: format!("bad step: {}", e),
|
||||||
|
})?;
|
||||||
|
self.cli(&args).map_err(|e| Failure {
|
||||||
|
reason: format!(
|
||||||
|
"step `{}` failed: {}",
|
||||||
|
args.first().cloned().unwrap_or_default(),
|
||||||
|
e
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(asserts) = case.get("assert").and_then(|a| a.as_array()) {
|
||||||
|
for a in asserts {
|
||||||
|
let (expr, describe) = assert_to_eval(a).map_err(|e| Failure {
|
||||||
|
reason: format!("bad assert: {}", e),
|
||||||
|
})?;
|
||||||
|
let data = self.cli(&["eval".to_string(), expr]).map_err(|e| Failure {
|
||||||
|
reason: format!("assert `{}` could not run: {}", describe, e),
|
||||||
|
})?;
|
||||||
|
let result = data.as_ref().and_then(|d| d.get("result"));
|
||||||
|
if !is_truthy(result) {
|
||||||
|
let got = result
|
||||||
|
.map(value_short)
|
||||||
|
.unwrap_or_else(|| "undefined".into());
|
||||||
|
return Err(Failure {
|
||||||
|
reason: format!("assert {} → got {}", describe, got),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort screenshot of the failing state. Returns the saved path.
|
||||||
|
fn capture_artifact(&self, case_name: &str) -> Option<String> {
|
||||||
|
let _ = std::fs::create_dir_all(&self.artifacts_dir);
|
||||||
|
let path = format!("{}/{}.png", self.artifacts_dir, slug(case_name));
|
||||||
|
match self.cli(&["screenshot".to_string(), path.clone()]) {
|
||||||
|
Ok(Some(d)) => d
|
||||||
|
.get("path")
|
||||||
|
.and_then(|p| p.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
.or(Some(path)),
|
||||||
|
Ok(None) => Some(path),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a YAML step (a one-key object) to chrome-use CLI args.
|
||||||
|
fn step_to_args(step: &Value) -> Result<Vec<String>, String> {
|
||||||
|
let obj = step
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| "step must be a key: value mapping".to_string())?;
|
||||||
|
let (key, val) = obj.iter().next().ok_or_else(|| "empty step".to_string())?;
|
||||||
|
let s = |v: &Value| v.as_str().map(String::from);
|
||||||
|
match key.as_str() {
|
||||||
|
"open" | "goto" | "navigate" => {
|
||||||
|
let url = s(val).ok_or("open: expected a URL string")?;
|
||||||
|
Ok(vec!["open".into(), url])
|
||||||
|
}
|
||||||
|
"click" => Ok(vec![
|
||||||
|
"click".into(),
|
||||||
|
s(val).ok_or("click: expected a selector")?,
|
||||||
|
]),
|
||||||
|
"press" => Ok(vec!["press".into(), s(val).ok_or("press: expected a key")?]),
|
||||||
|
"eval" => Ok(vec![
|
||||||
|
"eval".into(),
|
||||||
|
s(val).ok_or("eval: expected JS string")?,
|
||||||
|
]),
|
||||||
|
"fill" | "type" => {
|
||||||
|
let sel = field(val, &["sel", "selector"]).ok_or("fill/type: need sel")?;
|
||||||
|
let text = field(val, &["text", "value"]).ok_or("fill/type: need text")?;
|
||||||
|
Ok(vec![key.clone(), sel, text])
|
||||||
|
}
|
||||||
|
"scroll" => {
|
||||||
|
if let Some(dir) = s(val) {
|
||||||
|
Ok(vec!["scroll".into(), dir])
|
||||||
|
} else {
|
||||||
|
let dir = field(val, &["dir", "direction"]).ok_or("scroll: need dir")?;
|
||||||
|
let mut a = vec!["scroll".into(), dir];
|
||||||
|
if let Some(px) = field(val, &["px", "pixels"]) {
|
||||||
|
a.push(px);
|
||||||
|
}
|
||||||
|
Ok(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"wait" => {
|
||||||
|
if let Some(n) = val.as_i64() {
|
||||||
|
Ok(vec!["wait".into(), n.to_string()])
|
||||||
|
} else if let Some(load) = field(val, &["load"]) {
|
||||||
|
Ok(vec!["wait".into(), "--load".into(), load])
|
||||||
|
} else if let Some(sel) = s(val) {
|
||||||
|
Ok(vec!["wait".into(), sel])
|
||||||
|
} else {
|
||||||
|
Err("wait: expected ms, a selector, or { load: <state> }".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => Err(format!("unknown step `{}`", other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile a YAML assert (one-key object) into (js-bool-expr, human-describe).
|
||||||
|
fn assert_to_eval(a: &Value) -> Result<(String, String), String> {
|
||||||
|
let obj = a
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| "assert must be a key: value mapping".to_string())?;
|
||||||
|
let (key, val) = obj
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "empty assert".to_string())?;
|
||||||
|
match key.as_str() {
|
||||||
|
"url" => {
|
||||||
|
let (op, want) = str_op(val).ok_or("url: need contains/equals/matches")?;
|
||||||
|
Ok((
|
||||||
|
cmp_expr("location.href", &op, &want),
|
||||||
|
format!("url {} {:?}", op, want),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"visible" => {
|
||||||
|
let sel = val.as_str().ok_or("visible: expected a selector")?;
|
||||||
|
Ok((visible_expr(sel), format!("visible {:?}", sel)))
|
||||||
|
}
|
||||||
|
"hidden" => {
|
||||||
|
let sel = val.as_str().ok_or("hidden: expected a selector")?;
|
||||||
|
Ok((
|
||||||
|
format!("!({})", visible_expr(sel)),
|
||||||
|
format!("hidden {:?}", sel),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"text" => {
|
||||||
|
let sel = field(val, &["sel", "selector"]).ok_or("text: need sel")?;
|
||||||
|
let (op, want) = str_op(val).ok_or("text: need contains/equals/matches")?;
|
||||||
|
let base = format!(
|
||||||
|
"((document.querySelector({})||{{}}).textContent||\"\")",
|
||||||
|
js(&sel)
|
||||||
|
);
|
||||||
|
Ok((
|
||||||
|
cmp_expr(&base, &op, &want),
|
||||||
|
format!("text {:?} {} {:?}", sel, op, want),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"count" => {
|
||||||
|
let sel = field(val, &["sel", "selector"]).ok_or("count: need sel")?;
|
||||||
|
let n = val
|
||||||
|
.get("eq")
|
||||||
|
.or_else(|| val.get("equals"))
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.ok_or("count: need eq: <n>")?;
|
||||||
|
Ok((
|
||||||
|
format!("document.querySelectorAll({}).length==={}", js(&sel), n),
|
||||||
|
format!("count {:?} == {}", sel, n),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"eval" => {
|
||||||
|
let expr = val.as_str().ok_or("eval: expected JS string")?;
|
||||||
|
Ok((format!("!!({})", expr), format!("eval {:?}", expr)))
|
||||||
|
}
|
||||||
|
other => Err(format!("unknown assert `{}`", other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible_expr(sel: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"(function(){{var e=document.querySelector({});return !!(e&&(e.offsetWidth||e.offsetHeight||e.getClientRects().length));}})()",
|
||||||
|
js(sel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract (op, want) from `{contains|equals|matches: <str>}`.
|
||||||
|
fn str_op(val: &Value) -> Option<(String, String)> {
|
||||||
|
for op in ["contains", "equals", "matches"] {
|
||||||
|
if let Some(s) = val.get(op).and_then(|v| v.as_str()) {
|
||||||
|
return Some((op.to_string(), s.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmp_expr(base: &str, op: &str, want: &str) -> String {
|
||||||
|
match op {
|
||||||
|
"equals" => format!("({})==={}", base, js(want)),
|
||||||
|
"matches" => format!("new RegExp({}).test({})", js(want), base),
|
||||||
|
_ => format!("({}).includes({})", base, js(want)), // contains
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First present field among `keys`, as a string.
|
||||||
|
fn field(val: &Value, keys: &[&str]) -> Option<String> {
|
||||||
|
for k in keys {
|
||||||
|
if let Some(v) = val.get(*k) {
|
||||||
|
return match v {
|
||||||
|
Value::String(s) => Some(s.clone()),
|
||||||
|
Value::Number(n) => Some(n.to_string()),
|
||||||
|
Value::Bool(b) => Some(b.to_string()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-encode a string so it embeds safely as a JS literal.
|
||||||
|
fn js(s: &str) -> String {
|
||||||
|
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_truthy(v: Option<&Value>) -> bool {
|
||||||
|
match v {
|
||||||
|
Some(Value::Bool(b)) => *b,
|
||||||
|
Some(Value::Null) | None => false,
|
||||||
|
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
|
||||||
|
Some(Value::String(s)) => !s.is_empty(),
|
||||||
|
Some(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_short(v: &Value) -> String {
|
||||||
|
let s = v.to_string();
|
||||||
|
if s.len() > 60 {
|
||||||
|
format!("{}…", &s[..60])
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slug(name: &str) -> String {
|
||||||
|
let s: String = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||||
|
.collect();
|
||||||
|
s.trim_matches('-').to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_mapping() {
|
||||||
|
assert_eq!(
|
||||||
|
step_to_args(&json!({"open": "https://x.com"})).unwrap(),
|
||||||
|
vec!["open", "https://x.com"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
step_to_args(&json!({"fill": {"sel": "#a", "text": "hi"}})).unwrap(),
|
||||||
|
vec!["fill", "#a", "hi"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
step_to_args(&json!({"wait": {"load": "networkidle"}})).unwrap(),
|
||||||
|
vec!["wait", "--load", "networkidle"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
step_to_args(&json!({"wait": 500})).unwrap(),
|
||||||
|
vec!["wait", "500"]
|
||||||
|
);
|
||||||
|
assert!(step_to_args(&json!({"bogus": 1})).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assert_compilation() {
|
||||||
|
let (e, _) = assert_to_eval(&json!({"url": {"contains": "x.com"}})).unwrap();
|
||||||
|
assert!(e.contains("location.href") && e.contains(".includes("));
|
||||||
|
let (e, _) = assert_to_eval(&json!({"count": {"sel": ".a", "eq": 3}})).unwrap();
|
||||||
|
assert!(e.contains("querySelectorAll") && e.ends_with("===3"));
|
||||||
|
let (e, _) = assert_to_eval(&json!({"hidden": "#x"})).unwrap();
|
||||||
|
assert!(e.starts_with("!("));
|
||||||
|
let (e, _) = assert_to_eval(&json!({"eval": "window.ok"})).unwrap();
|
||||||
|
assert_eq!(e, "!!(window.ok)");
|
||||||
|
assert!(assert_to_eval(&json!({"bogus": 1})).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truthiness() {
|
||||||
|
assert!(is_truthy(Some(&json!(true))));
|
||||||
|
assert!(!is_truthy(Some(&json!(false))));
|
||||||
|
assert!(!is_truthy(None));
|
||||||
|
assert!(!is_truthy(Some(&json!(""))));
|
||||||
|
assert!(is_truthy(Some(&json!("x"))));
|
||||||
|
assert!(!is_truthy(Some(&json!(0))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn js_escaping() {
|
||||||
|
// Selectors with quotes must embed safely.
|
||||||
|
assert_eq!(js(r#"a"b"#), r#""a\"b""#);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok() -> &'static str {
|
||||||
|
"\x1b[32m✓\x1b[0m"
|
||||||
|
}
|
||||||
|
fn cross() -> &'static str {
|
||||||
|
"\x1b[31m✗\x1b[0m"
|
||||||
|
}
|
||||||
|
fn err() -> &'static str {
|
||||||
|
"\x1b[31merror:\x1b[0m"
|
||||||
|
}
|
||||||
@@ -6,6 +6,6 @@ from **openclaw-browser-relay** by chengyixu
|
|||||||
|
|
||||||
Changes for chrome-use: rebranded to "chrome-use connect"; the
|
Changes for chrome-use: rebranded to "chrome-use connect"; the
|
||||||
transport is rewritten from a localhost WebSocket + shared token to Chrome
|
transport is rewritten from a localhost WebSocket + shared token to Chrome
|
||||||
**native messaging** (host `com.leeguoo.chrome_use`) — no port, no token,
|
**native messaging** (host `com.agent_browser.connect`) — no port, no token,
|
||||||
Chrome authenticates the extension to the host by id. WebSocket/token/options
|
Chrome authenticates the extension to the host by id. WebSocket/token/options
|
||||||
code removed.
|
code removed.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
// attach + Target handling; the transport is rewritten from WebSocket+token to
|
// attach + Target handling; the transport is rewritten from WebSocket+token to
|
||||||
// native messaging.
|
// native messaging.
|
||||||
|
|
||||||
const HOST_NAME = 'com.leeguoo.chrome_use'
|
const HOST_NAME = 'com.agent_browser.connect'
|
||||||
const SKIP_URL = /^(chrome|chrome-extension|devtools|chrome-untrusted|edge|about):/i
|
const SKIP_URL = /^(chrome|chrome-extension|devtools|chrome-untrusted|edge|about):/i
|
||||||
|
|
||||||
/** @type {chrome.runtime.Port|null} */
|
/** @type {chrome.runtime.Port|null} */
|
||||||
@@ -24,7 +24,6 @@ let port = null
|
|||||||
/** Whether the native-messaging host (the local chrome-use CLI) is linked.
|
/** Whether the native-messaging host (the local chrome-use CLI) is linked.
|
||||||
* Read by the popup status page. */
|
* Read by the popup status page. */
|
||||||
let hostConnected = false
|
let hostConnected = false
|
||||||
let nextSession = 1
|
|
||||||
/** tabId -> { sessionId, targetId } */
|
/** tabId -> { sessionId, targetId } */
|
||||||
const tabs = new Map()
|
const tabs = new Map()
|
||||||
/** sessionId -> tabId (main session per tab) */
|
/** sessionId -> tabId (main session per tab) */
|
||||||
@@ -261,7 +260,16 @@ async function attachTab(tabId) {
|
|||||||
const targetInfo = info?.targetInfo
|
const targetInfo = info?.targetInfo
|
||||||
const targetId = String(targetInfo?.targetId || '')
|
const targetId = String(targetInfo?.targetId || '')
|
||||||
if (!targetId) throw new Error('attachTab: no 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 }
|
const entry = { sessionId, targetId }
|
||||||
tabs.set(tabId, entry)
|
tabs.set(tabId, entry)
|
||||||
sessionToTab.set(sessionId, tabId)
|
sessionToTab.set(sessionId, tabId)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "chrome-use",
|
"name": "chrome-use",
|
||||||
"version": "0.5.0",
|
"version": "0.4.4",
|
||||||
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
"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",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||||
"icons": {
|
"icons": {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Chrome Web Store 提交指南</h1>
|
<h1>Chrome Web Store 提交指南</h1>
|
||||||
<div class="sub">chrome-use · 上传包 <code>extensions/ab-connect.zip</code> · id 锁定为 <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code></div>
|
<div class="sub">chrome-use · <strong>更新现有商店条目</strong> <code>knfcmbamhjmaonkfnjhldjedeobeafmk</code> · 上传 <strong>key 已删</strong> 的包(纯改名,保住老用户/评分)</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p>为什么必须走商店:实测 Chrome 149 在<strong>非企业托管</strong>的 Mac 上,会把"非 Web Store"的 force-install 扩展直接标成 <code>[BLOCKED]</code>。商店扩展不受此限。这也是 codex / claude 扩展都发商店的原因。</p>
|
<p>为什么必须走商店:实测 Chrome 149 在<strong>非企业托管</strong>的 Mac 上,会把"非 Web Store"的 force-install 扩展直接标成 <code>[BLOCKED]</code>。商店扩展不受此限。这也是 codex / claude 扩展都发商店的原因。</p>
|
||||||
@@ -44,11 +44,15 @@
|
|||||||
<li>(隐私政策需要一个公开 URL,见第四节 —— 我可以帮你开 GitHub Pages 托管 <code>privacy.html</code>)</li>
|
<li>(隐私政策需要一个公开 URL,见第四节 —— 我可以帮你开 GitHub Pages 托管 <code>privacy.html</code>)</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h2>二、上传</h2>
|
<h2>二、上传(更新现有条目,纯改名)</h2>
|
||||||
|
<p>你已经有一个上架条目(原名 <em>agent-browser-stealth</em>,Item ID <code>knfcmbamhjmaonkfnjhldjedeobeafmk</code>)。这次只是把它<strong>改名成 chrome-use</strong>,所以走 <span class="field">更新版本</span>,<u>不要</u> New item —— 这样老用户自动更新、评分/安装量都保留。</p>
|
||||||
<ol>
|
<ol>
|
||||||
<li>devconsole → <span class="field">New item</span> → 上传 <code>extensions/ab-connect.zip</code></li>
|
<li>devconsole → 打开 <strong>现有的 agent-browser-stealth 条目</strong>(id <code>knfcmbamhjmaonkfnjhldjedeobeafmk</code>)→ <span class="field">Package → Upload new package</span>。</li>
|
||||||
<li>上传后确认分配到的 Item ID = <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>(因为 manifest 里保留了 <code>key</code>,id 会被锁成这个,native messaging 的 allowed_origins 才对得上)。<strong>若 id 不是这个,告诉我,我重签。</strong></li>
|
<li>上传 <strong>key 已删</strong> 的包 <code>chrome-use-store-vX.Y.Z.zip</code>(<em>必须删掉 manifest 的 <code>key</code> 字段</em>,否则商店报"key 字段不符";仓库里 <code>ab-connect/manifest.json</code> 带 key 是给本地 Load-unpacked 用的,别直接传那个)。上传后 Item ID <strong>保持 <code>knfcmbam…</code> 不变</strong>;用户看到的扩展名变成 <strong>chrome-use</strong>。</li>
|
||||||
|
<li>native messaging 的 <code>allowed_origins</code> 同时放行 <code>knfcmbam…</code> 和 <code>ciiljdl…</code> 两个 id,所以改名后 relay 照常连得上,<strong>不会断现有用户</strong>。</li>
|
||||||
|
<li><strong>不要</strong>在这次发布里改 <code>background.js</code> 的 native host 名(保持 <code>com.agent_browser.connect</code>);<code>com.leeguoo.chrome_use</code> 是给将来真迁移用的。</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
<div class="warn"><strong>若你确实想另开一个全新的 "chrome-use" 条目(新 id、评分清零、用户需重装)</strong>:那才用保留 key 的包,id 会锁成 <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>。仅在你想彻底脱离旧 <em>stealth</em> 品牌时才这么做 —— 默认按上面"更新现有条目"走。</div>
|
||||||
|
|
||||||
<h2>三、商店信息(直接复制以下文案)</h2>
|
<h2>三、商店信息(直接复制以下文案)</h2>
|
||||||
|
|
||||||
@@ -114,8 +118,12 @@ automate pages the user is working with, entirely on the user's machine and at t
|
|||||||
<pre>https://leeguooooo.github.io/chrome-use/extensions/store/privacy.html</pre>
|
<pre>https://leeguooooo.github.io/chrome-use/extensions/store/privacy.html</pre>
|
||||||
<p>(部署需 1–2 分钟生效。raw 备用直链:<code>https://raw.githubusercontent.com/leeguooooo/chrome-use/main/extensions/store/privacy.html</code>。)</p>
|
<p>(部署需 1–2 分钟生效。raw 备用直链:<code>https://raw.githubusercontent.com/leeguooooo/chrome-use/main/extensions/store/privacy.html</code>。)</p>
|
||||||
|
|
||||||
<h2>六、截图 / Screenshots(至少 1 张,1280×800 或 640×400)</h2>
|
<h2>六、图标 + 截图 / Icon & Screenshots</h2>
|
||||||
<p>可以截一张 CLI + Chrome 并排的演示图。<em>需要的话我用 cua-driver 截一张合规尺寸的图给你。</em></p>
|
<p><strong>已生成,涂鸦风(和 cookie-use README 同一套)。</strong>上传到对应字段即可:</p>
|
||||||
|
<ul>
|
||||||
|
<li><span class="field">Store icon(128×128)</span>:<code>chrome-use-store-icon-128.png</code></li>
|
||||||
|
<li><span class="field">Screenshots(每张正好 1280×800)</span>:<code>chrome-use-store-shot1-1280x800.png</code>(CMD 牵线操控已登录浏览器)、<code>shot2</code>(机械臂抓浏览器方向盘)、<code>shot3</code>(浏览器插线连终端 CONNECTED)。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h2>七、提交后</h2>
|
<h2>七、提交后</h2>
|
||||||
<ol>
|
<ol>
|
||||||
@@ -127,6 +135,6 @@ automate pages the user is working with, entirely on the user's machine and at t
|
|||||||
<strong>今天的临时可用方案:</strong> 在你这台 Mac 上 <code>chrome://extensions</code> → 打开开发者模式 → Load unpacked → 选 <code>extensions/ab-connect</code>,30 秒手动装一次,native messaging + <code>extension connect</code> 立即可用。等商店过审再切静默路径。
|
<strong>今天的临时可用方案:</strong> 在你这台 Mac 上 <code>chrome://extensions</code> → 打开开发者模式 → Load unpacked → 选 <code>extensions/ab-connect</code>,30 秒手动装一次,native messaging + <code>extension connect</code> 立即可用。等商店过审再切静默路径。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>chrome-use · 提交包与文案随扩展版本更新;改扩展后重跑 <code>scripts/pack-extension.sh</code> 并重打 <code>ab-connect.zip</code>。</footer>
|
<footer>chrome-use · 更新现有条目 <code>knfcmbam…</code>(纯改名);上传包必须删 key。改扩展后重打 key-stripped 的 <code>chrome-use-store-vX.Y.Z.zip</code> 再传。</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "chrome-use",
|
"name": "chrome-use",
|
||||||
"version": "1.0.0",
|
"version": "1.2.1",
|
||||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
|
|||||||
@@ -240,7 +240,11 @@ chrome-use pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
|
|||||||
# (no silent no-op). Use this for custom
|
# (no silent no-op). Use this for custom
|
||||||
# dropdowns where `select` returns ✓ but
|
# dropdowns where `select` returns ✓ but
|
||||||
# changes nothing.
|
# changes nothing.
|
||||||
chrome-use upload @e5 file1.pdf # upload file(s)
|
chrome-use upload @e5 file1.pdf # upload file(s) — NOTE: needs a --launch/direct-CDP
|
||||||
|
# session. Over the extension relay it CANNOT work
|
||||||
|
# (Chrome's chrome.debugger forbids it); chrome-use
|
||||||
|
# errors with a hint. Carry your login into a launched
|
||||||
|
# session via `cookies export` | `cookies set --curl`.
|
||||||
chrome-use scroll down 500 # scroll page (up/down/left/right)
|
chrome-use scroll down 500 # scroll page (up/down/left/right)
|
||||||
chrome-use scrollintoview @e1 # scroll element into view
|
chrome-use scrollintoview @e1 # scroll element into view
|
||||||
chrome-use drag @e1 @e2 # drag and drop
|
chrome-use drag @e1 @e2 # drag and drop
|
||||||
@@ -668,6 +672,8 @@ and [references/authentication.md](references/authentication.md).
|
|||||||
`chrome-use skills get electron`
|
`chrome-use skills get electron`
|
||||||
- **Slack workspace automation**: `chrome-use skills get slack`
|
- **Slack workspace automation**: `chrome-use skills get slack`
|
||||||
- **Exploratory testing / QA / bug hunts**: `chrome-use skills get dogfood`
|
- **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`
|
- **Vercel Sandbox microVMs**: `chrome-use skills get vercel-sandbox`
|
||||||
- **AWS Bedrock AgentCore cloud browser**: `chrome-use skills get agentcore`
|
- **AWS Bedrock AgentCore cloud browser**: `chrome-use skills get agentcore`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: test
|
||||||
|
description: Write and run re-runnable, unit-test-style browser test suites with `chrome-use test <suite.yaml>`. Use when repetitive manual browser checks (does the page load logged in? is this element there? did the flow work?) should become a fixed, repeatable regression suite instead of being re-done by hand each time — frontend automated testing on top of chrome-use.
|
||||||
|
---
|
||||||
|
|
||||||
|
# chrome-use test — browser test suites
|
||||||
|
|
||||||
|
Turn the repetitive "open it, click around, check it's right" work into a
|
||||||
|
**re-runnable suite**, like unit tests for the frontend. Every time you find a
|
||||||
|
regression, add a case — the suite gets more valuable the more you use it.
|
||||||
|
|
||||||
|
```
|
||||||
|
chrome-use test <suite.yaml> [--launch | --session <name>] [--json]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Exit code **0** if all cases pass, **1** if any fail → drop it straight into CI.
|
||||||
|
- Default: launches a fresh isolated browser (deterministic, repeatable) in a
|
||||||
|
`cu-test` session and closes it after. Pass `--session <name>` to run against an
|
||||||
|
already-connected session (e.g. the live Chrome via `chrome-use extension connect`).
|
||||||
|
- Failed cases auto-save a screenshot to `cu-test-artifacts/<case>.png`.
|
||||||
|
|
||||||
|
## Suite format (YAML)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
suite: chatgpt smoke # label (optional)
|
||||||
|
setup: # runs once before all cases (optional)
|
||||||
|
- account: chatgpt/huayue # inject a cookie-use stored login (optional)
|
||||||
|
- open: https://chatgpt.com/ # …or any normal step
|
||||||
|
cases:
|
||||||
|
- name: home loads logged in
|
||||||
|
steps: # steps reuse chrome-use's own commands
|
||||||
|
- open: https://chatgpt.com/
|
||||||
|
- wait: { load: networkidle }
|
||||||
|
assert: # all asserts must hold or the case fails
|
||||||
|
- url: { contains: chatgpt.com }
|
||||||
|
- visible: "#prompt-textarea"
|
||||||
|
- name: composer takes text
|
||||||
|
steps:
|
||||||
|
- fill: { sel: "#prompt-textarea", text: "hi" }
|
||||||
|
assert:
|
||||||
|
- text: { sel: "#prompt-textarea", contains: hi }
|
||||||
|
- eval: "!!window.__NEXT_DATA__"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps (the verbs)
|
||||||
|
|
||||||
|
Each step is a one-key mapping; the key is a chrome-use command:
|
||||||
|
|
||||||
|
| Step | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `open: <url>` | navigate |
|
||||||
|
| `click: <selector\|@ref>` | click |
|
||||||
|
| `fill: { sel: <s>, text: <t> }` | clear + type |
|
||||||
|
| `type: { sel: <s>, text: <t> }` | type (no clear) |
|
||||||
|
| `press: <key>` | key press (e.g. `Enter`) |
|
||||||
|
| `wait: <ms>` / `wait: { load: networkidle }` / `wait: <selector>` | wait |
|
||||||
|
| `scroll: <up\|down\|...>` or `{ dir: down, px: 500 }` | scroll |
|
||||||
|
| `eval: "<js>"` | run JS |
|
||||||
|
|
||||||
|
## Assertions (the checks) — all compile to one truthy `eval`
|
||||||
|
|
||||||
|
| Assert | Passes when |
|
||||||
|
|---|---|
|
||||||
|
| `url: { contains\|equals\|matches: <v> }` | the page URL matches |
|
||||||
|
| `visible: <selector>` | element exists and is laid out |
|
||||||
|
| `hidden: <selector>` | element is absent / not laid out |
|
||||||
|
| `text: { sel: <s>, contains\|equals\|matches: <v> }` | element text matches |
|
||||||
|
| `count: { sel: <s>, eq: <n> }` | exactly N elements match |
|
||||||
|
| `eval: "<js>"` | the JS expression is truthy |
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
`setup: - account: <id>` injects a [cookie-use](https://github.com/leeguooooo/cookie-use)
|
||||||
|
stored login into the test session, so the suite runs authenticated. (Needs
|
||||||
|
`cookie-use` installed; skip the line if you don't use it.)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Do the check once by hand with `open`/`snapshot`/`eval` to learn the selectors.
|
||||||
|
2. Write it up as a case in a `*.yaml` suite.
|
||||||
|
3. `chrome-use test suite.yaml` — green means it works; red shows the failing
|
||||||
|
assert + a screenshot.
|
||||||
|
4. Found a regression later? Add a case. Run the whole suite in CI.
|
||||||
|
|
||||||
|
## Limits (v1)
|
||||||
|
|
||||||
|
Assertions are evaluated independently after the steps run. No per-case retries,
|
||||||
|
no parallel cases, no snapshot/screenshot baseline diffing yet (use an `eval`
|
||||||
|
assert against known content for now). Steps run sequentially; a failing step
|
||||||
|
fails the case immediately.
|
||||||