Compare commits

...
10 Commits
Author SHA1 Message Date
leeguooooo 9ba43e0cbd chore(release): 1.2.0 — chrome-use test (browser test suites)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-12 17:27:38 +09:00
leeguooooo d740884299 feat(test): chrome-use test <suite.yaml> — re-runnable browser test suites
Turn repetitive browser checks into unit-test-style YAML suites. Steps reuse
chrome-use's own commands; assertions (url/visible/hidden/text/count/eval)
compile to a single truthy `eval`. The runner re-invokes the binary per step
(inherits all flag/launch/daemon/ref semantics; the daemon stays up so each
step is a fast socket call), launches an isolated browser by default, captures a
screenshot on failure, and exits non-zero for CI. `setup: account:` injects a
cookie-use login. Ships a `test` skill (skills get test). Unit-tested step/assert
compilation.
2026-06-12 17:27:38 +09:00
leeguooooo db484f2ac9 fix(cli): absolute screenshot path (#16) + show relay in session list (#15)
#16: handle_screenshot now returns a canonicalized ABSOLUTE path, so the
`✓ Screenshot saved to …` line is the same regardless of process cwd and the
agent can read the file without guessing the cwd.

#15: `session list` now reflects the extension-relay connection — when the relay
is up it shows the active session as `(relay/extension → live Chrome)` instead
of "No active sessions", and the --json output gains a `relay` bool. Stops agents
misjudging a live relay connection as down.
2026-06-12 16:43:39 +09:00
leeguooooo b475038e25 fix(upload): actionable error when file upload hits the extension-relay limit (#13)
DOM.setFileInputFiles is forbidden by Chrome's chrome.debugger API, so upload
always fails over the extension relay with an opaque -32000 "Not allowed". Map
it to a clear message: file upload needs a --launch/direct-CDP session, and
point at the cookies export|set --curl workaround. Note the limit in the core
skill upload line too.
2026-06-12 16:09:55 +09:00
leeguooooo 545e2545b4 fix(cookies): drop needless return in transfer arm (clippy -D warnings, CI red)
The cookies transfer arm's tail `return Ok(...)` tripped clippy::needless_return,
failing the CI lint gate (-D warnings). Make it a tail expression.
2026-06-12 15:49:16 +09:00
leeguooooo 5f342e34a2 docs(store): rewrite submission guide for rename-existing-item flow
The CWS rename to chrome-use updates the EXISTING item (knfcmbam…) with a
key-stripped package, not a new key-locked item — existing users auto-update and
reviews are kept. Native host allow_origins already lists both ids so the relay
doesn't break. Also point the icon/screenshots section at the generated assets.
2026-06-12 15:43:43 +09:00
leeguooooo 4106a151a1 feat(connect): install + recognize BOTH native-messaging host names (staged extension migration)
Stage 1 of the agent-browser → chrome-use extension migration: the CLI now
writes a host manifest under both com.agent_browser.connect (extension ≤0.4.2)
AND com.leeguoo.chrome_use (the rebrand 0.5.0+), both pointing at the same
launcher, and host_installed()/uninstall recognize both. So the relay works no
matter which extension version a user has, with no forced re-install — which
lets the store roll 0.4.3 (cosmetic name only, host unchanged) and later 0.5.0
(new host) without ever breaking the relay or re-popping the consent dialog.
2026-06-12 14:50:42 +09:00
leeguooooo eb60053183 fix(launch): serialize concurrent same-profile launches (issue #11)
N parallel `open --profile <same>` (e.g. chatgpt-imagegen's web backend firing
3 image gens at once) collided on the profile-copy disk I/O and Chrome's profile
lock: every candidate burned its full ~30s launch timeout and ALL failed (0
success), because the loser instances hung without writing DevToolsActivePort.

ProfileLaunchLock takes a cross-process flock on a per-resolved-profile lock
file, held across the copy + launch until Chrome is up, so concurrent
same-profile launches queue instead of colliding — the storm becomes
all-succeed-serially instead of all-fail. The kernel releases the lock when the
holder exits, so a crash can't wedge the queue; acquisition is best-effort
(launch proceeds unlocked if it can't be taken). Uncontended single launches
are unaffected.
2026-06-12 14:40:03 +09:00
leeguooooo 2aa216dd7a fix(config): brand-compat config dir (~/.chrome-use ⇄ ~/.agent-browser) so the relay survives the rename
After the agent-browser → chrome-use rename, the new binary used ~/.chrome-use
+ host com.leeguoo.chrome_use and couldn't find the relay that the still-old
native-messaging host wrote to ~/.agent-browser → it fell back to raw
--remote-debugging-port and re-popped 'Allow remote debugging?'.

- config_home()/config_dir_basename(): 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. get_socket_dir() routes
  through it so sockets/state are consistent within a run.
- relay_url_path(): the relay-cdp-url is a cross-binary handoff (host writes,
  CLI reads), so read from whichever brand dir actually has the file
  (~/.chrome-use OR ~/.agent-browser).

Combined with keeping HOST_NAME=com.agent_browser.connect (b6febbe), the renamed
chrome-use binary now relays through the existing ab-connect 0.4.2 extension
with zero dialog. Verified live: chrome-use found ~/.agent-browser/relay-cdp-url
and listed the user's real tabs, no consent dialog.
2026-06-12 14:29:21 +09:00
leeguooooo b6febbef39 fix(connect): keep native-messaging host as com.agent_browser.connect (no relay break)
Reverting the host-name rename from the chrome-use rebrand. The host name is
invisible internal plumbing (lives only in NativeMessagingHosts/*.json and the
extension), so renaming it to com.leeguoo.chrome_use bought nothing user-facing
but broke the relay for every existing user: the new chrome-use binary couldn't
find a matching host/extension, silently fell back to raw --remote-debugging-port,
and re-popped the 'Allow remote debugging?' consent dialog.

Keeping com.agent_browser.connect means the renamed chrome-use binary keeps
working with the already-installed host json and the live ab-connect 0.4.2
extension — zero relay break, no dialog, and the store republish becomes an
OPTIONAL cosmetic display-name update (manifest bumped 0.5.0 → 0.4.3, name stays
chrome-use). Only the binary/command name changed for users.
2026-06-12 14:07:10 +09:00
18 changed files with 876 additions and 54 deletions
+1
View File
@@ -75,3 +75,4 @@ out/
# extension signing key (never commit) + local-only id record
.secrets/
*.pem
/cu-test-artifacts
+21 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+11 -2
View File
@@ -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))?;
+19 -1
View File
@@ -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(())
}
+59
View File
@@ -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"));
+522
View File
@@ -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"
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 -1
View File
@@ -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": {
+15 -7
View File
@@ -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 &amp; 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
View File
@@ -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",
+5 -1
View File
@@ -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
+90
View File
@@ -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.