chromewright 0.8.0

Browser automation MCP server via Chrome DevTools Protocol (CDP)
Documentation
//! Browser kernel: shared in-page JS templates compiled once and rendered per probe.
//!
//! Tool scripts include `__BROWSER_KERNEL__` for same-origin target resolution helpers and a
//! single config placeholder. [`BrowserKernelTemplateShell`] expands the kernel once, then
//! each render only splices JSON config between fixed prefix/suffix strings.

use serde_json::Value;
use std::sync::OnceLock;

const BROWSER_KERNEL_JS: &str = include_str!("browser_kernel.js");

/// Compiled tool probe template with the shared browser-kernel script inlined once.
///
/// Split around a single config placeholder so each render only splices JSON config
/// between the fixed prefix/suffix without re-expanding `__BROWSER_KERNEL__`.
pub(crate) struct BrowserKernelTemplateShell {
    prefix: String,
    suffix: String,
}

impl BrowserKernelTemplateShell {
    /// Expand `__BROWSER_KERNEL__` and split the template on exactly one config placeholder.
    fn compile(template: &'static str, config_placeholder: &'static str) -> Self {
        let expanded = template.replace("__BROWSER_KERNEL__", BROWSER_KERNEL_JS);
        let mut parts = expanded.split(config_placeholder);
        let prefix = parts
            .next()
            .expect("expanded browser-kernel template should have a prefix");
        let suffix = parts
            .next()
            .expect("browser-kernel template must contain exactly one config placeholder");
        assert!(
            parts.next().is_none(),
            "browser-kernel template must contain exactly one config placeholder"
        );

        Self {
            prefix: prefix.to_string(),
            suffix: suffix.to_string(),
        }
    }

    /// Splice serialized `config` between the compiled prefix and suffix.
    fn render(&self, config: &Value) -> String {
        let config_json = config.to_string();
        let mut rendered =
            String::with_capacity(self.prefix.len() + config_json.len() + self.suffix.len());
        rendered.push_str(&self.prefix);
        rendered.push_str(&config_json);
        rendered.push_str(&self.suffix);
        rendered
    }
}

/// Lazily compile a tool probe template and render it with the given JSON config.
///
/// `shell_cache` should be a process-static [`OnceLock`] so kernel expansion runs once per
/// template. Panics if `template` does not contain exactly one `config_placeholder`.
pub(crate) fn render_browser_kernel_script(
    shell_cache: &OnceLock<BrowserKernelTemplateShell>,
    template: &'static str,
    config_placeholder: &'static str,
    config: &Value,
) -> String {
    shell_cache
        .get_or_init(|| BrowserKernelTemplateShell::compile(template, config_placeholder))
        .render(config)
}

#[cfg(test)]
mod tests {
    use super::BrowserKernelTemplateShell;
    use super::render_browser_kernel_script;
    use std::sync::OnceLock;

    #[test]
    fn test_render_browser_kernel_script_injects_kernel_and_config() {
        let shell = OnceLock::new();
        let rendered = render_browser_kernel_script(
            &shell,
            "const config = __CONFIG__;\n__BROWSER_KERNEL__\nreturn config;",
            "__CONFIG__",
            &serde_json::json!({ "selector": "#save" }),
        );

        assert!(rendered.contains(r##"const config = {"selector":"#save"};"##));
        assert!(rendered.contains("function resolveTargetMatch(config, options)"));
        assert!(rendered.contains("findActionableIndexForElement(selectorMatch.element)"));
        assert!(
            rendered.contains("const indexedMatch = searchActionableIndex(config.target_index)")
        );
        assert!(
            !rendered.contains("(selectorMatch.frame_depth || 0) === 0"),
            "target_index reconciliation must also apply to same-origin iframe matches"
        );
        assert!(!rendered.contains("__BROWSER_KERNEL__"));
        assert!(!rendered.contains("__CONFIG__"));
    }

    #[test]
    fn test_browser_kernel_template_shell_renders_equivalent_output() {
        let shell = BrowserKernelTemplateShell::compile(
            "const config = __CONFIG__;\n__BROWSER_KERNEL__\nreturn config.selector;",
            "__CONFIG__",
        );

        let rendered = shell.render(&serde_json::json!({ "selector": "#save" }));

        assert!(rendered.starts_with(r##"const config = {"selector":"#save"};"##));
        assert!(rendered.contains("function resolveTargetMatch(config, options)"));
        assert!(rendered.ends_with("\nreturn config.selector;"));
    }
}