Skip to main content

swink_agent_plugin_web/
playwright.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use serde::{Deserialize, Serialize};
8use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
9use tokio::process::{Child, Command};
10
11/// Viewport dimensions for screenshots.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Viewport {
14    pub width: u32,
15    pub height: u32,
16}
17
18/// Preset extraction types.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum ExtractionPreset {
22    Links,
23    Headings,
24    Tables,
25}
26
27/// A single extracted element from a web page.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ExtractedElement {
30    pub tag: String,
31    pub text: String,
32    pub attributes: HashMap<String, String>,
33}
34
35/// Request sent to the Playwright bridge subprocess.
36#[derive(Debug, Serialize)]
37#[serde(tag = "action", rename_all = "lowercase")]
38pub enum PlaywrightRequest {
39    Screenshot {
40        id: u64,
41        url: String,
42        viewport: Option<Viewport>,
43    },
44    Extract {
45        id: u64,
46        url: String,
47        selector: Option<String>,
48        preset: Option<ExtractionPreset>,
49    },
50    Ping {
51        id: u64,
52    },
53}
54
55/// Response from the Playwright bridge subprocess.
56#[derive(Debug, Deserialize)]
57pub struct PlaywrightResponse {
58    pub id: u64,
59    pub ok: bool,
60    pub data: Option<serde_json::Value>,
61    pub error: Option<String>,
62}
63
64/// Errors from the Playwright bridge.
65#[derive(Debug, thiserror::Error)]
66pub enum PlaywrightError {
67    #[error(
68        "Playwright/Node.js not found. Install with: npm install -g playwright && npx playwright install chromium"
69    )]
70    NotInstalled,
71    #[error("Bridge communication error: {0}")]
72    Communication(String),
73    #[error("Bridge returned error: {0}")]
74    BridgeError(String),
75    #[error("Operation timed out after {0:?}")]
76    Timeout(Duration),
77    #[error("IO error: {0}")]
78    Io(#[from] std::io::Error),
79}
80
81/// Default timeout for bridge operations (30 seconds).
82const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
83const BRIDGE_SCRIPT: &str = include_str!("playwright_bridge.js");
84static BRIDGE_SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
85
86/// Bridge to a Playwright Node.js subprocess for headless browser operations.
87///
88/// Spawns a child process running the embedded `playwright_bridge.js` script
89/// and communicates via JSON lines on stdin/stdout.
90pub struct PlaywrightBridge {
91    _child: Child,
92    stdin: BufWriter<tokio::process::ChildStdin>,
93    stdout: BufReader<tokio::process::ChildStdout>,
94    next_id: AtomicU64,
95    /// Path to the temp JS file — kept alive for the process lifetime.
96    _bridge_script: PathBuf,
97}
98
99impl PlaywrightBridge {
100    /// Start the Playwright bridge subprocess.
101    ///
102    /// Writes the embedded bridge JS to a temp file, spawns `node` to run it,
103    /// and verifies the bridge is alive with a ping.
104    ///
105    /// `playwright_path` optionally overrides the Node.js binary path.
106    pub async fn start(playwright_path: Option<&Path>) -> Result<Self, PlaywrightError> {
107        let script_path = write_bridge_script_temp_file().await?;
108
109        // Resolve node binary path.
110        let node_path = resolve_node_path(playwright_path);
111
112        // Spawn the bridge process.
113        let mut child = Command::new(&node_path)
114            .arg(&script_path)
115            .stdin(Stdio::piped())
116            .stdout(Stdio::piped())
117            .stderr(Stdio::null())
118            .kill_on_drop(true)
119            .spawn()
120            .map_err(|e| {
121                if e.kind() == std::io::ErrorKind::NotFound {
122                    PlaywrightError::NotInstalled
123                } else {
124                    PlaywrightError::Io(e)
125                }
126            })?;
127
128        let stdin = child
129            .stdin
130            .take()
131            .ok_or_else(|| PlaywrightError::Communication("failed to open stdin".into()))?;
132        let stdout = child
133            .stdout
134            .take()
135            .ok_or_else(|| PlaywrightError::Communication("failed to open stdout".into()))?;
136
137        let mut bridge = Self {
138            _child: child,
139            stdin: BufWriter::new(stdin),
140            stdout: BufReader::new(stdout),
141            next_id: AtomicU64::new(1),
142            _bridge_script: script_path,
143        };
144
145        // Verify the bridge is alive.
146        let id = bridge.next_id();
147        let resp = bridge.send_request(PlaywrightRequest::Ping { id }).await?;
148        if !resp.ok {
149            return Err(PlaywrightError::Communication(
150                resp.error.unwrap_or_else(|| "ping failed".into()),
151            ));
152        }
153
154        Ok(bridge)
155    }
156
157    /// Send a request to the bridge and return the response.
158    ///
159    /// Applies a 30-second timeout to the entire operation.
160    pub async fn send_request(
161        &mut self,
162        request: PlaywrightRequest,
163    ) -> Result<PlaywrightResponse, PlaywrightError> {
164        let result = tokio::time::timeout(DEFAULT_TIMEOUT, self.send_request_inner(request)).await;
165        match result {
166            Ok(inner) => inner,
167            Err(_) => Err(PlaywrightError::Timeout(DEFAULT_TIMEOUT)),
168        }
169    }
170
171    /// Take a screenshot of a web page and return the base64-encoded PNG.
172    pub async fn screenshot(
173        &mut self,
174        url: &str,
175        viewport: Option<Viewport>,
176    ) -> Result<String, PlaywrightError> {
177        let id = self.next_id();
178        let resp = self
179            .send_request(PlaywrightRequest::Screenshot {
180                id,
181                url: url.to_owned(),
182                viewport,
183            })
184            .await?;
185
186        if !resp.ok {
187            return Err(PlaywrightError::BridgeError(
188                resp.error.unwrap_or_else(|| "screenshot failed".into()),
189            ));
190        }
191
192        resp.data
193            .and_then(|v| v.as_str().map(String::from))
194            .ok_or_else(|| PlaywrightError::Communication("missing screenshot data".into()))
195    }
196
197    /// Extract elements from a web page using a CSS selector or preset.
198    pub async fn extract(
199        &mut self,
200        url: &str,
201        selector: Option<&str>,
202        preset: Option<ExtractionPreset>,
203    ) -> Result<Vec<ExtractedElement>, PlaywrightError> {
204        let id = self.next_id();
205        let resp = self
206            .send_request(PlaywrightRequest::Extract {
207                id,
208                url: url.to_owned(),
209                selector: selector.map(String::from),
210                preset,
211            })
212            .await?;
213
214        if !resp.ok {
215            return Err(PlaywrightError::BridgeError(
216                resp.error.unwrap_or_else(|| "extraction failed".into()),
217            ));
218        }
219
220        let data = resp
221            .data
222            .ok_or_else(|| PlaywrightError::Communication("missing extraction data".into()))?;
223
224        serde_json::from_value(data)
225            .map_err(|e| PlaywrightError::Communication(format!("failed to parse elements: {e}")))
226    }
227
228    // ── Internals ──────────────────────────────────────────────────────────
229
230    fn next_id(&self) -> u64 {
231        self.next_id.fetch_add(1, Ordering::Relaxed)
232    }
233
234    async fn send_request_inner(
235        &mut self,
236        request: PlaywrightRequest,
237    ) -> Result<PlaywrightResponse, PlaywrightError> {
238        let mut line = serde_json::to_string(&request)
239            .map_err(|e| PlaywrightError::Communication(format!("serialize error: {e}")))?;
240        line.push('\n');
241
242        self.stdin
243            .write_all(line.as_bytes())
244            .await
245            .map_err(|e| PlaywrightError::Communication(format!("write error: {e}")))?;
246        self.stdin
247            .flush()
248            .await
249            .map_err(|e| PlaywrightError::Communication(format!("flush error: {e}")))?;
250
251        let mut response_line = String::new();
252        let bytes_read = self
253            .stdout
254            .read_line(&mut response_line)
255            .await
256            .map_err(|e| PlaywrightError::Communication(format!("read error: {e}")))?;
257
258        if bytes_read == 0 {
259            return Err(PlaywrightError::Communication(
260                "bridge process closed stdout".into(),
261            ));
262        }
263
264        let response: PlaywrightResponse = serde_json::from_str(&response_line)
265            .map_err(|e| PlaywrightError::Communication(format!("deserialize error: {e}")))?;
266
267        let expected_id = match &request {
268            PlaywrightRequest::Screenshot { id, .. }
269            | PlaywrightRequest::Extract { id, .. }
270            | PlaywrightRequest::Ping { id } => *id,
271        };
272
273        if response.id != expected_id {
274            return Err(PlaywrightError::Communication(format!(
275                "response id mismatch: expected {expected_id}, got {}",
276                response.id
277            )));
278        }
279
280        Ok(response)
281    }
282}
283
284async fn write_bridge_script_temp_file() -> Result<PathBuf, PlaywrightError> {
285    let sequence = BRIDGE_SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
286    let timestamp = SystemTime::now()
287        .duration_since(UNIX_EPOCH)
288        .map_or(0, |duration| duration.as_nanos());
289    let script_path = std::env::temp_dir().join(format!(
290        "swink_playwright_bridge_{}_{}_{}.js",
291        std::process::id(),
292        timestamp,
293        sequence
294    ));
295
296    tokio::fs::write(&script_path, BRIDGE_SCRIPT).await?;
297    Ok(script_path)
298}
299
300/// Resolve the path to the `node` binary.
301///
302/// Priority: explicit path > `which node` > bare `"node"`.
303fn resolve_node_path(explicit: Option<&Path>) -> PathBuf {
304    if let Some(p) = explicit {
305        return p.to_path_buf();
306    }
307
308    // Try `which node` synchronously (called once at startup, acceptable).
309    if let Ok(output) = std::process::Command::new("which").arg("node").output()
310        && output.status.success()
311    {
312        let path_str = String::from_utf8_lossy(&output.stdout).trim().to_owned();
313        if !path_str.is_empty() {
314            return PathBuf::from(path_str);
315        }
316    }
317
318    PathBuf::from("node")
319}
320
321#[cfg(test)]
322mod tests {
323    use std::collections::HashSet;
324    use std::path::Path;
325    use std::process::Command as StdCommand;
326
327    use super::{BRIDGE_SCRIPT, resolve_node_path, write_bridge_script_temp_file};
328
329    #[tokio::test]
330    async fn writes_unique_bridge_scripts_for_concurrent_startups() {
331        let handles = (0..8).map(|_| tokio::spawn(write_bridge_script_temp_file()));
332        let mut paths = Vec::new();
333
334        for handle in handles {
335            let path = handle
336                .await
337                .expect("task should complete")
338                .expect("temp script creation should succeed");
339            paths.push(path);
340        }
341
342        let unique_paths: HashSet<_> = paths.iter().cloned().collect();
343        assert_eq!(unique_paths.len(), paths.len());
344
345        for path in &paths {
346            let contents = tokio::fs::read_to_string(path)
347                .await
348                .expect("script contents should be readable");
349            assert_eq!(contents, BRIDGE_SCRIPT);
350            tokio::fs::remove_file(path)
351                .await
352                .expect("temp script cleanup should succeed");
353        }
354    }
355
356    #[test]
357    fn bridge_script_exports_data_only_extract_helpers() {
358        assert!(!BRIDGE_SCRIPT.contains("eval("));
359
360        let bridge_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/playwright_bridge.js");
361        let node_script = format!(
362            r"
363const bridge = require({bridge_path});
364
365const linksPlan = bridge.buildExtractionPlan({{ preset: 'links' }});
366if (linksPlan.selector !== 'a[href]' || linksPlan.preset !== 'links') {{
367  throw new Error('unexpected links plan: ' + JSON.stringify(linksPlan));
368}}
369
370const selectorPlan = bridge.buildExtractionPlan({{ selector: '.card' }});
371if (selectorPlan.selector !== '.card' || selectorPlan.preset !== null) {{
372  throw new Error('unexpected selector plan: ' + JSON.stringify(selectorPlan));
373}}
374
375const customElement = bridge.extractElementData(
376  {{
377    tagName: 'DIV',
378    textContent: '  Hello world  ',
379    attributes: [{{ name: 'data-id', value: '42' }}],
380    getAttribute(name) {{
381      return name === 'data-id' ? '42' : null;
382    }},
383    innerHTML: '<span>Hello world</span>',
384  }},
385  null
386);
387if (customElement.tag !== 'div' || customElement.text !== 'Hello world' || customElement.attributes['data-id'] !== '42') {{
388  throw new Error('unexpected custom element: ' + JSON.stringify(customElement));
389}}
390
391const linkElement = bridge.extractElementData(
392  {{
393    tagName: 'A',
394    textContent: ' Docs ',
395    attributes: [{{ name: 'href', value: '/docs' }}],
396    getAttribute(name) {{
397      return name === 'href' ? '/docs' : null;
398    }},
399    innerHTML: 'Docs',
400  }},
401  'links'
402);
403if (linkElement.attributes.href !== '/docs' || Object.keys(linkElement.attributes).length !== 1) {{
404  throw new Error('unexpected link element: ' + JSON.stringify(linkElement));
405}}
406
407const headingElement = bridge.extractElementData(
408  {{
409    tagName: 'H2',
410    textContent: ' Section ',
411    attributes: [{{ name: 'id', value: 'section' }}],
412    getAttribute() {{
413      return null;
414    }},
415    innerHTML: 'Section',
416  }},
417  'headings'
418);
419if (headingElement.tag !== 'h2' || headingElement.text !== 'Section' || Object.keys(headingElement.attributes).length !== 0) {{
420  throw new Error('unexpected heading element: ' + JSON.stringify(headingElement));
421}}
422
423const tableElement = bridge.extractElementData(
424  {{
425    tagName: 'TABLE',
426    textContent: '',
427    attributes: [],
428    getAttribute() {{
429      return null;
430    }},
431    innerHTML: '<tbody><tr><td>value</td></tr></tbody>',
432  }},
433  'tables'
434);
435if (tableElement.tag !== 'table' || !tableElement.text.includes('<tbody>')) {{
436  throw new Error('unexpected table element: ' + JSON.stringify(tableElement));
437}}
438",
439            bridge_path = serde_json::to_string(&bridge_path.display().to_string())
440                .expect("path should serialize"),
441        );
442
443        let output = StdCommand::new(resolve_node_path(None))
444            .arg("-e")
445            .arg(node_script)
446            .output()
447            .expect("node should run bridge helper assertions");
448
449        assert!(
450            output.status.success(),
451            "node assertions failed: {}",
452            String::from_utf8_lossy(&output.stderr)
453        );
454    }
455}