diff --git a/README.ja.md b/README.ja.md index 5af6a28..84d5370 100644 --- a/README.ja.md +++ b/README.ja.md @@ -121,6 +121,43 @@ cp target/release/autocli /usr/local/bin/ # macOS / Linux > Public モードのコマンド(hackernews、devto、lobsters など)は拡張機能なしで使用できます。 +### 任意: Chrome Tab Group 厳格バックグラウンドモード + +ブラウザコマンドは、デフォルトでは従来どおり automation window を使用します。新しい可視 Chrome ウィンドウを作りたくない場合は、Tab Group 厳格バックグラウンドモードを明示的に有効化できます。 + +```json +{ + "browser": { + "carrier": "tab-group-background", + "groupName": "work", + "groupIdleTimeoutSeconds": 30 + } +} +``` + +環境変数でも上書きできます。 + +```bash +AUTOCLI_BROWSER_CARRIER=tab-group-background +AUTOCLI_BROWSER_GROUP_NAME=work +AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT=30 +``` + +実際のグループ名は `AutoCLI-${groupName}` になり、完全一致する Tab Group があれば再利用します。`groupName` が未指定または空白の場合は `default` になり、`AutoCLI-default` を使用します。このモードでは、新しい可視 Chrome ウィンドウを作らず、AutoCLI タブへ切り替えず、現在のアクティブタブを奪いません。Chrome、拡張機能、または既存の通常 Chrome ウィンドウが利用できない場合は、新規ウィンドウやアクティブ化へのフォールバックをせず、明示的に失敗します。`tabs select` はこのモードでは使用できません。 + +コマンド終了後は、連続コマンドのために同じグループを短時間保持します。アイドルタイムアウト後は、そのグループ内で AutoCLI が作成したタブだけを削除します。元に戻すには、これらの環境変数を削除するか、`carrier` を `automation-window` に戻してください。 + +カスタムローカル adapter でも、コマンドごとにブラウザ carrier を指定できます。この設定はグローバル設定と環境変数を上書きします。 + +```yaml +browser: + carrier: tab-group-background + groupName: work + groupIdleTimeoutSeconds: 30 +``` + +既存の `browser: true` / `browser: false` 構文は引き続き互換です。adapter またはグローバル設定で `tab-group-background` を明示的に選び、有効な `groupName` がない場合は `default` を使用します。 + ## Skill インストール ワンクリックで AI Agent に autocli skill をインストール: diff --git a/README.md b/README.md index ba3a824..e74a060 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,43 @@ Simply re-run the install command or download the latest release to overwrite th > Public mode commands (hackernews, devto, lobsters, etc.) work without the extension. +### Optional: Chrome Tab Group Strict Background Mode + +Browser commands still use the existing automation window by default. To avoid creating a new visible Chrome window, explicitly enable Tab Group strict background mode: + +```json +{ + "browser": { + "carrier": "tab-group-background", + "groupName": "work", + "groupIdleTimeoutSeconds": 30 + } +} +``` + +Environment variables can override the file config: + +```bash +AUTOCLI_BROWSER_CARRIER=tab-group-background +AUTOCLI_BROWSER_GROUP_NAME=work +AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT=30 +``` + +The actual group title is `AutoCLI-${groupName}`; an exact-title Tab Group is reused. If `groupName` is omitted or blank, it defaults to `default`, producing `AutoCLI-default`. This mode strictly avoids new visible Chrome windows, avoids switching to AutoCLI tabs, and avoids stealing the current active tab. If Chrome, the extension, or an existing normal Chrome window is unavailable, the command fails instead of falling back to a new window or an activated tab. `tabs select` is unsupported in this mode. + +After a command ends, the same group is kept briefly for consecutive commands. When it becomes idle, AutoCLI removes only tabs it created in that group. To roll back, unset these environment variables or set `carrier` back to `automation-window`. + +Custom local adapters can also choose the browser carrier per command, overriding global config and environment variables: + +```yaml +browser: + carrier: tab-group-background + groupName: work + groupIdleTimeoutSeconds: 30 +``` + +The existing `browser: true` / `browser: false` syntax remains compatible. If an adapter or global config explicitly selects `tab-group-background` without a valid `groupName`, AutoCLI uses `default`. + ## Skill Install One-click install autocli skill for your AI Agent: diff --git a/README.zh.md b/README.zh.md index 123c338..e5cceee 100644 --- a/README.zh.md +++ b/README.zh.md @@ -121,6 +121,43 @@ cp target/release/autocli /usr/local/bin/ # macOS / Linux > Public 模式命令(hackernews、devto、lobsters 等)无需安装扩展即可使用。 +### 可选:Chrome Tab Group 严格后台模式 + +浏览器命令默认仍使用原有的 automation window。需要避免新建可见 Chrome 窗口时,可以显式启用 Tab Group 严格后台模式: + +```json +{ + "browser": { + "carrier": "tab-group-background", + "groupName": "work", + "groupIdleTimeoutSeconds": 30 + } +} +``` + +也可以用环境变量覆盖: + +```bash +AUTOCLI_BROWSER_CARRIER=tab-group-background +AUTOCLI_BROWSER_GROUP_NAME=work +AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT=30 +``` + +启用后组名统一为 `AutoCLI-${groupName}`,同名 Tab Group 会被复用;未设置或留空 `groupName` 时默认为 `default`,即 `AutoCLI-default`。该模式严格不创建新的可见 Chrome 窗口、不切换到 AutoCLI 标签、不抢当前活动标签;如果 Chrome、扩展或现有普通 Chrome 窗口不可用,会直接报错,不会自动回退到新窗口或激活标签。`tabs select` 在该模式下不可用。 + +命令结束时会短暂保留同一组用于连续命令;空闲超时后只清理 AutoCLI 在该组内创建的标签。回滚到旧行为时,删除这些环境变量或将 `carrier` 改回 `automation-window`。 + +自定义本地 adapter 也可以单独指定浏览器承载方式,且会覆盖全局配置和环境变量: + +```yaml +browser: + carrier: tab-group-background + groupName: work + groupIdleTimeoutSeconds: 30 +``` + +现有写法 `browser: true` / `browser: false` 仍然兼容。若 adapter 或全局配置显式选择 `tab-group-background` 但没有提供有效 `groupName`,默认使用 `default`。 + ## Skill 安装 一键为你的 AI Agent 安装 autocli skill: diff --git a/crates/autocli-browser/src/bridge.rs b/crates/autocli-browser/src/bridge.rs index dc40af4..5a67284 100644 --- a/crates/autocli-browser/src/bridge.rs +++ b/crates/autocli-browser/src/bridge.rs @@ -1,8 +1,9 @@ -use autocli_core::{CliError, IPage}; +use autocli_core::{BrowserOptions, CliError, IPage}; use std::sync::Arc; use std::time::Duration; use tracing::{debug, info, warn}; +use crate::config::{load_browser_config_with_options, BrowserCarrier, BrowserConfig}; use crate::daemon_client::DaemonClient; use crate::page::DaemonPage; @@ -17,11 +18,30 @@ const EXTENSION_POLL_INTERVAL: Duration = Duration::from_millis(500); /// The daemon runs as a detached background process with its own idle-shutdown lifecycle. pub struct BrowserBridge { port: u16, + browser_config: Option, + browser_options: Option, } impl BrowserBridge { pub fn new(port: u16) -> Self { - Self { port } + Self { + port, + browser_config: None, + browser_options: None, + } + } + + pub fn with_browser_config(port: u16, browser_config: BrowserConfig) -> Self { + Self { + port, + browser_config: Some(browser_config), + browser_options: None, + } + } + + pub fn with_browser_options(mut self, browser_options: Option) -> Self { + self.browser_options = browser_options; + self } /// Create a bridge using the default port. @@ -29,6 +49,13 @@ impl BrowserBridge { Self::new(DEFAULT_PORT) } + fn should_wake_chrome_for_extension_recovery(&self) -> bool { + !matches!( + self.browser_config.as_ref().map(|cfg| &cfg.carrier), + Some(BrowserCarrier::TabGroupBackground) + ) + } + /// Connect to the daemon, starting it if necessary, and return a trait-object page. pub async fn connect(&mut self) -> Result, CliError> { Ok(self.connect_daemon_page().await?) @@ -37,6 +64,7 @@ impl BrowserBridge { /// Connect and return the concrete `DaemonPage` so callers can use /// daemon-specific methods (e.g. `read_article`) not on the `IPage` trait. pub async fn connect_daemon_page(&mut self) -> Result, CliError> { + let browser_config = self.browser_config()?; let client = Arc::new(DaemonClient::new(self.port)); // Step 1: Check Chrome is running @@ -61,8 +89,27 @@ impl BrowserBridge { } // Step 3: Wait up to 5s for extension to connect - if self.poll_extension(&client, EXTENSION_INITIAL_WAIT, false).await { - return Ok(Arc::new(DaemonPage::new(client, "default"))); + if self + .poll_extension(&client, EXTENSION_INITIAL_WAIT, false) + .await + { + return Ok(Arc::new(DaemonPage::new_with_browser_config( + client, + "default", + browser_config.clone(), + ))); + } + + if !self.should_wake_chrome_for_extension_recovery() { + return Err(CliError::BrowserConnect { + message: "Chrome extension not connected in strict-background mode".into(), + suggestions: vec![ + "Open an existing normal Chrome window with the AutoCLI extension already enabled".into(), + "Strict background mode will not open Chrome, create a new window, or wake the extension with about:blank".into(), + format!("The daemon is listening on port {}", self.port), + ], + source: None, + }); } // Step 4: Extension not connected — try to wake up Chrome @@ -71,8 +118,15 @@ impl BrowserBridge { wake_chrome(); // Step 5: Wait remaining 25s with progress - if self.poll_extension(&client, EXTENSION_REMAINING_WAIT, true).await { - return Ok(Arc::new(DaemonPage::new(client, "default"))); + if self + .poll_extension(&client, EXTENSION_REMAINING_WAIT, true) + .await + { + return Ok(Arc::new(DaemonPage::new_with_browser_config( + client, + "default", + browser_config.clone(), + ))); } warn!("Chrome extension is not connected to the daemon"); @@ -165,6 +219,18 @@ impl BrowserBridge { READY_TIMEOUT.as_secs() ))) } + + fn browser_config(&mut self) -> Result { + if self.browser_config.is_none() { + self.browser_config = Some(load_browser_config_with_options( + self.browser_options.as_ref(), + )?); + } + Ok(self + .browser_config + .clone() + .unwrap_or_else(BrowserConfig::default)) + } } /// Check if Chrome/Chromium is running as a process. @@ -243,4 +309,24 @@ mod tests { let bridge = BrowserBridge::default_port(); assert_eq!(bridge.port, DEFAULT_PORT); } + + #[test] + fn tab_group_background_does_not_use_chrome_wake_path() { + let bridge = BrowserBridge::with_browser_config( + 19925, + BrowserConfig { + carrier: BrowserCarrier::TabGroupBackground, + group_name: Some("work".to_string()), + group_idle_timeout_seconds: Some(30), + }, + ); + + assert!(!bridge.should_wake_chrome_for_extension_recovery()); + } + + #[test] + fn automation_window_keeps_chrome_wake_path() { + let bridge = BrowserBridge::new(19925); + assert!(bridge.should_wake_chrome_for_extension_recovery()); + } } diff --git a/crates/autocli-browser/src/cdp.rs b/crates/autocli-browser/src/cdp.rs index 3ede670..d0ff737 100644 --- a/crates/autocli-browser/src/cdp.rs +++ b/crates/autocli-browser/src/cdp.rs @@ -1,9 +1,9 @@ use async_trait::async_trait; -use futures::{SinkExt, StreamExt}; use autocli_core::{ AutoScrollOptions, CliError, Cookie, CookieOptions, GotoOptions, IPage, InterceptedRequest, NetworkRequest, ScreenshotOptions, SnapshotOptions, TabInfo, WaitOptions, }; +use futures::{SinkExt, StreamExt}; use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -15,8 +15,10 @@ use tracing::{debug, error}; use crate::dom_helpers; -type WsSink = - futures::stream::SplitSink>, Message>; +type WsSink = futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream>, + Message, +>; /// Direct Chrome DevTools Protocol page client via WebSocket. /// @@ -46,9 +48,7 @@ impl CdpPage { Ok(Message::Text(text)) => { if let Ok(json) = serde_json::from_str::(&text) { if let Some(id) = json.get("id").and_then(|v| v.as_u64()) { - if let Some(tx) = - reader_pending.write().await.remove(&id) - { + if let Some(tx) = reader_pending.write().await.remove(&id) { let _ = tx.send(json); } } else { @@ -215,9 +215,7 @@ impl IPage for CdpPage { } async fn cookies(&self, _options: Option) -> Result, CliError> { - let result = self - .send_cdp("Network.getCookies", json!({})) - .await?; + let result = self.send_cdp("Network.getCookies", json!({})).await?; let cookies_val = result.get("cookies").cloned().unwrap_or(json!([])); let cookies: Vec = serde_json::from_value(cookies_val).unwrap_or_default(); Ok(cookies) @@ -267,10 +265,7 @@ impl IPage for CdpPage { async fn tabs(&self) -> Result, CliError> { let result = self.send_cdp("Target.getTargets", json!({})).await?; - let targets = result - .get("targetInfos") - .cloned() - .unwrap_or(json!([])); + let targets = result.get("targetInfos").cloned().unwrap_or(json!([])); let mut tabs = Vec::new(); if let Some(arr) = targets.as_array() { for t in arr { diff --git a/crates/autocli-browser/src/config.rs b/crates/autocli-browser/src/config.rs new file mode 100644 index 0000000..6f420bb --- /dev/null +++ b/crates/autocli-browser/src/config.rs @@ -0,0 +1,424 @@ +use autocli_core::{BrowserOptions, CliError}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BrowserCarrier { + AutomationWindow, + TabGroupBackground, +} + +impl Default for BrowserCarrier { + fn default() -> Self { + Self::AutomationWindow + } +} + +impl BrowserCarrier { + pub fn as_str(&self) -> &'static str { + match self { + Self::AutomationWindow => "automation-window", + Self::TabGroupBackground => "tab-group-background", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserConfig { + pub carrier: BrowserCarrier, + pub group_name: Option, + pub group_idle_timeout_seconds: Option, +} + +impl Default for BrowserConfig { + fn default() -> Self { + Self { + carrier: BrowserCarrier::AutomationWindow, + group_name: None, + group_idle_timeout_seconds: None, + } + } +} + +pub fn load_browser_config() -> Result { + let file_json = read_default_config_file()?; + let env_pairs = browser_env_pairs(); + resolve_browser_config(file_json.as_deref(), &env_pairs) +} + +pub fn load_browser_config_with_options( + options: Option<&BrowserOptions>, +) -> Result { + let file_json = read_default_config_file()?; + let env_pairs = browser_env_pairs(); + resolve_browser_config_with_options(file_json.as_deref(), &env_pairs, options) +} + +pub fn resolve_browser_config( + file_json: Option<&str>, + env: &[(K, V)], +) -> Result +where + K: AsRef, + V: AsRef, +{ + resolve_browser_config_with_options(file_json, env, None) +} + +pub fn resolve_browser_config_with_options( + file_json: Option<&str>, + env: &[(K, V)], + options: Option<&BrowserOptions>, +) -> Result +where + K: AsRef, + V: AsRef, +{ + let mut cfg = match file_json { + Some(raw) if !raw.trim().is_empty() => parse_file_config(raw)?, + _ => BrowserConfig::default(), + }; + + for (key, value) in env { + apply_env_override(&mut cfg, key.as_ref(), value.as_ref())?; + } + + if let Some(options) = options { + apply_command_options(&mut cfg, options)?; + } + + validate_browser_config(cfg) +} + +fn parse_file_config(raw: &str) -> Result { + let root: Value = serde_json::from_str(raw).map_err(|e| { + CliError::config(format!( + "Invalid ~/.autocli/config.json browser config JSON: {e}" + )) + })?; + let Some(browser) = root.get("browser") else { + return Ok(BrowserConfig::default()); + }; + let Some(browser) = browser.as_object() else { + return Err(CliError::config("browser config must be an object")); + }; + + let carrier = match browser.get("carrier").and_then(Value::as_str) { + Some(value) => parse_carrier(value)?, + None => BrowserCarrier::AutomationWindow, + }; + let group_name = browser + .get("groupName") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let group_idle_timeout_seconds = match browser.get("groupIdleTimeoutSeconds") { + Some(Value::Number(n)) => Some(n.as_u64().filter(|value| *value > 0).ok_or_else(|| { + CliError::config("browser group idle timeout must be a positive integer") + })?), + Some(Value::String(s)) => Some(parse_timeout(s)?), + Some(_) => { + return Err(CliError::config( + "browser.groupIdleTimeoutSeconds must be a positive integer timeout", + )); + } + None => None, + }; + + Ok(BrowserConfig { + carrier, + group_name, + group_idle_timeout_seconds, + }) +} + +fn apply_env_override(cfg: &mut BrowserConfig, key: &str, value: &str) -> Result<(), CliError> { + match key { + "AUTOCLI_BROWSER_CARRIER" => cfg.carrier = parse_carrier(value)?, + "AUTOCLI_BROWSER_GROUP_NAME" => cfg.group_name = Some(value.to_string()), + "AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT" => { + cfg.group_idle_timeout_seconds = Some(parse_timeout(value)?); + } + _ => {} + } + Ok(()) +} + +fn apply_command_options( + cfg: &mut BrowserConfig, + options: &BrowserOptions, +) -> Result<(), CliError> { + if let Some(carrier) = &options.carrier { + cfg.carrier = parse_carrier(carrier)?; + } + if let Some(group_name) = &options.group_name { + cfg.group_name = Some(group_name.clone()); + } + if let Some(timeout) = options.group_idle_timeout_seconds { + cfg.group_idle_timeout_seconds = Some(timeout); + } + Ok(()) +} + +fn validate_browser_config(mut cfg: BrowserConfig) -> Result { + if let Some(name) = cfg.group_name.take() { + let trimmed = name.trim(); + if trimmed.is_empty() { + cfg.group_name = None; + } else if trimmed.contains('\n') || trimmed.contains('\r') { + return Err(CliError::config( + "browser group name cannot contain newlines", + )); + } else { + cfg.group_name = Some(trimmed.to_string()); + } + } + + if cfg.carrier == BrowserCarrier::TabGroupBackground && cfg.group_name.is_none() { + cfg.group_name = Some("default".to_string()); + } + + if cfg.group_idle_timeout_seconds == Some(0) { + return Err(CliError::config( + "browser group idle timeout must be a positive integer", + )); + } + + Ok(cfg) +} + +fn parse_carrier(value: &str) -> Result { + match value.trim() { + "" | "automation-window" => Ok(BrowserCarrier::AutomationWindow), + "tab-group-background" => Ok(BrowserCarrier::TabGroupBackground), + other => Err(CliError::config(format!( + "Unknown browser carrier '{other}'. Expected automation-window or tab-group-background" + ))), + } +} + +fn parse_timeout(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| CliError::config("browser group idle timeout must be a positive integer"))?; + if parsed == 0 { + return Err(CliError::config( + "browser group idle timeout must be a positive integer", + )); + } + Ok(parsed) +} + +fn read_default_config_file() -> Result, CliError> { + let home = match std::env::var("HOME") { + Ok(home) => home, + Err(_) => return Ok(None), + }; + let path = Path::new(&home).join(".autocli").join("config.json"); + match std::fs::read_to_string(path) { + Ok(raw) => Ok(Some(raw)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(CliError::config(format!( + "Failed to read ~/.autocli/config.json: {e}" + ))), + } +} + +fn browser_env_pairs() -> Vec<(String, String)> { + [ + "AUTOCLI_BROWSER_CARRIER", + "AUTOCLI_BROWSER_GROUP_NAME", + "AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT", + ] + .iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| ((*key).to_string(), value)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resolve(file_json: Option<&str>, env: &[(&str, &str)]) -> Result { + resolve_browser_config(file_json, env) + } + + fn resolve_with_options( + file_json: Option<&str>, + env: &[(&str, &str)], + options: Option<&BrowserOptions>, + ) -> Result { + resolve_browser_config_with_options(file_json, env, options) + } + + #[test] + fn defaults_to_automation_window_when_config_is_absent() { + let cfg = resolve(None, &[]).unwrap(); + assert_eq!(cfg.carrier, BrowserCarrier::AutomationWindow); + assert_eq!(cfg.group_name, None); + assert_eq!(cfg.group_idle_timeout_seconds, None); + } + + #[test] + fn reads_browser_group_config_from_existing_config_file() { + let cfg = resolve( + Some( + r#"{ + "llm": { "provider": "openai" }, + "browser": { + "carrier": "tab-group-background", + "groupName": "work", + "groupIdleTimeoutSeconds": 45 + } +}"#, + ), + &[], + ) + .unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("work")); + assert_eq!(cfg.group_idle_timeout_seconds, Some(45)); + } + + #[test] + fn env_overrides_file_config() { + let cfg = resolve( + Some( + r#"{ + "browser": { + "carrier": "automation-window", + "groupName": "file", + "groupIdleTimeoutSeconds": 10 + } +}"#, + ), + &[ + ("AUTOCLI_BROWSER_CARRIER", "tab-group-background"), + ("AUTOCLI_BROWSER_GROUP_NAME", "env"), + ("AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT", "20"), + ], + ) + .unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("env")); + assert_eq!(cfg.group_idle_timeout_seconds, Some(20)); + } + + #[test] + fn command_options_override_env_and_file_config() { + let options = BrowserOptions { + carrier: Some("tab-group-background".to_string()), + group_name: Some("adapter".to_string()), + group_idle_timeout_seconds: Some(30), + }; + let cfg = resolve_with_options( + Some( + r#"{ + "browser": { + "carrier": "automation-window", + "groupName": "file", + "groupIdleTimeoutSeconds": 10 + } +}"#, + ), + &[ + ("AUTOCLI_BROWSER_CARRIER", "automation-window"), + ("AUTOCLI_BROWSER_GROUP_NAME", "env"), + ("AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT", "20"), + ], + Some(&options), + ) + .unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("adapter")); + assert_eq!(cfg.group_idle_timeout_seconds, Some(30)); + } + + #[test] + fn tab_group_mode_defaults_missing_command_group_name() { + let options = BrowserOptions { + carrier: Some("tab-group-background".to_string()), + group_name: None, + group_idle_timeout_seconds: None, + }; + let cfg = resolve_with_options(None, &[], Some(&options)).unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("default")); + } + + #[test] + fn tab_group_mode_defaults_empty_group_name() { + let cfg = resolve( + Some(r#"{ "browser": { "carrier": "tab-group-background", "groupName": " " } }"#), + &[], + ) + .unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("default")); + } + + #[test] + fn tab_group_mode_defaults_missing_file_group_name() { + let cfg = resolve( + Some(r#"{ "browser": { "carrier": "tab-group-background" } }"#), + &[], + ) + .unwrap(); + + assert_eq!(cfg.carrier, BrowserCarrier::TabGroupBackground); + assert_eq!(cfg.group_name.as_deref(), Some("default")); + } + + #[test] + fn rejects_unknown_carrier_without_fallback() { + let err = resolve( + Some(r#"{ "browser": { "carrier": "hidden-window" } }"#), + &[], + ) + .unwrap_err(); + + assert_eq!(err.code(), "CONFIG"); + assert!(err.to_string().contains("carrier")); + } + + #[test] + fn rejects_invalid_idle_timeout_without_fallback() { + let err = resolve( + None, + &[ + ("AUTOCLI_BROWSER_CARRIER", "tab-group-background"), + ("AUTOCLI_BROWSER_GROUP_NAME", "work"), + ("AUTOCLI_BROWSER_GROUP_IDLE_TIMEOUT", "0"), + ], + ) + .unwrap_err(); + + assert_eq!(err.code(), "CONFIG"); + assert!(err.to_string().contains("timeout")); + } + + #[test] + fn rejects_non_positive_numeric_idle_timeout_without_fallback() { + let err = resolve( + Some( + r#"{ "browser": { "carrier": "tab-group-background", "groupName": "work", "groupIdleTimeoutSeconds": -1 } }"#, + ), + &[], + ) + .unwrap_err(); + + assert_eq!(err.code(), "CONFIG"); + assert!(err.to_string().contains("timeout")); + } +} diff --git a/crates/autocli-browser/src/lib.rs b/crates/autocli-browser/src/lib.rs index 004ac3b..c5dc445 100644 --- a/crates/autocli-browser/src/lib.rs +++ b/crates/autocli-browser/src/lib.rs @@ -1,18 +1,20 @@ // Architecture and protocol design derived from OpenCLI // (https://github.com/jackwener/opencli) by jackwener, Apache-2.0 -pub mod types; +pub mod bridge; +pub mod cdp; +pub mod config; +pub mod daemon; pub mod daemon_client; -pub mod page; pub mod dom_helpers; +pub mod page; pub mod stealth; -pub mod daemon; -pub mod bridge; -pub mod cdp; +pub mod types; pub use bridge::BrowserBridge; -pub use page::DaemonPage; pub use cdp::CdpPage; +pub use config::{BrowserCarrier, BrowserConfig}; pub use daemon::Daemon; pub use daemon_client::DaemonClient; +pub use page::DaemonPage; pub use types::{DaemonCommand, DaemonResult, ReadArticle}; diff --git a/crates/autocli-browser/src/page.rs b/crates/autocli-browser/src/page.rs index 84053c4..cd82150 100644 --- a/crates/autocli-browser/src/page.rs +++ b/crates/autocli-browser/src/page.rs @@ -7,6 +7,7 @@ use serde_json::Value; use std::sync::Arc; use tokio::sync::RwLock; +use crate::config::{BrowserCarrier, BrowserConfig}; use crate::daemon_client::DaemonClient; use crate::dom_helpers; use crate::types::{DaemonCommand, ReadArticle}; @@ -15,6 +16,7 @@ use crate::types::{DaemonCommand, ReadArticle}; pub struct DaemonPage { client: Arc, workspace: String, + browser_config: BrowserConfig, tab_id: RwLock>, } @@ -23,6 +25,20 @@ impl DaemonPage { Self { client, workspace: workspace.into(), + browser_config: BrowserConfig::default(), + tab_id: RwLock::new(None), + } + } + + pub fn new_with_browser_config( + client: Arc, + workspace: impl Into, + browser_config: BrowserConfig, + ) -> Self { + Self { + client, + workspace: workspace.into(), + browser_config, tab_id: RwLock::new(None), } } @@ -30,6 +46,18 @@ impl DaemonPage { /// Build a command with workspace and optional tab_id pre-filled. async fn cmd(&self, action: &str) -> DaemonCommand { let mut c = DaemonCommand::new(action).with_workspace(self.workspace.clone()); + c = c.with_carrier(self.browser_config.carrier.as_str()); + if matches!( + self.browser_config.carrier, + BrowserCarrier::TabGroupBackground + ) { + if let Some(group_name) = &self.browser_config.group_name { + c = c.with_group_name(group_name.clone()); + } + if let Some(timeout) = self.browser_config.group_idle_timeout_seconds { + c = c.with_group_idle_timeout_seconds(timeout); + } + } if let Some(tid) = *self.tab_id.read().await { c = c.with_tab_id(tid); } @@ -54,9 +82,8 @@ impl DaemonPage { pub async fn read_article(&self, url: &str) -> Result { let cmd = self.cmd("read-article").await.with_url(url); let val = self.send(cmd).await?; - serde_json::from_value::(val).map_err(|e| { - CliError::argument(format!("Failed to parse article payload: {e}")) - }) + serde_json::from_value::(val) + .map_err(|e| CliError::argument(format!("Failed to parse article payload: {e}"))) } } @@ -164,8 +191,7 @@ impl IPage for DaemonPage { async fn snapshot(&self, options: Option) -> Result { let opts = options.unwrap_or_default(); - let js = - dom_helpers::snapshot_js(opts.selector.as_deref(), opts.include_hidden); + let js = dom_helpers::snapshot_js(opts.selector.as_deref(), opts.include_hidden); self.eval_js(&js).await } @@ -256,7 +282,10 @@ pub(crate) fn base64_decode_simple(input: &str) -> Vec { } let _ = TABLE; // suppress unused warning - let bytes: Vec = input.bytes().filter(|&b| b != b'=' && b != b'\n' && b != b'\r').collect(); + let bytes: Vec = input + .bytes() + .filter(|&b| b != b'=' && b != b'\n' && b != b'\r') + .collect(); let mut out = Vec::with_capacity(bytes.len() * 3 / 4); for chunk in bytes.chunks(4) { let n = chunk.len(); diff --git a/crates/autocli-browser/src/types.rs b/crates/autocli-browser/src/types.rs index 996722e..e7a7924 100644 --- a/crates/autocli-browser/src/types.rs +++ b/crates/autocli-browser/src/types.rs @@ -15,6 +15,15 @@ pub struct DaemonCommand { pub tab_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub carrier: Option, + #[serde(rename = "groupName", skip_serializing_if = "Option::is_none")] + pub group_name: Option, + #[serde( + rename = "groupIdleTimeoutSeconds", + skip_serializing_if = "Option::is_none" + )] + pub group_idle_timeout_seconds: Option, } impl DaemonCommand { @@ -27,6 +36,9 @@ impl DaemonCommand { workspace: None, tab_id: None, format: None, + carrier: None, + group_name: None, + group_idle_timeout_seconds: None, } } @@ -54,6 +66,21 @@ impl DaemonCommand { self.format = Some(format.into()); self } + + pub fn with_carrier(mut self, carrier: impl Into) -> Self { + self.carrier = Some(carrier.into()); + self + } + + pub fn with_group_name(mut self, group_name: impl Into) -> Self { + self.group_name = Some(group_name.into()); + self + } + + pub fn with_group_idle_timeout_seconds(mut self, seconds: u64) -> Self { + self.group_idle_timeout_seconds = Some(seconds); + self + } } /// Article payload returned by the extension's read-article action. diff --git a/crates/autocli-cli/src/execution.rs b/crates/autocli-cli/src/execution.rs index e326cfe..e8e0a72 100644 --- a/crates/autocli-cli/src/execution.rs +++ b/crates/autocli-cli/src/execution.rs @@ -1,9 +1,9 @@ +use autocli_browser::BrowserBridge; use autocli_core::{CliCommand, CliError, IPage}; use autocli_pipeline::{execute_pipeline, steps::register_all_steps, StepRegistry}; -use autocli_browser::BrowserBridge; use serde_json::Value; -use std::sync::Arc; use std::collections::HashMap; +use std::sync::Arc; /// Get daemon port from env or default fn daemon_port() -> u16 { @@ -56,12 +56,15 @@ async fn execute_command_inner( if cmd.needs_browser() { // Browser session - let mut bridge = BrowserBridge::new(daemon_port()); + let mut bridge = + BrowserBridge::new(daemon_port()).with_browser_options(cmd.browser_options.clone()); let page = bridge.connect().await?; // Pre-navigate to domain if set, but ONLY if the pipeline doesn't // start with its own navigate step (to avoid double navigation). - let pipeline_starts_with_navigate = cmd.pipeline.as_ref() + let pipeline_starts_with_navigate = cmd + .pipeline + .as_ref() .and_then(|steps| steps.first()) .and_then(|step| step.as_object()) .map_or(false, |obj| obj.contains_key("navigate")); @@ -95,7 +98,6 @@ async fn execute_command_inner( } } - async fn run_command( cmd: &CliCommand, page: Option>, diff --git a/crates/autocli-core/src/command.rs b/crates/autocli-core/src/command.rs index 58bc44f..930fac2 100644 --- a/crates/autocli-core/src/command.rs +++ b/crates/autocli-core/src/command.rs @@ -30,6 +30,13 @@ impl Default for NavigateBefore { } } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserOptions { + pub carrier: Option, + pub group_name: Option, + pub group_idle_timeout_seconds: Option, +} + #[derive(Clone)] pub struct CliCommand { pub site: String, @@ -38,6 +45,7 @@ pub struct CliCommand { pub domain: Option, pub strategy: Strategy, pub browser: bool, + pub browser_options: Option, pub args: Vec, pub columns: Vec, pub pipeline: Option>, @@ -58,8 +66,16 @@ impl CliCommand { // Check if pipeline contains browser steps if let Some(ref pipeline) = self.pipeline { const BROWSER_STEPS: &[&str] = &[ - "navigate", "click", "type", "wait", "press", - "evaluate", "snapshot", "screenshot", "intercept", "tap", + "navigate", + "click", + "type", + "wait", + "press", + "evaluate", + "snapshot", + "screenshot", + "intercept", + "tap", ]; for step in pipeline { if let Some(obj) = step.as_object() { diff --git a/crates/autocli-core/src/lib.rs b/crates/autocli-core/src/lib.rs index f07697f..7d97428 100644 --- a/crates/autocli-core/src/lib.rs +++ b/crates/autocli-core/src/lib.rs @@ -1,21 +1,21 @@ // Architecture and protocol design derived from OpenCLI // (https://github.com/jackwener/opencli) by jackwener, Apache-2.0 -mod strategy; mod args; mod command; -mod registry; mod error; mod page; +mod registry; +mod strategy; mod value_ext; -pub use strategy::Strategy; pub use args::{ArgDef, ArgType}; -pub use command::{AdapterFunc, CliCommand, CommandArgs, NavigateBefore}; -pub use registry::Registry; +pub use command::{AdapterFunc, BrowserOptions, CliCommand, CommandArgs, NavigateBefore}; pub use error::CliError; pub use page::{ AutoScrollOptions, Cookie, CookieOptions, GotoOptions, IPage, InterceptedRequest, NetworkRequest, ScreenshotOptions, ScrollDirection, SnapshotOptions, TabInfo, WaitOptions, }; +pub use registry::Registry; +pub use strategy::Strategy; pub use value_ext::ValueExt; diff --git a/crates/autocli-core/src/registry.rs b/crates/autocli-core/src/registry.rs index 6d9c6b9..28b65d8 100644 --- a/crates/autocli-core/src/registry.rs +++ b/crates/autocli-core/src/registry.rs @@ -40,8 +40,7 @@ impl Registry { } pub fn all_commands(&self) -> Vec<&CliCommand> { - let mut cmds: Vec<&CliCommand> = - self.commands.values().flat_map(|s| s.values()).collect(); + let mut cmds: Vec<&CliCommand> = self.commands.values().flat_map(|s| s.values()).collect(); cmds.sort_by(|a, b| (&a.site, &a.name).cmp(&(&b.site, &b.name))); cmds } @@ -68,6 +67,7 @@ mod tests { domain: None, strategy: Strategy::Public, browser: false, + browser_options: None, args: vec![], columns: vec![], pipeline: None, diff --git a/crates/autocli-discovery/src/yaml_parser.rs b/crates/autocli-discovery/src/yaml_parser.rs index c71eacb..7bd9765 100644 --- a/crates/autocli-discovery/src/yaml_parser.rs +++ b/crates/autocli-discovery/src/yaml_parser.rs @@ -1,4 +1,6 @@ -use autocli_core::{ArgDef, ArgType, CliCommand, CliError, NavigateBefore, Strategy}; +use autocli_core::{ + ArgDef, ArgType, BrowserOptions, CliCommand, CliError, NavigateBefore, Strategy, +}; use serde_json::Value; /// Parse a YAML adapter file content into a CliCommand. @@ -31,9 +33,7 @@ pub fn parse_yaml_adapter(content: &str) -> Result { // Parse strategy (default: public) let strategy = match raw.get("strategy").and_then(|v| v.as_str()) { - Some(s) => { - serde_json::from_value(Value::String(s.to_string())).unwrap_or(Strategy::Public) - } + Some(s) => serde_json::from_value(Value::String(s.to_string())).unwrap_or(Strategy::Public), None => Strategy::Public, }; @@ -52,10 +52,9 @@ pub fn parse_yaml_adapter(content: &str) -> Result { .unwrap_or_default(); // Pipeline is stored as-is (Vec) - let pipeline = raw - .get("pipeline") - .and_then(|v| v.as_array()) - .cloned(); + let pipeline = raw.get("pipeline").and_then(|v| v.as_array()).cloned(); + + let (browser, browser_options) = parse_browser_config(&raw, strategy.requires_browser())?; Ok(CliCommand { site, @@ -65,15 +64,10 @@ pub fn parse_yaml_adapter(content: &str) -> Result { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), - domain: raw - .get("domain") - .and_then(|v| v.as_str()) - .map(String::from), + domain: raw.get("domain").and_then(|v| v.as_str()).map(String::from), strategy, - browser: raw - .get("browser") - .and_then(|v| v.as_bool()) - .unwrap_or(strategy.requires_browser()), + browser, + browser_options, args, columns, pipeline, @@ -83,6 +77,76 @@ pub fn parse_yaml_adapter(content: &str) -> Result { }) } +fn parse_browser_config( + raw: &Value, + strategy_requires_browser: bool, +) -> Result<(bool, Option), CliError> { + let Some(browser) = raw.get("browser") else { + return Ok((strategy_requires_browser, None)); + }; + + if let Some(enabled) = browser.as_bool() { + return Ok((enabled, None)); + } + + let Some(obj) = browser.as_object() else { + return Err(CliError::AdapterLoad { + message: "browser must be a boolean or an object".into(), + suggestions: vec![], + source: None, + }); + }; + + let enabled = obj.get("enabled").and_then(Value::as_bool).unwrap_or(true); + if !enabled { + return Ok((false, None)); + } + + let group_idle_timeout_seconds = + match obj.get("groupIdleTimeoutSeconds") { + Some(Value::Number(n)) => { + Some(n.as_u64().filter(|value| *value > 0).ok_or_else(|| { + CliError::AdapterLoad { + message: "browser.groupIdleTimeoutSeconds must be a positive integer" + .into(), + suggestions: vec![], + source: None, + } + })?) + } + Some(Value::String(s)) => { + Some(s.trim().parse::().map_err(|_| CliError::AdapterLoad { + message: "browser.groupIdleTimeoutSeconds must be a positive integer".into(), + suggestions: vec![], + source: None, + })?) + } + Some(_) => { + return Err(CliError::AdapterLoad { + message: "browser.groupIdleTimeoutSeconds must be a positive integer".into(), + suggestions: vec![], + source: None, + }); + } + None => None, + }; + + Ok(( + true, + Some(BrowserOptions { + carrier: obj + .get("carrier") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + group_name: obj + .get("groupName") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + group_idle_timeout_seconds, + }), + )) +} + /// Parse args from YAML map format to Vec fn parse_args(raw: &Value) -> Result, CliError> { let args_val = match raw.get("args") { @@ -157,6 +221,7 @@ pipeline: assert_eq!(cmd.name, "top"); assert_eq!(cmd.strategy, Strategy::Public); assert!(!cmd.browser); + assert_eq!(cmd.browser_options, None); assert_eq!(cmd.args.len(), 1); assert_eq!(cmd.args[0].name, "limit"); assert_eq!(cmd.args[0].arg_type, ArgType::Int); @@ -180,6 +245,40 @@ domain: www.bilibili.com assert_eq!(cmd.domain, Some("www.bilibili.com".to_string())); } + #[test] + fn test_parse_browser_object_config() { + let yaml = r#" +site: example +name: read +description: Read with background group +strategy: public +browser: + carrier: tab-group-background + groupName: work + groupIdleTimeoutSeconds: 30 +"#; + let cmd = parse_yaml_adapter(yaml).unwrap(); + assert!(cmd.browser); + let options = cmd.browser_options.unwrap(); + assert_eq!(options.carrier.as_deref(), Some("tab-group-background")); + assert_eq!(options.group_name.as_deref(), Some("work")); + assert_eq!(options.group_idle_timeout_seconds, Some(30)); + } + + #[test] + fn test_browser_object_can_disable_browser() { + let yaml = r#" +site: example +name: public +description: Public command +browser: + enabled: false +"#; + let cmd = parse_yaml_adapter(yaml).unwrap(); + assert!(!cmd.browser); + assert_eq!(cmd.browser_options, None); + } + #[test] fn test_parse_missing_site_errors() { let yaml = "name: test\n"; diff --git a/extension/manifest.json b/extension/manifest.json index 236b161..fe4bd4f 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -9,7 +9,8 @@ "tabs", "cookies", "activeTab", - "alarms" + "alarms", + "tabGroups" ], "host_permissions": [ "" diff --git a/extension/package-lock.json b/extension/package-lock.json index a511312..2e81adb 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,19 +1,20 @@ { "name": "autocli-extension", - "version": "1.5.5", + "version": "1.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "autocli-extension", - "version": "1.5.5", + "version": "1.5.6", "dependencies": { "@mozilla/readability": "^0.6.0" }, "devDependencies": { "@types/chrome": "^0.0.287", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.9" } }, "node_modules/@esbuild/aix-ppc64": { @@ -458,6 +459,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@mozilla/readability": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", @@ -817,6 +825,24 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/chrome": { "version": "0.0.287", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", @@ -828,6 +854,13 @@ "@types/har-format": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -859,6 +892,153 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -901,6 +1081,26 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -934,6 +1134,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -953,6 +1163,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1047,6 +1278,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1057,6 +1295,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1074,6 +1343,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1162,6 +1441,113 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/extension/package.json b/extension/package.json index 3dcbdfd..6d77e32 100644 --- a/extension/package.json +++ b/extension/package.json @@ -12,7 +12,8 @@ "devDependencies": { "@types/chrome": "^0.0.287", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.9" }, "dependencies": { "@mozilla/readability": "^0.6.0" diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index d05f083..c1cdecf 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -7,6 +7,7 @@ type Listener void> = { addListener: (fn: T) => vo type MockTab = { id: number; windowId: number; + groupId?: number; url?: string; title?: string; active?: boolean; @@ -31,14 +32,19 @@ class MockWebSocket { function createChromeMock() { let nextTabId = 10; + let nextGroupId = 100; + const groups: Array<{ id: number; title?: string; windowId: number; collapsed?: boolean; color?: string }> = []; const tabs: MockTab[] = [ { id: 1, windowId: 1, url: 'https://automation.example', title: 'automation', active: true, status: 'complete' }, { id: 2, windowId: 2, url: 'https://user.example', title: 'user', active: true, status: 'complete' }, { id: 3, windowId: 1, url: 'chrome://extensions', title: 'chrome', active: false, status: 'complete' }, ]; - const query = vi.fn(async (queryInfo: { windowId?: number } = {}) => { - return tabs.filter((tab) => queryInfo.windowId === undefined || tab.windowId === queryInfo.windowId); + const query = vi.fn(async (queryInfo: { windowId?: number; groupId?: number } = {}) => { + return tabs.filter((tab) => + (queryInfo.windowId === undefined || tab.windowId === queryInfo.windowId) && + (queryInfo.groupId === undefined || tab.groupId === queryInfo.groupId) + ); }); const create = vi.fn(async ({ windowId, url, active }: { windowId?: number; url?: string; active?: boolean }) => { const tab: MockTab = { @@ -52,6 +58,17 @@ function createChromeMock() { tabs.push(tab); return tab; }); + const group = vi.fn(async ({ groupId, tabIds }: { groupId?: number; tabIds: number[] }) => { + const first = tabs.find((entry) => entry.id === tabIds[0]); + if (!first) throw new Error('Cannot group unknown tab'); + const resolvedGroupId = groupId ?? nextGroupId++; + if (groupId === undefined) groups.push({ id: resolvedGroupId, windowId: first.windowId }); + for (const tabId of tabIds) { + const tab = tabs.find((entry) => entry.id === tabId); + if (tab) tab.groupId = resolvedGroupId; + } + return resolvedGroupId; + }); const update = vi.fn(async (tabId: number, updates: { active?: boolean; url?: string }) => { const tab = tabs.find((entry) => entry.id === tabId); if (!tab) throw new Error(`Unknown tab ${tabId}`); @@ -64,8 +81,15 @@ function createChromeMock() { tabs: { query, create, + group, update, - remove: vi.fn(async (_tabId: number) => {}), + remove: vi.fn(async (tabIdOrIds: number | number[]) => { + const ids = Array.isArray(tabIdOrIds) ? tabIdOrIds : [tabIdOrIds]; + for (const tabId of ids) { + const index = tabs.findIndex((entry) => entry.id === tabId); + if (index >= 0) tabs.splice(index, 1); + } + }), get: vi.fn(async (tabId: number) => { const tab = tabs.find((entry) => entry.id === tabId); if (!tab) throw new Error(`Unknown tab ${tabId}`); @@ -76,9 +100,27 @@ function createChromeMock() { windows: { get: vi.fn(async (windowId: number) => ({ id: windowId })), create: vi.fn(async ({ url, focused, width, height, type }: any) => ({ id: 1, url, focused, width, height, type })), + getLastFocused: vi.fn(async () => ({ id: 2, type: 'normal' })), + getAll: vi.fn(async () => [{ id: 2, type: 'normal' }]), remove: vi.fn(async (_windowId: number) => {}), onRemoved: { addListener: vi.fn() } as Listener<(windowId: number) => void>, }, + tabGroups: { + get: vi.fn(async (groupId: number) => { + const groupEntry = groups.find((entry) => entry.id === groupId); + if (!groupEntry) throw new Error(`Unknown group ${groupId}`); + return groupEntry; + }), + query: vi.fn(async ({ title }: { title?: string } = {}) => + groups.filter((entry) => title === undefined || entry.title === title) + ), + update: vi.fn(async (groupId: number, updates: { title?: string; collapsed?: boolean; color?: string }) => { + const groupEntry = groups.find((entry) => entry.id === groupId); + if (!groupEntry) throw new Error(`Unknown group ${groupId}`); + Object.assign(groupEntry, updates); + return groupEntry; + }), + }, alarms: { create: vi.fn(), onAlarm: { addListener: vi.fn() } as Listener<(alarm: { name: string }) => void>, @@ -86,13 +128,22 @@ function createChromeMock() { runtime: { onInstalled: { addListener: vi.fn() } as Listener<() => void>, onStartup: { addListener: vi.fn() } as Listener<() => void>, + onMessage: { addListener: vi.fn() } as Listener<(msg: unknown, sender: unknown, sendResponse: (response?: unknown) => void) => void>, + onConnect: { addListener: vi.fn() } as Listener<(port: chrome.runtime.Port) => void>, + getManifest: vi.fn(() => ({ version: '1.5.6' })), + }, + action: { + onClicked: { addListener: vi.fn() } as Listener<(tab: chrome.tabs.Tab) => void>, + }, + scripting: { + executeScript: vi.fn(), }, cookies: { getAll: vi.fn(async () => []), }, }; - return { chrome, tabs, query, create, update }; + return { chrome, tabs, groups, query, create, group, update }; } describe('background tab isolation', () => { @@ -150,4 +201,192 @@ describe('background tab isolation', () => { expect.objectContaining({ workspace: 'site:zhihu', windowId: 2 }), ])); }); + + it('creates strict-background tab group tabs without activating tabs or creating windows', async () => { + const { chrome, create, group } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + + const result = await mod.__test__.handleTabs({ + id: '4', + action: 'tabs', + op: 'new', + url: 'https://new.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + expect(result.ok).toBe(true); + expect(chrome.windows.create).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalledWith({ windowId: 2, url: 'about:blank', active: false }); + expect(create).toHaveBeenCalledWith({ windowId: 2, url: 'https://new.example', active: false }); + expect(group).toHaveBeenCalled(); + expect(chrome.tabGroups.update).toHaveBeenCalledWith(expect.any(Number), expect.objectContaining({ + title: 'AutoCLI-work', + collapsed: true, + })); + }); + + it('rejects strict-background tab selection without activating any tab', async () => { + const { chrome, update } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleTabs({ + id: '5', + action: 'tabs', + op: 'select', + tabId: 2, + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + expect(result.ok).toBe(false); + expect(result.error).toContain('unsupported'); + expect(update).not.toHaveBeenCalledWith(expect.any(Number), expect.objectContaining({ active: true })); + }); + + it('does not navigate or list user tabs already present in a reused strict-background group', async () => { + const { chrome, tabs, groups, create, update } = createChromeMock(); + groups.push({ id: 123, title: 'AutoCLI-work', windowId: 2, collapsed: true, color: 'blue' }); + tabs.push({ + id: 9, + windowId: 2, + groupId: 123, + url: 'https://user-in-group.example', + title: 'user group tab', + active: true, + status: 'complete', + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const navigate = await mod.__test__.handleNavigate({ + id: '8', + action: 'navigate', + url: 'https://new.example', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + const list = await mod.__test__.handleTabs({ + id: '9', + action: 'tabs', + op: 'list', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + expect(navigate.ok).toBe(true); + expect(tabs.find((tab) => tab.id === 9)?.url).toBe('https://user-in-group.example'); + expect(create).toHaveBeenCalledWith({ windowId: 2, url: 'https://new.example', active: false }); + expect(update).not.toHaveBeenCalledWith(9, expect.anything()); + const listedTabs = list.data as Array<{ tabId: number }>; + expect(listedTabs).toHaveLength(1); + expect(listedTabs[0].tabId).not.toBe(9); + }); + + it('returns a clear error for invalid strict-background idle timeout', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + await mod.__test__.handleTabs({ + id: '10', + action: 'tabs', + op: 'new', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + const result = await mod.__test__.handleCommand({ + id: '11', + action: 'tabs', + op: 'list', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: -1, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('idle timeout'); + }); + + it('rejects malformed strict-background close-window without an existing session', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const missingGroup = await mod.__test__.handleCommand({ + id: '12', + action: 'close-window', + workspace: 'site:twitter', + carrier: 'tab-group-background', + }); + const invalidTimeout = await mod.__test__.handleCommand({ + id: '13', + action: 'close-window', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + groupIdleTimeoutSeconds: -1, + }); + + expect(missingGroup.ok).toBe(false); + expect(missingGroup.error).toContain('group name'); + expect(invalidTimeout.ok).toBe(false); + expect(invalidTimeout.error).toContain('idle timeout'); + expect(chrome.windows.remove).not.toHaveBeenCalled(); + }); + + it('rejects unknown carriers instead of falling back to automation-window behavior', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleCommand({ + id: '14', + action: 'tabs', + op: 'new', + workspace: 'site:twitter', + carrier: 'hidden-window', + } as any); + + expect(result.ok).toBe(false); + expect(result.error).toContain('Unknown browser carrier'); + expect(chrome.windows.create).not.toHaveBeenCalled(); + }); + + it('releases strict-background sessions without removing the host window', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + await mod.__test__.handleTabs({ + id: '6', + action: 'tabs', + op: 'new', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + const result = await mod.__test__.handleCloseWindow({ + id: '7', + action: 'close-window', + workspace: 'site:twitter', + carrier: 'tab-group-background', + groupName: 'work', + }, 'site:twitter'); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ released: true }); + expect(chrome.windows.remove).not.toHaveBeenCalled(); + }); }); diff --git a/extension/src/background.ts b/extension/src/background.ts index 978f354..078e2b7 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -7,10 +7,12 @@ * dispatches them to Chrome APIs (debugger/tabs/cookies), returns results. */ -import type { Command, Result } from './protocol'; +import type { BrowserCarrier, Command, Result } from './protocol'; import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; +type SelectorWindow = Window & typeof globalThis & { __autocliSelectorActive?: boolean }; + let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; @@ -144,13 +146,59 @@ type AutomationSession = { idleDeadlineAt: number; }; +type TabGroupSession = { + groupId: number; + windowId: number; + title: string; + ownedTabIds: Set; + idleTimer: ReturnType | null; + idleDeadlineAt: number; +}; + const automationSessions = new Map(); +const tabGroupSessions = new Map(); const WINDOW_IDLE_TIMEOUT = 30000; // 30s — quick cleanup after command finishes function getWorkspaceKey(workspace?: string): string { return workspace?.trim() || 'default'; } +function getCarrier(cmd: Command): BrowserCarrier { + const carrier = cmd.carrier ?? 'automation-window'; + if (carrier !== 'automation-window' && carrier !== 'tab-group-background') { + throw new Error(`Unknown browser carrier: ${carrier}`); + } + return carrier; +} + +function isTabGroupCarrier(cmd: Command): boolean { + return getCarrier(cmd) === 'tab-group-background'; +} + +function getGroupTitle(cmd: Command): string { + const name = cmd.groupName?.trim(); + if (!name) throw new Error('strict-background group name is required'); + if (name.includes('\n') || name.includes('\r')) throw new Error('strict-background group name cannot contain newlines'); + return `AutoCLI-${name}`; +} + +function getTabGroupSessionKey(workspace: string, title: string): string { + return `${workspace}\n${title}`; +} + +function getTabGroupIdleTimeout(cmd: Command): number { + const seconds = cmd.groupIdleTimeoutSeconds; + if (seconds === undefined) return WINDOW_IDLE_TIMEOUT; + if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('strict-background group idle timeout must be positive'); + return seconds * 1000; +} + +function validateStrictBackgroundCommand(cmd: Command): void { + if (!isTabGroupCarrier(cmd)) return; + getGroupTitle(cmd); + getTabGroupIdleTimeout(cmd); +} + function resetWindowIdleTimer(workspace: string): void { const session = automationSessions.get(workspace); if (!session) return; @@ -169,6 +217,37 @@ function resetWindowIdleTimer(workspace: string): void { }, WINDOW_IDLE_TIMEOUT); } +function resetTabGroupIdleTimer(workspace: string, cmd: Command): void { + if (!isTabGroupCarrier(cmd)) return; + let title: string; + try { + title = getGroupTitle(cmd); + } catch { + return; + } + const key = getTabGroupSessionKey(workspace, title); + const session = tabGroupSessions.get(key); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + const timeout = getTabGroupIdleTimeout(cmd); + session.idleDeadlineAt = Date.now() + timeout; + session.idleTimer = setTimeout(async () => { + const current = tabGroupSessions.get(key); + if (!current) return; + const ownedTabIds = [...current.ownedTabIds]; + if (ownedTabIds.length > 0) { + try { + await chrome.tabs.remove(ownedTabIds); + } catch { + // Some or all tabs may already be gone. + } + } + current.ownedTabIds.clear(); + tabGroupSessions.delete(key); + console.log(`[autocli] Tab group ${current.title} (${workspace}) released (idle timeout)`); + }, timeout); +} + /** Get or create the dedicated automation window. * @param initialUrl — if provided (http/https), used as the initial page instead of about:blank. * This avoids an extra blank-page→target-domain navigation on first command. @@ -282,9 +361,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { async function handleCommand(cmd: Command): Promise { const workspace = getWorkspaceKey(cmd.workspace); - // Reset idle timer on every command (window stays alive while active) - resetWindowIdleTimer(workspace); try { + validateStrictBackgroundCommand(cmd); + + // Reset idle timer on every command (window/group stays alive while active). + if (isTabGroupCarrier(cmd)) resetTabGroupIdleTimer(workspace, cmd); + else resetWindowIdleTimer(workspace); + switch (cmd.action) { case 'exec': return await handleExec(cmd, workspace); @@ -365,11 +448,114 @@ function setWorkspaceSession(workspace: string, session: Pick { + try { + const lastFocused = await chrome.windows.getLastFocused({ windowTypes: ['normal'] }); + if (lastFocused.id !== undefined) return lastFocused.id; + } catch { + // Fall through to getAll. + } + const windows = await chrome.windows.getAll({ windowTypes: ['normal'] }); + const host = windows.find((win) => win.id !== undefined); + if (host?.id === undefined) { + throw new Error('strict-background mode requires an existing normal Chrome window'); + } + return host.id; +} + +async function getTabGroupSession(workspace: string, cmd: Command): Promise { + const title = getGroupTitle(cmd); + const key = getTabGroupSessionKey(workspace, title); + const existing = tabGroupSessions.get(key); + if (existing) { + try { + await chrome.tabGroups.get(existing.groupId); + return existing; + } catch { + tabGroupSessions.delete(key); + } + } + + const exactGroups = (await chrome.tabGroups.query({ title })) + .filter((group) => group.id !== undefined && group.title === title); + const reusable = exactGroups[0]; + if (reusable?.id !== undefined) { + const groupTabs = await chrome.tabs.query({ groupId: reusable.id }); + const windowId = reusable.windowId ?? groupTabs[0]?.windowId; + if (windowId === undefined) { + throw new Error(`strict-background group ${title} has no normal host window`); + } + const session: TabGroupSession = { + groupId: reusable.id, + windowId, + title, + ownedTabIds: new Set(), + idleTimer: null, + idleDeadlineAt: Date.now() + getTabGroupIdleTimeout(cmd), + }; + tabGroupSessions.set(key, session); + resetTabGroupIdleTimer(workspace, cmd); + return session; + } + + const windowId = await getHostWindowId(); + const seedTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: false }); + if (!seedTab.id) throw new Error('Failed to create strict-background seed tab'); + const groupId = await chrome.tabs.group({ tabIds: [seedTab.id] }); + await chrome.tabGroups.update(groupId, { title, color: 'blue', collapsed: true }); + const session: TabGroupSession = { + groupId, + windowId, + title, + ownedTabIds: new Set([seedTab.id]), + idleTimer: null, + idleDeadlineAt: Date.now() + getTabGroupIdleTimeout(cmd), + }; + tabGroupSessions.set(key, session); + resetTabGroupIdleTimer(workspace, cmd); + return session; +} + +async function resolveTabGroupTab(tabId: number | undefined, workspace: string, cmd: Command, initialUrl?: string): Promise { + const session = await getTabGroupSession(workspace, cmd); + + if (tabId !== undefined) { + const tab = await chrome.tabs.get(tabId); + if (tab.groupId !== session.groupId) { + throw new Error(`Tab ${tabId} is not in strict-background group ${session.title}`); + } + if (!session.ownedTabIds.has(tabId)) { + throw new Error(`Tab ${tabId} is not owned by AutoCLI strict-background session ${session.title}`); + } + if (!isDebuggableUrl(tab.url)) { + throw new Error(`Tab ${tabId} URL is not debuggable (${tab.url})`); + } + return { tabId, tab }; + } + + const tabs = await chrome.tabs.query({ groupId: session.groupId }); + const debuggableTab = tabs.find((tab) => + tab.id !== undefined && session.ownedTabIds.has(tab.id) && isDebuggableUrl(tab.url) + ); + if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab }; + + const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const tab = await chrome.tabs.create({ windowId: session.windowId, url: startUrl, active: false }); + if (!tab.id) throw new Error('Failed to create strict-background tab'); + await chrome.tabs.group({ groupId: session.groupId, tabIds: [tab.id] }); + session.ownedTabIds.add(tab.id); + return { tabId: tab.id, tab }; +} + /** * Resolve target tab in the automation window, returning both the tabId and * the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). */ -async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string): Promise { +async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string, cmd?: Command): Promise { + if (cmd && isTabGroupCarrier(cmd)) { + return resolveTabGroupTab(tabId, workspace, cmd, initialUrl); + } + // Even when an explicit tabId is provided, validate it is still debuggable. if (tabId !== undefined) { try { @@ -427,8 +613,8 @@ async function resolveTab(tabId: number | undefined, workspace: string, initialU } /** Convenience wrapper returning just the tabId (used by most handlers) */ -async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string): Promise { - const resolved = await resolveTab(tabId, workspace, initialUrl); +async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string, cmd?: Command): Promise { + const resolved = await resolveTab(tabId, workspace, initialUrl, cmd); return resolved.tabId; } @@ -448,9 +634,24 @@ async function listAutomationWebTabs(workspace: string): Promise isDebuggableUrl(tab.url)); } +async function listTabGroupWebTabs(workspace: string, cmd: Command): Promise { + const session = await getTabGroupSession(workspace, cmd); + const tabs = await chrome.tabs.query({ groupId: session.groupId }); + return tabs.filter((tab) => + tab.id !== undefined && session.ownedTabIds.has(tab.id) && isDebuggableUrl(tab.url) + ); +} + +function removeOwnedTab(workspace: string, cmd: Command, tabId: number): void { + if (!isTabGroupCarrier(cmd)) return; + const title = getGroupTitle(cmd); + const session = tabGroupSessions.get(getTabGroupSessionKey(workspace, title)); + session?.ownedTabIds.delete(tabId); +} + async function handleExec(cmd: Command, workspace: string): Promise { if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' }; - const tabId = await resolveTabId(cmd.tabId, workspace); + const tabId = await resolveTabId(cmd.tabId, workspace, undefined, cmd); try { const aggressive = workspace.startsWith('operate:'); const data = await executor.evaluateAsync(tabId, cmd.code, aggressive); @@ -466,7 +667,7 @@ async function handleNavigate(cmd: Command, workspace: string): Promise return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' }; } // Pass target URL so that first-time window creation can start on the right domain - const resolved = await resolveTab(cmd.tabId, workspace, cmd.url); + const resolved = await resolveTab(cmd.tabId, workspace, cmd.url, cmd); const tabId = resolved.tabId; const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); @@ -546,7 +747,7 @@ async function handleNavigate(cmd: Command, workspace: string): Promise // during navigation (e.g. a tab-management extension regrouped it), // try to move it back to maintain session isolation. const session = automationSessions.get(workspace); - if (session && tab.windowId !== session.windowId) { + if (!isTabGroupCarrier(cmd) && session && tab.windowId !== session.windowId) { console.warn(`[autocli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`); try { await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); @@ -566,7 +767,9 @@ async function handleNavigate(cmd: Command, workspace: string): Promise async function handleTabs(cmd: Command, workspace: string): Promise { switch (cmd.op) { case 'list': { - const tabs = await listAutomationWebTabs(workspace); + const tabs = isTabGroupCarrier(cmd) + ? await listTabGroupWebTabs(workspace, cmd) + : await listAutomationWebTabs(workspace); const data = tabs .map((t, i) => ({ index: i, @@ -581,25 +784,41 @@ async function handleTabs(cmd: Command, workspace: string): Promise { if (cmd.url && !isSafeNavigationUrl(cmd.url)) { return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' }; } + if (isTabGroupCarrier(cmd)) { + const session = await getTabGroupSession(workspace, cmd); + const tab = await chrome.tabs.create({ windowId: session.windowId, url: cmd.url ?? BLANK_PAGE, active: false }); + if (!tab.id) return { id: cmd.id, ok: false, error: 'Failed to create strict-background tab' }; + await chrome.tabs.group({ groupId: session.groupId, tabIds: [tab.id] }); + session.ownedTabIds.add(tab.id); + resetTabGroupIdleTimer(workspace, cmd); + return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; + } const windowId = await getAutomationWindow(workspace); const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; } case 'close': { if (cmd.index !== undefined) { - const tabs = await listAutomationWebTabs(workspace); + const tabs = isTabGroupCarrier(cmd) + ? await listTabGroupWebTabs(workspace, cmd) + : await listAutomationWebTabs(workspace); const target = tabs[cmd.index]; if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; await chrome.tabs.remove(target.id); await executor.detach(target.id); + removeOwnedTab(workspace, cmd, target.id); return { id: cmd.id, ok: true, data: { closed: target.id } }; } - const tabId = await resolveTabId(cmd.tabId, workspace); + const tabId = await resolveTabId(cmd.tabId, workspace, undefined, cmd); await chrome.tabs.remove(tabId); await executor.detach(tabId); + removeOwnedTab(workspace, cmd, tabId); return { id: cmd.id, ok: true, data: { closed: tabId } }; } case 'select': { + if (isTabGroupCarrier(cmd)) { + return { id: cmd.id, ok: false, error: 'unsupported-operation: tabs select is disabled in strict-background mode' }; + } if (cmd.index === undefined && cmd.tabId === undefined) return { id: cmd.id, ok: false, error: 'Missing index or tabId' }; if (cmd.tabId !== undefined) { @@ -648,7 +867,7 @@ async function handleCookies(cmd: Command): Promise { } async function handleScreenshot(cmd: Command, workspace: string): Promise { - const tabId = await resolveTabId(cmd.tabId, workspace); + const tabId = await resolveTabId(cmd.tabId, workspace, undefined, cmd); try { const data = await executor.screenshot(tabId, { format: cmd.format, @@ -690,7 +909,7 @@ async function handleCdp(cmd: Command, workspace: string): Promise { if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; } - const tabId = await resolveTabId(cmd.tabId, workspace); + const tabId = await resolveTabId(cmd.tabId, workspace, undefined, cmd); try { const aggressive = workspace.startsWith('operate:'); await executor.ensureAttached(tabId, aggressive); @@ -706,6 +925,11 @@ async function handleCdp(cmd: Command, workspace: string): Promise { } async function handleCloseWindow(cmd: Command, workspace: string): Promise { + if (isTabGroupCarrier(cmd)) { + validateStrictBackgroundCommand(cmd); + resetTabGroupIdleTimer(workspace, cmd); + return { id: cmd.id, ok: true, data: { released: true } }; + } const session = automationSessions.get(workspace); if (session) { try { @@ -723,7 +947,7 @@ async function handleSetFileInput(cmd: Command, workspace: string): Promise { export const __test__ = { handleNavigate, + handleCommand, isTargetUrl, handleTabs, + handleCloseWindow, handleSessions, resolveTabId, resetWindowIdleTimer, @@ -942,7 +1168,7 @@ chrome.action.onClicked.addListener(async (tab) => { // Check if already injected — if so, just toggle const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, - func: () => !!window.__autocliSelectorActive, + func: () => !!(window as SelectorWindow).__autocliSelectorActive, }); if (result?.result) { // Already injected, re-run content.js to toggle diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index 450c173..274769f 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -64,6 +64,7 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f lastError = e instanceof Error ? e.message : String(e); if (attempt < MAX_ATTACH_RETRIES) { console.warn(`[autocli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); + await cleanupBeforeAttachRetry(tabId); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); // Re-verify tab URL before retrying (it may have changed) try { @@ -105,6 +106,21 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f } } +async function cleanupBeforeAttachRetry(tabId: number): Promise { + try { + await chrome.scripting.executeScript({ + target: { tabId }, + func: () => { + const roots = document.querySelectorAll('[data-autocli-transient="true"]'); + roots.forEach((node) => node.remove()); + return { removed: roots.length }; + }, + }); + } catch { + // Cleanup is best-effort; retry attach remains the real gate. + } +} + export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise { // Retry the entire evaluate (attach + command). // Normal: 2 retries. Operate: 3 retries (tolerates extension interference). diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 4a68815..7f9a037 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -8,6 +8,7 @@ */ export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp' | 'read-article'; +export type BrowserCarrier = 'automation-window' | 'tab-group-background'; export interface Command { /** Unique request ID */ @@ -20,6 +21,12 @@ export interface Command { code?: string; /** Logical workspace for automation session reuse */ workspace?: string; + /** Browser carrier mode. Defaults to automation-window for protocol compatibility. */ + carrier?: BrowserCarrier; + /** User configured group suffix. Effective title is AutoCLI-${groupName}. */ + groupName?: string; + /** Idle cleanup timeout for strict background group sessions. */ + groupIdleTimeoutSeconds?: number; /** URL to navigate to (navigate action) */ url?: string; /** Sub-operation for tabs: list, new, close, select */