Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ba43e0cbd | ||
|
|
d740884299 | ||
|
|
db484f2ac9 | ||
|
|
b475038e25 | ||
|
|
545e2545b4 | ||
|
|
5f342e34a2 | ||
|
|
4106a151a1 | ||
|
|
eb60053183 | ||
|
|
2aa216dd7a | ||
|
|
b6febbef39 |
@@ -75,3 +75,4 @@ out/
|
||||
# extension signing key (never commit) + local-only id record
|
||||
.secrets/
|
||||
*.pem
|
||||
/cu-test-artifacts
|
||||
|
||||
Generated
+21
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -312,6 +312,7 @@ dependencies = [
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"similar",
|
||||
@@ -1982,6 +1983,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -2419,6 +2433,12 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrome-use"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
@@ -45,6 +45,7 @@ sha1 = "0.10"
|
||||
chrono = "0.4"
|
||||
urlencoding = "2"
|
||||
rust-embed = "8"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
+2
-2
@@ -1328,11 +1328,11 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
usage: "cookies transfer --from <profile> [--domain <domain>]",
|
||||
});
|
||||
}
|
||||
return Ok(json!({
|
||||
Ok(json!({
|
||||
"id": id,
|
||||
"action": "cookies_set",
|
||||
"cookies": cookies,
|
||||
}));
|
||||
}))
|
||||
}
|
||||
"set" => {
|
||||
// --curl <file> mode: import cookies from a JSON array,
|
||||
|
||||
+58
-27
@@ -16,8 +16,18 @@ use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Native-messaging host name; must match `HOST_NAME` in the extension and the
|
||||
/// manifest filename.
|
||||
pub const HOST_NAME: &str = "com.leeguoo.chrome_use";
|
||||
/// manifest filename. `com.agent_browser.connect` is the original name, used by
|
||||
/// 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
|
||||
/// 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 manifest = serde_json::json!({
|
||||
"name": HOST_NAME,
|
||||
"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())?;
|
||||
|
||||
// Write a manifest under EVERY accepted host name (both point to the same
|
||||
// launcher + allowed extensions), so any extension version's
|
||||
// `connectNative(<its host name>)` finds a matching host json.
|
||||
let mut written = Vec::new();
|
||||
for dir in native_messaging_dirs() {
|
||||
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())?;
|
||||
let path = dir.join(format!("{HOST_NAME}.json"));
|
||||
std::fs::write(&path, &body).map_err(|e| e.to_string())?;
|
||||
written.push(path.display().to_string());
|
||||
for host in HOST_NAMES {
|
||||
let manifest = serde_json::json!({
|
||||
"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() {
|
||||
return Err("no Chrome/Chromium NativeMessagingHosts directory found".into());
|
||||
@@ -289,9 +303,11 @@ fn remove_force_install_profile() -> bool {
|
||||
fn remove_host_manifests() -> usize {
|
||||
let mut n = 0;
|
||||
for dir in native_messaging_dirs() {
|
||||
let path = dir.join(format!("{HOST_NAME}.json"));
|
||||
if path.exists() && std::fs::remove_file(&path).is_ok() {
|
||||
n += 1;
|
||||
for host in HOST_NAMES {
|
||||
let path = dir.join(format!("{host}.json"));
|
||||
if path.exists() && std::fs::remove_file(&path).is_ok() {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
@@ -334,7 +350,7 @@ fn native_messaging_dirs() -> Vec<PathBuf> {
|
||||
fn host_manifest_path_for_chrome() -> Option<PathBuf> {
|
||||
native_messaging_dirs()
|
||||
.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())
|
||||
.or_else(|| {
|
||||
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
|
||||
/// the chosen path.
|
||||
pub fn host_installed() -> bool {
|
||||
native_messaging_dirs()
|
||||
.into_iter()
|
||||
.any(|d| d.join(format!("{HOST_NAME}.json")).exists())
|
||||
native_messaging_dirs().into_iter().any(|d| {
|
||||
HOST_NAMES
|
||||
.iter()
|
||||
.any(|h| d.join(format!("{h}.json")).exists())
|
||||
})
|
||||
}
|
||||
|
||||
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).
|
||||
///
|
||||
/// 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 {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".chrome-use").join("relay-cdp-url"))
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp/ab-relay-cdp-url"))
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
for base in [".chrome-use", ".agent-browser"] {
|
||||
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
|
||||
|
||||
+36
-5
@@ -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.
|
||||
/// 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 {
|
||||
// 1. Explicit override (ignore empty string)
|
||||
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)
|
||||
if let Ok(runtime_dir) = env::var("XDG_RUNTIME_DIR") {
|
||||
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/)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".chrome-use");
|
||||
if dirs::home_dir().is_some() {
|
||||
return config_home();
|
||||
}
|
||||
|
||||
// 4. Last resort: temp dir
|
||||
env::temp_dir().join("chrome-use")
|
||||
env::temp_dir().join(config_dir_basename(false))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
+31
-3
@@ -11,6 +11,7 @@ mod install;
|
||||
mod native;
|
||||
mod output;
|
||||
mod skills;
|
||||
mod test_runner;
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
mod upgrade;
|
||||
@@ -269,13 +270,19 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
.into_iter()
|
||||
.map(|s| s.name)
|
||||
.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 {
|
||||
println!(
|
||||
r#"{{"success":true,"data":{{"sessions":{}}}}}"#,
|
||||
serde_json::to_string(&sessions).unwrap_or_default()
|
||||
r#"{{"success":true,"data":{{"sessions":{},"relay":{}}}}}"#,
|
||||
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");
|
||||
} else {
|
||||
println!("Active sessions:");
|
||||
@@ -287,6 +294,14 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
};
|
||||
println!("{} {}", marker, s);
|
||||
}
|
||||
if relay_up && !sessions.iter().any(|s| s == session) {
|
||||
println!(
|
||||
"{} {} {}",
|
||||
color::cyan("→"),
|
||||
session,
|
||||
color::dim("(relay/extension → live Chrome)")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None | Some(_) => {
|
||||
@@ -707,6 +722,19 @@ fn main() {
|
||||
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)
|
||||
if clean.first().map(|s| s.as_str()) == Some("skills") {
|
||||
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 }))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let annotate = cmd
|
||||
.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))?;
|
||||
std::fs::write(p, bytes)
|
||||
.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!(
|
||||
"/tmp/screenshot-{}.png",
|
||||
@@ -2976,7 +2985,7 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut response = json!({ "path": result.path });
|
||||
let mut response = json!({ "path": absolutize_saved_path(&result.path) });
|
||||
if !result.annotations.is_empty() {
|
||||
response["annotations"] = serde_json::to_value(&result.annotations)
|
||||
.map_err(|e| format!("Failed to serialize annotations: {}", e))?;
|
||||
|
||||
@@ -1593,7 +1593,25 @@ impl BrowserManager {
|
||||
})),
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
let chrome_path = match &options.executable_path {
|
||||
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.
|
||||
let mut resolved_options: Option<LaunchOptions> = 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 is_chrome_profile_name(profile) {
|
||||
@@ -372,6 +419,7 @@ pub fn launch_chrome(options: &LaunchOptions) -> Result<ChromeProcess, String> {
|
||||
.to_string()
|
||||
})?;
|
||||
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 mut opts = options.clone();
|
||||
@@ -1886,6 +1934,17 @@ mod tests {
|
||||
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]
|
||||
fn test_is_chrome_profile_name_paths() {
|
||||
assert!(!is_chrome_profile_name("/tmp/dir"));
|
||||
|
||||
@@ -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
|
||||
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
|
||||
code removed.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// attach + Target handling; the transport is rewritten from WebSocket+token to
|
||||
// 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
|
||||
|
||||
/** @type {chrome.runtime.Port|null} */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "chrome-use",
|
||||
"version": "0.5.0",
|
||||
"version": "0.4.3",
|
||||
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||
"icons": {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<body>
|
||||
<header>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<li>devconsole → <span class="field">New item</span> → 上传 <code>extensions/ab-connect.zip</code></li>
|
||||
<li>上传后确认分配到的 Item ID = <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>(因为 manifest 里保留了 <code>key</code>,id 会被锁成这个,native messaging 的 allowed_origins 才对得上)。<strong>若 id 不是这个,告诉我,我重签。</strong></li>
|
||||
<li>devconsole → 打开 <strong>现有的 agent-browser-stealth 条目</strong>(id <code>knfcmbamhjmaonkfnjhldjedeobeafmk</code>)→ <span class="field">Package → Upload new package</span>。</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>
|
||||
<div class="warn"><strong>若你确实想另开一个全新的 "chrome-use" 条目(新 id、评分清零、用户需重装)</strong>:那才用保留 key 的包,id 会锁成 <code>ciiljdlhdpfckdcfkphgmfalanpdejep</code>。仅在你想彻底脱离旧 <em>stealth</em> 品牌时才这么做 —— 默认按上面"更新现有条目"走。</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<p>可以截一张 CLI + Chrome 并排的演示图。<em>需要的话我用 cua-driver 截一张合规尺寸的图给你。</em></p>
|
||||
<h2>六、图标 + 截图 / Icon & Screenshots</h2>
|
||||
<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>
|
||||
<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> 立即可用。等商店过审再切静默路径。
|
||||
</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>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chrome-use",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||
"type": "module",
|
||||
"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
|
||||
# dropdowns where `select` returns ✓ but
|
||||
# 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 scrollintoview @e1 # scroll element into view
|
||||
chrome-use drag @e1 @e2 # drag and drop
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user