Skip to main content

ferridriver_config/
mcp.rs

1//! MCP server configuration types.
2//!
3//! Loaded from the `[mcp]` table of the unified `ferridriver.toml`. Provides
4//! data fields plus pure helper methods. The `McpServerConfig` trait
5//! implementation that wires this into the live MCP server lives in
6//! `ferridriver-mcp::config` (where the trait is defined).
7
8use std::collections::HashMap;
9use std::net::TcpStream;
10use std::path::Path;
11use std::process::Command;
12use std::sync::Mutex;
13use std::time::{Duration, Instant};
14
15use ferridriver::backend::BackendKind;
16use ferridriver::state::ConnectMode;
17use serde::{Deserialize, Serialize};
18
19/// Default TTL for cached command outputs (5 minutes).
20pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(300);
21
22/// Timeout for verifying a browser port is responsive.
23pub const DISCOVER_TCP_TIMEOUT: Duration = Duration::from_millis(500);
24
25/// Default MCP server name returned by `get_info`.
26pub const DEFAULT_SERVER_NAME: &str = "ferridriver";
27
28/// Root MCP-section configuration loaded from a unified `ferridriver.{toml,yaml,json}`
29/// file under the `[mcp]` table.
30#[derive(Debug, Default, Deserialize, Serialize)]
31#[serde(default, rename_all = "camelCase")]
32pub struct McpConfig {
33  /// MCP server metadata.
34  pub server: ServerConfig,
35  /// Browser launch and instance configuration.
36  pub browser: BrowserConfig,
37
38  // -- runtime fields (not deserialized) --
39  /// Cached command outputs (populated at runtime).
40  #[serde(skip)]
41  command_cache: CommandCache,
42  /// Pre-built combined instructions string.
43  #[serde(skip)]
44  instructions_cache: std::sync::OnceLock<String>,
45}
46
47/// MCP server metadata configuration.
48#[derive(Debug, Default, Deserialize, Serialize)]
49#[serde(default)]
50pub struct ServerConfig {
51  /// Server name for MCP `get_info` (default: "ferridriver").
52  pub name: Option<String>,
53  /// Full override of server instructions. If set, replaces the default instructions entirely.
54  pub instructions: Option<String>,
55  /// Additional instructions appended to the default ferridriver instructions.
56  /// Ignored if `instructions` is set.
57  pub extra_instructions: Option<String>,
58}
59
60/// Browser launch and per-instance configuration.
61#[derive(Debug, Default, Deserialize, Serialize)]
62#[serde(default)]
63pub struct BrowserConfig {
64  /// Browser backend: "cdp-pipe" (default), "cdp-raw", "bidi".
65  pub backend: Option<String>,
66  /// Run browsers in headless mode.
67  pub headless: Option<bool>,
68  /// Path to Chrome/Chromium executable.
69  pub executable_path: Option<String>,
70  /// Default viewport dimensions for new pages.
71  pub viewport: Option<ViewportDef>,
72  /// Base Chrome arguments applied to ALL browser instances.
73  pub chrome_args: Vec<String>,
74  /// External command to get per-instance Chrome args.
75  /// `${INSTANCE}` is replaced with the instance name.
76  /// Output: one arg per line, or JSON array of strings.
77  pub instance_args_command: Option<String>,
78  /// External command to discover a running browser instance.
79  /// `${INSTANCE}` is replaced with the instance name.
80  /// Output: a `ws://` URL on the first line, or empty for "not found".
81  pub instance_discover_command: Option<String>,
82  /// Cache TTL in seconds for command outputs (default: 300).
83  pub command_cache_ttl: Option<u64>,
84  /// Static per-instance overrides (keyed by instance name).
85  pub instances: HashMap<String, InstanceConfig>,
86  /// Default config for instances not listed in `instances`.
87  pub default_instance: Option<InstanceConfig>,
88}
89
90/// Per-instance configuration.
91#[derive(Debug, Default, Clone, Deserialize, Serialize)]
92#[serde(default)]
93pub struct InstanceConfig {
94  /// Additional Chrome arguments for this instance.
95  pub chrome_args: Vec<String>,
96  /// Explicit WebSocket URL to connect to (skip launch).
97  pub connect_url: Option<String>,
98  /// Path to Chrome profile directory for `DevToolsActivePort` discovery.
99  /// `${INSTANCE}` is replaced with the instance name. Supports `~` expansion.
100  pub discover_profile: Option<String>,
101}
102
103/// Viewport dimensions.
104#[derive(Debug, Clone, Deserialize, Serialize)]
105pub struct ViewportDef {
106  pub width: Option<i64>,
107  pub height: Option<i64>,
108}
109
110impl McpConfig {
111  /// Resolve the `BackendKind` from config (defaults to `CdpPipe`).
112  #[must_use]
113  pub fn backend_kind(&self) -> BackendKind {
114    match self.browser.backend.as_deref() {
115      Some("cdp-raw") => BackendKind::CdpRaw,
116      Some("bidi") => BackendKind::Bidi,
117      #[cfg(target_os = "macos")]
118      Some("webkit") => BackendKind::WebKit,
119      _ => BackendKind::CdpPipe,
120    }
121  }
122
123  /// Whether headless mode is enabled (defaults to false).
124  #[must_use]
125  pub fn headless(&self) -> bool {
126    self.browser.headless.unwrap_or(false)
127  }
128
129  /// Cache TTL for command outputs.
130  fn cache_ttl(&self) -> Duration {
131    self
132      .browser
133      .command_cache_ttl
134      .map_or(DEFAULT_CACHE_TTL, Duration::from_secs)
135  }
136
137  /// Base Chrome args applied to every browser instance.
138  #[must_use]
139  pub fn chrome_args(&self) -> Vec<String> {
140    self.browser.chrome_args.clone()
141  }
142
143  /// Resolve Chrome args for a named instance: static per-instance args
144  /// followed by dynamic args returned by `instance_args_command`.
145  #[must_use]
146  pub fn chrome_args_for_instance(&self, instance: &str) -> Vec<String> {
147    let mut args = Vec::new();
148
149    if let Some(ic) = self.browser.instances.get(instance) {
150      args.extend(ic.chrome_args.iter().cloned());
151    } else if let Some(ref default) = self.browser.default_instance {
152      args.extend(default.chrome_args.iter().cloned());
153    }
154
155    if let Some(ref cmd_template) = self.browser.instance_args_command {
156      let cmd = cmd_template.replace("${INSTANCE}", instance);
157      match self.command_cache.get_or_exec(&cmd, self.cache_ttl()) {
158        Ok(lines) => args.extend(lines),
159        Err(e) => tracing::warn!("instance_args_command failed for '{instance}': {e}"),
160      }
161    }
162
163    args
164  }
165
166  /// Resolve a `ConnectMode` for the given instance: static `connect_url`,
167  /// then profile discovery, then `instance_discover_command`.
168  #[must_use]
169  pub fn resolve_instance(&self, instance: &str) -> Option<ConnectMode> {
170    if let Some(ic) = self.browser.instances.get(instance) {
171      if let Some(ref url) = ic.connect_url {
172        return Some(ConnectMode::ConnectUrl(url.clone()));
173      }
174      if let Some(ref profile_template) = ic.discover_profile {
175        match discover_from_profile(profile_template, instance) {
176          ProfileDiscovery::Found(mode) => return Some(mode),
177          ProfileDiscovery::Stale => return None,
178          ProfileDiscovery::NotFound => {},
179        }
180      }
181    }
182
183    if let Some(ref default) = self.browser.default_instance {
184      if let Some(ref profile_template) = default.discover_profile {
185        match discover_from_profile(profile_template, instance) {
186          ProfileDiscovery::Found(mode) => return Some(mode),
187          ProfileDiscovery::Stale => return None,
188          ProfileDiscovery::NotFound => {},
189        }
190      }
191    }
192
193    if let Some(ref cmd_template) = self.browser.instance_discover_command {
194      let cmd = cmd_template.replace("${INSTANCE}", instance);
195      match self.command_cache.get_or_exec(&cmd, self.cache_ttl()) {
196        Ok(lines) => {
197          if let Some(url) = lines.first() {
198            let url = url.trim();
199            if url.starts_with("ws://") || url.starts_with("wss://") {
200              return Some(ConnectMode::ConnectUrl(url.to_string()));
201            }
202          }
203        },
204        Err(e) => tracing::warn!("instance_discover_command failed for '{instance}': {e}"),
205      }
206    }
207
208    None
209  }
210
211  /// MCP server display name from config or the default.
212  #[must_use]
213  pub fn server_name(&self) -> &str {
214    self.server.name.as_deref().unwrap_or(DEFAULT_SERVER_NAME)
215  }
216
217  /// Resolve final server instructions, blending defaults with config-provided
218  /// overrides or extras.
219  pub fn server_instructions<'a>(&'a self, defaults: &str) -> &'a str {
220    self.instructions_cache.get_or_init(|| {
221      if let Some(ref full) = self.server.instructions {
222        return full.clone();
223      }
224      match &self.server.extra_instructions {
225        Some(extra) => format!("{defaults}\n\n{extra}"),
226        None => defaults.to_string(),
227      }
228    })
229  }
230}
231
232/// Result of attempting to discover a browser via a Chrome profile directory.
233enum ProfileDiscovery {
234  Found(ConnectMode),
235  Stale,
236  NotFound,
237}
238
239/// Read `DevToolsActivePort` from a Chrome profile directory and return a
240/// `ConnectMode` if the browser is responding.
241fn discover_from_profile(profile_template: &str, instance: &str) -> ProfileDiscovery {
242  let template = profile_template.replace("${INSTANCE}", instance);
243  let expanded = shellexpand::tilde(&template);
244  let profile_dir = Path::new(expanded.as_ref());
245
246  let port_file = profile_dir.join("DevToolsActivePort");
247  let Ok(content) = std::fs::read_to_string(&port_file) else {
248    return ProfileDiscovery::NotFound;
249  };
250  let mut lines = content.lines();
251  let Some(port) = lines.next().and_then(|l| l.parse::<u16>().ok()) else {
252    return ProfileDiscovery::NotFound;
253  };
254  let path = lines.next().unwrap_or("/");
255
256  let addr = format!("127.0.0.1:{port}");
257  if let Ok(sock_addr) = addr.parse() {
258    if TcpStream::connect_timeout(&sock_addr, DISCOVER_TCP_TIMEOUT).is_ok() {
259      return ProfileDiscovery::Found(ConnectMode::ConnectUrl(format!("ws://127.0.0.1:{port}{path}")));
260    }
261  }
262
263  ProfileDiscovery::Stale
264}
265
266/// TTL-based cache for external command outputs.
267///
268/// Same command string (after `${INSTANCE}` substitution) returns cached output
269/// within the TTL window, avoiding repeated subprocess spawns.
270#[derive(Debug, Default)]
271struct CommandCache {
272  entries: Mutex<HashMap<String, CacheEntry>>,
273}
274
275#[derive(Debug, Clone)]
276struct CacheEntry {
277  lines: Vec<String>,
278  created: Instant,
279}
280
281impl CommandCache {
282  fn get_or_exec(&self, command: &str, ttl: Duration) -> Result<Vec<String>, String> {
283    {
284      let cache = self.entries.lock().map_err(|e| format!("Cache lock poisoned: {e}"))?;
285      if let Some(entry) = cache.get(command) {
286        if entry.created.elapsed() < ttl {
287          return Ok(entry.lines.clone());
288        }
289      }
290    }
291
292    let lines = exec_command(command)?;
293
294    {
295      let mut cache = self.entries.lock().map_err(|e| format!("Cache lock poisoned: {e}"))?;
296      cache.insert(
297        command.to_string(),
298        CacheEntry {
299          lines: lines.clone(),
300          created: Instant::now(),
301        },
302      );
303    }
304
305    Ok(lines)
306  }
307}
308
309/// Execute a shell command and return its stdout lines.
310///
311/// Supported output formats (probed in order):
312/// - JSON array of strings: `["--flag1", "--flag2"]`
313/// - JSON object with an `args` field holding the array: `{"args": ["--flag1"], ...}`
314///   (matches the shape emitted by `box-dev-gate browser args --json`).
315/// - Plain text: one arg per line.
316fn exec_command(command: &str) -> Result<Vec<String>, String> {
317  let output = Command::new("sh")
318    .args(["-c", command])
319    .output()
320    .map_err(|e| format!("Failed to execute command: {e}"))?;
321
322  if !output.status.success() {
323    let stderr = String::from_utf8_lossy(&output.stderr);
324    return Err(format!("Command failed (exit {}): {stderr}", output.status));
325  }
326
327  let stdout = String::from_utf8_lossy(&output.stdout);
328  let trimmed = stdout.trim();
329
330  if trimmed.is_empty() {
331    return Ok(Vec::new());
332  }
333
334  if trimmed.starts_with('[')
335    && let Ok(arr) = serde_json::from_str::<Vec<String>>(trimmed)
336  {
337    return Ok(arr);
338  }
339
340  if trimmed.starts_with('{')
341    && let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
342    && let Some(arr) = value.get("args").and_then(|v| v.as_array())
343  {
344    let strs: Option<Vec<String>> = arr.iter().map(|v| v.as_str().map(str::to_string)).collect();
345    if let Some(strs) = strs {
346      return Ok(strs);
347    }
348  }
349
350  Ok(
351    trimmed
352      .lines()
353      .map(|l| l.trim().to_string())
354      .filter(|l| !l.is_empty())
355      .collect(),
356  )
357}
358
359#[cfg(test)]
360mod tests {
361  use super::*;
362
363  const TEST_DEFAULTS: &str = "Browser automation via the Model Context Protocol.";
364
365  #[test]
366  fn default_config_has_sane_defaults() {
367    let config = McpConfig::default();
368    assert_eq!(config.server_name(), "ferridriver");
369    assert_eq!(config.server_instructions(TEST_DEFAULTS), TEST_DEFAULTS);
370    assert!(config.chrome_args().is_empty());
371    assert!(config.chrome_args_for_instance("dev").is_empty());
372    assert!(config.resolve_instance("dev").is_none());
373    assert_eq!(config.backend_kind(), BackendKind::CdpPipe);
374    assert!(!config.headless());
375  }
376
377  #[test]
378  fn instructions_override() {
379    let mut config = McpConfig::default();
380    config.server.instructions = Some("Custom only".into());
381    config.server.extra_instructions = Some("Should be ignored".into());
382    assert_eq!(config.server_instructions(TEST_DEFAULTS), "Custom only");
383  }
384
385  #[test]
386  fn extra_instructions_appended() {
387    let mut config = McpConfig::default();
388    config.server.extra_instructions = Some("Extra context here.".into());
389    let instructions = config.server_instructions(TEST_DEFAULTS);
390    assert!(instructions.starts_with(TEST_DEFAULTS));
391    assert!(instructions.ends_with("Extra context here."));
392  }
393
394  #[test]
395  fn static_instance_args() {
396    let mut config = McpConfig::default();
397    config.browser.instances.insert(
398      "staging".into(),
399      InstanceConfig {
400        chrome_args: vec!["--proxy-server=localhost:8080".into()],
401        ..Default::default()
402      },
403    );
404    assert_eq!(
405      config.chrome_args_for_instance("staging"),
406      vec!["--proxy-server=localhost:8080"]
407    );
408    assert!(config.chrome_args_for_instance("unknown").is_empty());
409  }
410
411  #[test]
412  fn default_instance_fallback() {
413    let mut config = McpConfig::default();
414    config.browser.default_instance = Some(InstanceConfig {
415      chrome_args: vec!["--default-flag".into()],
416      ..Default::default()
417    });
418    assert_eq!(config.chrome_args_for_instance("any"), vec!["--default-flag"]);
419  }
420
421  #[test]
422  fn static_connect_url() {
423    let mut config = McpConfig::default();
424    config.browser.instances.insert(
425      "remote".into(),
426      InstanceConfig {
427        connect_url: Some("ws://192.168.1.50:9222/devtools/browser/abc".into()),
428        ..Default::default()
429      },
430    );
431    let mode = config.resolve_instance("remote");
432    assert!(matches!(mode, Some(ConnectMode::ConnectUrl(url)) if url.contains("192.168.1.50")));
433  }
434
435  #[test]
436  fn backend_parsing() {
437    let mut config = McpConfig::default();
438    assert_eq!(config.backend_kind(), BackendKind::CdpPipe);
439    config.browser.backend = Some("cdp-raw".into());
440    assert_eq!(config.backend_kind(), BackendKind::CdpRaw);
441    config.browser.backend = Some("bidi".into());
442    assert_eq!(config.backend_kind(), BackendKind::Bidi);
443    config.browser.backend = Some("unknown".into());
444    assert_eq!(config.backend_kind(), BackendKind::CdpPipe);
445  }
446
447  #[test]
448  fn command_cache_returns_cached_value() {
449    let cache = CommandCache::default();
450    let result1 = cache.get_or_exec("echo hello", Duration::from_secs(60));
451    assert_eq!(
452      result1.as_ref().map(Vec::as_slice),
453      Ok(["hello".to_string()].as_slice())
454    );
455    let result2 = cache.get_or_exec("echo hello", Duration::from_secs(60));
456    assert_eq!(result1, result2);
457  }
458
459  #[test]
460  fn command_json_output_parsing() {
461    let result = exec_command(r#"echo '["--flag1", "--flag2"]'"#);
462    assert_eq!(result, Ok(vec!["--flag1".to_string(), "--flag2".to_string()]));
463  }
464
465  #[test]
466  fn command_line_output_parsing() {
467    let result = exec_command("echo flag1 && echo flag2");
468    assert_eq!(result, Ok(vec!["flag1".to_string(), "flag2".to_string()]));
469  }
470
471  #[test]
472  fn command_empty_output() {
473    let result = exec_command("echo ''");
474    assert_eq!(result, Ok(Vec::new()));
475  }
476
477  #[test]
478  fn instance_args_command_substitutes_instance_name() {
479    let mut config = McpConfig::default();
480    config.browser.instance_args_command = Some("echo '--user-agent=Test-${INSTANCE}'".into());
481    let args = config.chrome_args_for_instance("staging");
482    assert_eq!(args, vec!["--user-agent=Test-staging"]);
483    let args2 = config.chrome_args_for_instance("production");
484    assert_eq!(args2, vec!["--user-agent=Test-production"]);
485  }
486
487  #[test]
488  fn instance_args_command_json_output() {
489    let mut config = McpConfig::default();
490    config.browser.instance_args_command = Some(r#"printf '["--dns-prefetch-disable","--tag=dev"]'"#.into());
491    let args = config.chrome_args_for_instance("dev");
492    assert_eq!(args, vec!["--dns-prefetch-disable", "--tag=dev"]);
493  }
494
495  #[test]
496  fn instance_args_command_json_object_with_args_field() {
497    let mut config = McpConfig::default();
498    config.browser.instance_args_command = Some(
499      r#"printf '{"environment":"staging","args":["--no-first-run","--host-resolver-rules=MAP a.box.com 1.2.3.4"]}'"#
500        .into(),
501    );
502    let args = config.chrome_args_for_instance("staging");
503    assert_eq!(
504      args,
505      vec!["--no-first-run", "--host-resolver-rules=MAP a.box.com 1.2.3.4"]
506    );
507  }
508
509  #[test]
510  fn instance_args_command_merges_with_static_args() {
511    let mut config = McpConfig::default();
512    config.browser.instances.insert(
513      "staging".into(),
514      InstanceConfig {
515        chrome_args: vec!["--proxy-server=localhost:8080".into()],
516        ..Default::default()
517      },
518    );
519    config.browser.instance_args_command = Some("echo '--user-agent=Bot-${INSTANCE}'".into());
520
521    let args = config.chrome_args_for_instance("staging");
522    assert_eq!(args.len(), 2);
523    assert_eq!(args[0], "--proxy-server=localhost:8080");
524    assert_eq!(args[1], "--user-agent=Bot-staging");
525  }
526
527  #[test]
528  fn instance_args_command_default_instance_plus_command() {
529    let mut config = McpConfig::default();
530    config.browser.default_instance = Some(InstanceConfig {
531      chrome_args: vec!["--default-flag".into()],
532      ..Default::default()
533    });
534    config.browser.instance_args_command = Some("echo '--dynamic-flag'".into());
535    let args = config.chrome_args_for_instance("unknown-env");
536    assert_eq!(args, vec!["--default-flag", "--dynamic-flag"]);
537  }
538
539  #[test]
540  fn instance_args_command_failure_is_non_fatal() {
541    let mut config = McpConfig::default();
542    config.browser.instance_args_command = Some("false".into());
543    config.browser.instances.insert(
544      "dev".into(),
545      InstanceConfig {
546        chrome_args: vec!["--static-flag".into()],
547        ..Default::default()
548      },
549    );
550    let args = config.chrome_args_for_instance("dev");
551    assert_eq!(args, vec!["--static-flag"]);
552  }
553
554  #[test]
555  fn discover_command_returns_ws_url() {
556    let mut config = McpConfig::default();
557    config.browser.instance_discover_command = Some("echo 'ws://127.0.0.1:9222/devtools/browser/abc'".into());
558    let mode = config.resolve_instance("any");
559    assert!(matches!(
560      mode,
561      Some(ConnectMode::ConnectUrl(url)) if url == "ws://127.0.0.1:9222/devtools/browser/abc"
562    ));
563  }
564
565  #[test]
566  fn discover_command_substitutes_instance() {
567    let mut config = McpConfig::default();
568    config.browser.instance_discover_command = Some("echo 'ws://127.0.0.1:9222/${INSTANCE}'".into());
569    let mode = config.resolve_instance("staging");
570    assert!(matches!(
571      mode,
572      Some(ConnectMode::ConnectUrl(url)) if url == "ws://127.0.0.1:9222/staging"
573    ));
574  }
575
576  #[test]
577  fn discover_command_ignores_non_ws_output() {
578    let mut config = McpConfig::default();
579    config.browser.instance_discover_command = Some("echo 'not-a-ws-url'".into());
580    assert!(config.resolve_instance("dev").is_none());
581  }
582
583  #[test]
584  fn discover_command_empty_output_returns_none() {
585    let mut config = McpConfig::default();
586    config.browser.instance_discover_command = Some("echo ''".into());
587    assert!(config.resolve_instance("dev").is_none());
588  }
589
590  #[test]
591  fn discover_command_failure_returns_none() {
592    let mut config = McpConfig::default();
593    config.browser.instance_discover_command = Some("false".into());
594    assert!(config.resolve_instance("dev").is_none());
595  }
596
597  #[test]
598  fn static_connect_url_takes_priority_over_discover_command() {
599    let mut config = McpConfig::default();
600    config.browser.instances.insert(
601      "staging".into(),
602      InstanceConfig {
603        connect_url: Some("ws://static-host:9222/browser".into()),
604        ..Default::default()
605      },
606    );
607    config.browser.instance_discover_command = Some("echo 'ws://dynamic-host:9222/browser'".into());
608    let mode = config.resolve_instance("staging");
609    assert!(matches!(
610      mode,
611      Some(ConnectMode::ConnectUrl(url)) if url == "ws://static-host:9222/browser"
612    ));
613  }
614
615  #[test]
616  fn unknown_instance_falls_through_to_discover_command() {
617    let mut config = McpConfig::default();
618    config.browser.instances.insert(
619      "staging".into(),
620      InstanceConfig {
621        connect_url: Some("ws://staging-host:9222/browser".into()),
622        ..Default::default()
623      },
624    );
625    config.browser.instance_discover_command = Some("echo 'ws://discovered-host:9222/${INSTANCE}'".into());
626
627    let staging = config.resolve_instance("staging");
628    assert!(matches!(
629      staging,
630      Some(ConnectMode::ConnectUrl(url)) if url.contains("staging-host")
631    ));
632
633    let prod = config.resolve_instance("production");
634    assert!(matches!(
635      prod,
636      Some(ConnectMode::ConnectUrl(url)) if url == "ws://discovered-host:9222/production"
637    ));
638  }
639
640  #[test]
641  fn no_discovery_returns_none_for_launch_fallback() {
642    let config = McpConfig::default();
643    assert!(config.resolve_instance("anything").is_none());
644  }
645
646  #[test]
647  fn command_cache_ttl_respects_config() {
648    let mut config = McpConfig::default();
649    config.browser.command_cache_ttl = Some(60);
650    assert_eq!(config.cache_ttl(), Duration::from_secs(60));
651
652    config.browser.command_cache_ttl = None;
653    assert_eq!(config.cache_ttl(), DEFAULT_CACHE_TTL);
654  }
655
656  #[test]
657  fn command_cache_expires_after_ttl() {
658    let cache = CommandCache::default();
659    let short_ttl = Duration::from_millis(50);
660
661    let result1 = cache.get_or_exec("echo first", short_ttl);
662    assert!(result1.is_ok());
663
664    std::thread::sleep(Duration::from_millis(100));
665
666    let result2 = cache.get_or_exec("echo first", short_ttl);
667    assert_eq!(result1, result2);
668
669    let entries = cache.entries.lock().unwrap();
670    let entry = entries.get("echo first").unwrap();
671    assert!(entry.created.elapsed() < Duration::from_millis(50));
672  }
673
674  #[test]
675  fn command_cache_different_commands_cached_separately() {
676    let cache = CommandCache::default();
677    let ttl = Duration::from_secs(60);
678
679    let r1 = cache.get_or_exec("echo aaa", ttl).unwrap();
680    let r2 = cache.get_or_exec("echo bbb", ttl).unwrap();
681    assert_eq!(r1, vec!["aaa"]);
682    assert_eq!(r2, vec!["bbb"]);
683
684    let entries = cache.entries.lock().unwrap();
685    assert_eq!(entries.len(), 2);
686  }
687
688  #[test]
689  fn config_resolve_uses_instance_not_composite_key() {
690    let mut config = McpConfig::default();
691    config.browser.instances.insert(
692      "staging".into(),
693      InstanceConfig {
694        connect_url: Some("ws://staging-browser:9222".into()),
695        ..Default::default()
696      },
697    );
698    assert!(config.resolve_instance("staging").is_some());
699    assert!(config.resolve_instance("staging:admin").is_none());
700  }
701
702  #[test]
703  fn instance_args_uses_instance_not_composite_key() {
704    let mut config = McpConfig::default();
705    config.browser.instances.insert(
706      "staging".into(),
707      InstanceConfig {
708        chrome_args: vec!["--staging-flag".into()],
709        ..Default::default()
710      },
711    );
712    assert_eq!(config.chrome_args_for_instance("staging"), vec!["--staging-flag"]);
713    assert!(config.chrome_args_for_instance("staging:admin").is_empty());
714  }
715
716  #[test]
717  fn discover_profile_nonexistent_path_returns_none() {
718    let result = discover_from_profile("/nonexistent/path/${INSTANCE}/profile", "dev");
719    assert!(matches!(result, ProfileDiscovery::NotFound));
720  }
721
722  #[test]
723  fn discover_profile_stale_port_file_returns_some_none() {
724    let dir = std::env::temp_dir().join("ferridriver-config-test-stale-profile");
725    let _ = std::fs::create_dir_all(&dir);
726    std::fs::write(dir.join("DevToolsActivePort"), "59999\n/devtools/browser/fake").unwrap();
727
728    let result = discover_from_profile(dir.to_str().unwrap(), "dev");
729    let _ = std::fs::remove_dir_all(&dir);
730
731    assert!(matches!(result, ProfileDiscovery::Stale));
732  }
733
734  #[test]
735  fn discover_profile_instance_substitution() {
736    let dir = std::env::temp_dir().join("ferridriver-config-test-inst-sub");
737    let staging_dir = dir.join("staging");
738    let _ = std::fs::create_dir_all(&staging_dir);
739    std::fs::write(staging_dir.join("DevToolsActivePort"), "59998\n/devtools/browser/abc").unwrap();
740
741    let template = format!("{}/${{INSTANCE}}", dir.display());
742    let result = discover_from_profile(&template, "staging");
743    let _ = std::fs::remove_dir_all(&dir);
744
745    assert!(matches!(result, ProfileDiscovery::Stale));
746  }
747}