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
11use crate::domain::DomainFilter;
12
13/// Viewport dimensions for screenshots.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Viewport {
16    pub width: u32,
17    pub height: u32,
18}
19
20/// Preset extraction types.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ExtractionPreset {
24    Links,
25    Headings,
26    Tables,
27}
28
29/// A single extracted element from a web page.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ExtractedElement {
32    pub tag: String,
33    pub text: String,
34    pub attributes: HashMap<String, String>,
35}
36
37/// Screenshot response plus the browser's final URL after redirects.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ScreenshotOutput {
40    pub base64: String,
41    pub final_url: String,
42}
43
44/// Extraction response plus the browser's final URL after redirects.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ExtractOutput {
47    pub elements: Vec<ExtractedElement>,
48    pub final_url: String,
49}
50
51#[derive(Debug, Clone, Serialize)]
52#[serde(rename_all = "camelCase")]
53pub(crate) struct BrowserNavigationFilter {
54    allowlist: Vec<String>,
55    denylist: Vec<String>,
56    block_private_ips: bool,
57}
58
59/// Request sent to the Playwright bridge subprocess.
60#[derive(Debug, Serialize)]
61#[serde(tag = "action", rename_all = "lowercase")]
62pub enum PlaywrightRequest {
63    Screenshot {
64        id: u64,
65        url: String,
66        viewport: Option<Viewport>,
67        filter: Option<BrowserNavigationFilter>,
68    },
69    Extract {
70        id: u64,
71        url: String,
72        selector: Option<String>,
73        preset: Option<ExtractionPreset>,
74        filter: Option<BrowserNavigationFilter>,
75    },
76    Ping {
77        id: u64,
78    },
79}
80
81/// Response from the Playwright bridge subprocess.
82#[derive(Debug, Deserialize)]
83pub struct PlaywrightResponse {
84    pub id: u64,
85    pub ok: bool,
86    pub data: Option<serde_json::Value>,
87    pub error: Option<String>,
88}
89
90/// Errors from the Playwright bridge.
91#[derive(Debug, thiserror::Error)]
92pub enum PlaywrightError {
93    #[error(
94        "Playwright/Node.js not found. Install with: npm install -g playwright && npx playwright install chromium"
95    )]
96    NotInstalled,
97    #[error("Bridge communication error: {0}")]
98    Communication(String),
99    #[error("Bridge returned error: {0}")]
100    BridgeError(String),
101    #[error("Operation timed out after {0:?}")]
102    Timeout(Duration),
103    #[error("IO error: {0}")]
104    Io(#[from] std::io::Error),
105}
106
107/// Default timeout for bridge operations (30 seconds).
108const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
109const BRIDGE_SCRIPT: &str = include_str!("playwright_bridge.js");
110static BRIDGE_SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
111
112/// Bridge to a Playwright Node.js subprocess for headless browser operations.
113///
114/// Spawns a child process running the embedded `playwright_bridge.js` script
115/// and communicates via JSON lines on stdin/stdout.
116pub struct PlaywrightBridge {
117    _child: Child,
118    stdin: BufWriter<tokio::process::ChildStdin>,
119    stdout: BufReader<tokio::process::ChildStdout>,
120    next_id: AtomicU64,
121    /// Path to the temp JS file — kept alive for the process lifetime.
122    _bridge_script: PathBuf,
123}
124
125impl PlaywrightBridge {
126    /// Start the Playwright bridge subprocess.
127    ///
128    /// Writes the embedded bridge JS to a temp file, spawns `node` to run it,
129    /// and verifies the bridge is alive with a ping.
130    ///
131    /// `playwright_path` optionally overrides the Node.js binary path.
132    pub async fn start(playwright_path: Option<&Path>) -> Result<Self, PlaywrightError> {
133        let script_path = write_bridge_script_temp_file().await?;
134
135        // Resolve node binary path.
136        let node_path = resolve_node_path(playwright_path);
137
138        // Spawn the bridge process.
139        let mut child = Command::new(&node_path)
140            .arg(&script_path)
141            .stdin(Stdio::piped())
142            .stdout(Stdio::piped())
143            .stderr(Stdio::null())
144            .kill_on_drop(true)
145            .spawn()
146            .map_err(|e| {
147                if e.kind() == std::io::ErrorKind::NotFound {
148                    PlaywrightError::NotInstalled
149                } else {
150                    PlaywrightError::Io(e)
151                }
152            })?;
153
154        let stdin = child
155            .stdin
156            .take()
157            .ok_or_else(|| PlaywrightError::Communication("failed to open stdin".into()))?;
158        let stdout = child
159            .stdout
160            .take()
161            .ok_or_else(|| PlaywrightError::Communication("failed to open stdout".into()))?;
162
163        let mut bridge = Self {
164            _child: child,
165            stdin: BufWriter::new(stdin),
166            stdout: BufReader::new(stdout),
167            next_id: AtomicU64::new(1),
168            _bridge_script: script_path,
169        };
170
171        // Verify the bridge is alive.
172        let id = bridge.next_id();
173        let resp = bridge.send_request(PlaywrightRequest::Ping { id }).await?;
174        if !resp.ok {
175            return Err(PlaywrightError::Communication(
176                resp.error.unwrap_or_else(|| "ping failed".into()),
177            ));
178        }
179
180        Ok(bridge)
181    }
182
183    /// Send a request to the bridge and return the response.
184    ///
185    /// Applies a 30-second timeout to the entire operation.
186    pub async fn send_request(
187        &mut self,
188        request: PlaywrightRequest,
189    ) -> Result<PlaywrightResponse, PlaywrightError> {
190        let result = tokio::time::timeout(DEFAULT_TIMEOUT, self.send_request_inner(request)).await;
191        match result {
192            Ok(inner) => inner,
193            Err(_) => Err(PlaywrightError::Timeout(DEFAULT_TIMEOUT)),
194        }
195    }
196
197    /// Take a screenshot of a web page and return the base64-encoded PNG.
198    pub async fn screenshot(
199        &mut self,
200        url: &str,
201        viewport: Option<Viewport>,
202        domain_filter: Option<&DomainFilter>,
203    ) -> Result<ScreenshotOutput, PlaywrightError> {
204        let id = self.next_id();
205        let resp = self
206            .send_request(PlaywrightRequest::Screenshot {
207                id,
208                url: url.to_owned(),
209                viewport,
210                filter: domain_filter.map(BrowserNavigationFilter::from),
211            })
212            .await?;
213
214        if !resp.ok {
215            return Err(PlaywrightError::BridgeError(
216                resp.error.unwrap_or_else(|| "screenshot failed".into()),
217            ));
218        }
219
220        parse_screenshot_data(resp.data, url)
221    }
222
223    /// Extract elements from a web page using a CSS selector or preset.
224    pub async fn extract(
225        &mut self,
226        url: &str,
227        selector: Option<&str>,
228        preset: Option<ExtractionPreset>,
229        domain_filter: Option<&DomainFilter>,
230    ) -> Result<ExtractOutput, PlaywrightError> {
231        let id = self.next_id();
232        let resp = self
233            .send_request(PlaywrightRequest::Extract {
234                id,
235                url: url.to_owned(),
236                selector: selector.map(String::from),
237                preset,
238                filter: domain_filter.map(BrowserNavigationFilter::from),
239            })
240            .await?;
241
242        if !resp.ok {
243            return Err(PlaywrightError::BridgeError(
244                resp.error.unwrap_or_else(|| "extraction failed".into()),
245            ));
246        }
247
248        parse_extract_data(resp.data, url)
249    }
250
251    // ── Internals ──────────────────────────────────────────────────────────
252
253    fn next_id(&self) -> u64 {
254        self.next_id.fetch_add(1, Ordering::Relaxed)
255    }
256
257    async fn send_request_inner(
258        &mut self,
259        request: PlaywrightRequest,
260    ) -> Result<PlaywrightResponse, PlaywrightError> {
261        let mut line = serde_json::to_string(&request)
262            .map_err(|e| PlaywrightError::Communication(format!("serialize error: {e}")))?;
263        line.push('\n');
264
265        self.stdin
266            .write_all(line.as_bytes())
267            .await
268            .map_err(|e| PlaywrightError::Communication(format!("write error: {e}")))?;
269        self.stdin
270            .flush()
271            .await
272            .map_err(|e| PlaywrightError::Communication(format!("flush error: {e}")))?;
273
274        let mut response_line = String::new();
275        let bytes_read = self
276            .stdout
277            .read_line(&mut response_line)
278            .await
279            .map_err(|e| PlaywrightError::Communication(format!("read error: {e}")))?;
280
281        if bytes_read == 0 {
282            return Err(PlaywrightError::Communication(
283                "bridge process closed stdout".into(),
284            ));
285        }
286
287        let response: PlaywrightResponse = serde_json::from_str(&response_line)
288            .map_err(|e| PlaywrightError::Communication(format!("deserialize error: {e}")))?;
289
290        let expected_id = match &request {
291            PlaywrightRequest::Screenshot { id, .. }
292            | PlaywrightRequest::Extract { id, .. }
293            | PlaywrightRequest::Ping { id } => *id,
294        };
295
296        if response.id != expected_id {
297            return Err(PlaywrightError::Communication(format!(
298                "response id mismatch: expected {expected_id}, got {}",
299                response.id
300            )));
301        }
302
303        Ok(response)
304    }
305}
306
307impl From<&DomainFilter> for BrowserNavigationFilter {
308    fn from(filter: &DomainFilter) -> Self {
309        Self {
310            allowlist: filter.allowlist.clone(),
311            denylist: filter.denylist.clone(),
312            block_private_ips: filter.block_private_ips,
313        }
314    }
315}
316
317fn parse_screenshot_data(
318    data: Option<serde_json::Value>,
319    requested_url: &str,
320) -> Result<ScreenshotOutput, PlaywrightError> {
321    let data =
322        data.ok_or_else(|| PlaywrightError::Communication("missing screenshot data".into()))?;
323
324    if let Some(base64) = data.as_str() {
325        return Ok(ScreenshotOutput {
326            base64: base64.to_owned(),
327            final_url: requested_url.to_owned(),
328        });
329    }
330
331    let base64 = data
332        .get("image")
333        .and_then(serde_json::Value::as_str)
334        .ok_or_else(|| PlaywrightError::Communication("missing screenshot image data".into()))?
335        .to_owned();
336    let final_url = data
337        .get("finalUrl")
338        .and_then(serde_json::Value::as_str)
339        .unwrap_or(requested_url)
340        .to_owned();
341
342    Ok(ScreenshotOutput { base64, final_url })
343}
344
345fn parse_extract_data(
346    data: Option<serde_json::Value>,
347    requested_url: &str,
348) -> Result<ExtractOutput, PlaywrightError> {
349    let data =
350        data.ok_or_else(|| PlaywrightError::Communication("missing extraction data".into()))?;
351
352    if data.is_array() {
353        let elements = serde_json::from_value(data).map_err(|error| {
354            PlaywrightError::Communication(format!("failed to parse elements: {error}"))
355        })?;
356        return Ok(ExtractOutput {
357            elements,
358            final_url: requested_url.to_owned(),
359        });
360    }
361
362    let elements_value = data
363        .get("elements")
364        .cloned()
365        .ok_or_else(|| PlaywrightError::Communication("missing extraction elements".into()))?;
366    let elements = serde_json::from_value(elements_value).map_err(|error| {
367        PlaywrightError::Communication(format!("failed to parse elements: {error}"))
368    })?;
369    let final_url = data
370        .get("finalUrl")
371        .and_then(serde_json::Value::as_str)
372        .unwrap_or(requested_url)
373        .to_owned();
374
375    Ok(ExtractOutput {
376        elements,
377        final_url,
378    })
379}
380
381async fn write_bridge_script_temp_file() -> Result<PathBuf, PlaywrightError> {
382    let sequence = BRIDGE_SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
383    let timestamp = SystemTime::now()
384        .duration_since(UNIX_EPOCH)
385        .map_or(0, |duration| duration.as_nanos());
386    let script_path = std::env::temp_dir().join(format!(
387        "swink_playwright_bridge_{}_{}_{}.js",
388        std::process::id(),
389        timestamp,
390        sequence
391    ));
392
393    tokio::fs::write(&script_path, BRIDGE_SCRIPT).await?;
394    Ok(script_path)
395}
396
397/// Resolve the path to the `node` binary.
398///
399/// Priority: explicit path > `which node` > bare `"node"`.
400fn resolve_node_path(explicit: Option<&Path>) -> PathBuf {
401    if let Some(p) = explicit {
402        return p.to_path_buf();
403    }
404
405    // Try `which node` synchronously (called once at startup, acceptable).
406    if let Ok(output) = std::process::Command::new("which").arg("node").output()
407        && output.status.success()
408    {
409        let path_str = String::from_utf8_lossy(&output.stdout).trim().to_owned();
410        if !path_str.is_empty() {
411            return PathBuf::from(path_str);
412        }
413    }
414
415    PathBuf::from("node")
416}
417
418#[cfg(test)]
419mod tests {
420    use std::collections::HashSet;
421    use std::path::Path;
422    use std::process::Command as StdCommand;
423
424    use serde_json::json;
425
426    use super::{
427        BRIDGE_SCRIPT, parse_extract_data, parse_screenshot_data, resolve_node_path,
428        write_bridge_script_temp_file,
429    };
430
431    #[tokio::test]
432    async fn writes_unique_bridge_scripts_for_concurrent_startups() {
433        let handles = (0..8).map(|_| tokio::spawn(write_bridge_script_temp_file()));
434        let mut paths = Vec::new();
435
436        for handle in handles {
437            let path = handle
438                .await
439                .expect("task should complete")
440                .expect("temp script creation should succeed");
441            paths.push(path);
442        }
443
444        let unique_paths: HashSet<_> = paths.iter().cloned().collect();
445        assert_eq!(unique_paths.len(), paths.len());
446
447        for path in &paths {
448            let contents = tokio::fs::read_to_string(path)
449                .await
450                .expect("script contents should be readable");
451            assert_eq!(contents, BRIDGE_SCRIPT);
452            tokio::fs::remove_file(path)
453                .await
454                .expect("temp script cleanup should succeed");
455        }
456    }
457
458    #[test]
459    fn bridge_script_blocks_special_use_hosts() {
460        let bridge_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/playwright_bridge.js");
461        let node_script = format!(
462            r"
463const bridge = require({bridge_path});
464
465for (const host of [
466  '0.0.0.0',
467  '100.64.0.1',
468  '198.18.0.1',
469  '192.0.2.1',
470  '224.0.0.1',
471  '::',
472  '::1',
473  'fc00::1',
474  'fd00::1',
475  'fe80::1',
476  'ff02::1',
477  '2001:db8::1',
478  '::ffff:10.0.0.1',
479  '::ffff:127.0.0.1',
480  '0:0:0:0:0:ffff:c0a8:0101',
481]) {{
482  if (!bridge.isBlockedPrivateHost(host)) {{
483    throw new Error('private host should be blocked: ' + host);
484  }}
485}}
486for (const host of ['93.184.216.34', '2606:4700:4700::1111', '::ffff:93.184.216.34']) {{
487  if (bridge.isBlockedPrivateHost(host)) {{
488    throw new Error('public host should not be blocked: ' + host);
489  }}
490}}
491",
492            bridge_path = serde_json::to_string(&bridge_path.display().to_string())
493                .expect("path should serialize"),
494        );
495
496        let output = StdCommand::new(resolve_node_path(None))
497            .arg("-e")
498            .arg(node_script)
499            .output()
500            .expect("node should run bridge host assertions");
501
502        assert!(
503            output.status.success(),
504            "node assertions failed: {}",
505            String::from_utf8_lossy(&output.stderr)
506        );
507    }
508
509    #[test]
510    fn bridge_script_filters_requests_without_playwright() {
511        let bridge_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/playwright_bridge.js");
512        let node_script = format!(
513            r"
514const bridge = require({bridge_path});
515
516void (async () => {{
517async function resolvesTo(addresses) {{
518  return addresses.map((address) => {{
519    const family = address.includes(':') ? 6 : 4;
520    return {{ address, family }};
521  }});
522}}
523
524if (!bridge.isBlockedPrivateHost('127.0.0.1') || !bridge.isBlockedPrivateHost('localhost')) {{
525  throw new Error('private host detection failed');
526}}
527if (await bridge.blockedByFilter('https://evil.com/path', {{ allowlist: [], denylist: ['evil.com'], blockPrivateIps: true }}) === null) {{
528  throw new Error('denylist filter failed');
529}}
530if (await bridge.blockedByFilter('http://127.0.0.1/admin', {{ allowlist: [], denylist: [], blockPrivateIps: true }}) === null) {{
531  throw new Error('private IP filter failed');
532}}
533if (await bridge.blockedByFilter(
534  'https://internal.example/admin',
535  {{ allowlist: [], denylist: [], blockPrivateIps: true }},
536  async () => resolvesTo(['10.0.0.5'])
537) === null) {{
538  throw new Error('resolved private subresource filter failed');
539}}
540if (await bridge.blockedByFilter(
541  'https://public.example/',
542  {{ allowlist: [], denylist: [], blockPrivateIps: true }},
543  async () => resolvesTo(['93.184.216.34'])
544) !== null) {{
545  throw new Error('public resolved address should not be blocked');
546}}
547if (!String(await bridge.blockedByFilter(
548  'https://unresolvable.example/',
549  {{ allowlist: [], denylist: [], blockPrivateIps: true }},
550  async () => {{ throw new Error('lookup failed'); }}
551)).includes('DNS resolution failed')) {{
552  throw new Error('DNS failures should fail closed');
553}}
554}})().catch((error) => {{
555  console.error(error.stack || error);
556  process.exit(1);
557}});
558",
559            bridge_path = serde_json::to_string(&bridge_path.display().to_string())
560                .expect("path should serialize"),
561        );
562
563        let output = StdCommand::new(resolve_node_path(None))
564            .arg("-e")
565            .arg(node_script)
566            .output()
567            .expect("node should run bridge filter assertions");
568
569        assert!(
570            output.status.success(),
571            "node assertions failed: {}",
572            String::from_utf8_lossy(&output.stderr)
573        );
574    }
575
576    #[test]
577    fn bridge_script_uses_context_routing_with_service_workers_blocked() {
578        let bridge_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/playwright_bridge.js");
579        let node_script = format!(
580            r"
581const bridge = require({bridge_path});
582
583void (async () => {{
584const options = bridge.newContextOptions({{ viewport: {{ width: 640, height: 480 }} }});
585if (options.serviceWorkers !== 'block') {{
586  throw new Error('service workers should be blocked for routed browser contexts');
587}}
588if (options.viewport.width !== 640 || options.viewport.height !== 480) {{
589  throw new Error('viewport options were not preserved: ' + JSON.stringify(options));
590}}
591const proxiedOptions = bridge.newContextOptions(
592  {{ viewport: {{ width: 800, height: 600 }} }},
593  {{ server: 'http://127.0.0.1:43210' }}
594);
595if (proxiedOptions.proxy.server !== 'http://127.0.0.1:43210') {{
596  throw new Error('proxy options were not installed: ' + JSON.stringify(proxiedOptions));
597}}
598
599let pattern = null;
600let abortReason = null;
601const context = {{
602  async route(routePattern, handler) {{
603    pattern = routePattern;
604    await handler({{
605      request() {{
606        return {{ url() {{ return 'http://127.0.0.1/admin'; }} }};
607      }},
608      async abort(reason) {{ abortReason = reason; }},
609      async continue() {{ throw new Error('blocked private request should not continue'); }},
610    }});
611  }},
612}};
613
614const blockedReason = await bridge.installNavigationFilter(
615  context,
616  {{ allowlist: [], denylist: [], blockPrivateIps: true }}
617);
618if (pattern !== '**/*') {{
619  throw new Error('context route should cover all requests, got: ' + pattern);
620}}
621if (abortReason !== 'blockedbyclient') {{
622  throw new Error('blocked request should be aborted by client, got: ' + abortReason);
623}}
624if (!String(blockedReason()).includes('private/internal host')) {{
625  throw new Error('blocked reason was not retained: ' + blockedReason());
626}}
627}})().catch((error) => {{
628  console.error(error.stack || error);
629  process.exit(1);
630}});
631",
632            bridge_path = serde_json::to_string(&bridge_path.display().to_string())
633                .expect("path should serialize"),
634        );
635
636        let output = StdCommand::new(resolve_node_path(None))
637            .arg("-e")
638            .arg(node_script)
639            .output()
640            .expect("node should run bridge context routing assertions");
641
642        assert!(
643            output.status.success(),
644            "node assertions failed: {}",
645            String::from_utf8_lossy(&output.stderr)
646        );
647    }
648
649    #[test]
650    fn bridge_script_resolves_proxy_targets_with_single_checked_address() {
651        let bridge_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/playwright_bridge.js");
652        let node_script = format!(
653            r"
654const bridge = require({bridge_path});
655
656void (async () => {{
657async function resolvesTo(addresses) {{
658  return addresses.map((address) => {{
659    const family = address.includes(':') ? 6 : 4;
660    return {{ address, family }};
661  }});
662}}
663
664const filter = {{ allowlist: [], denylist: [], blockPrivateIps: true }};
665if (!bridge.filterNeedsProxy(filter)) {{
666  throw new Error('private-IP filtering should enable the browser proxy');
667}}
668
669const target = await bridge.resolveProxyTarget(
670  new URL('https://public.example/path?q=1'),
671  filter,
672  async () => resolvesTo(['93.184.216.34'])
673);
674if (target.address !== '93.184.216.34' || target.host !== 'public.example' || target.port !== 443) {{
675  throw new Error('unexpected proxy target: ' + JSON.stringify(target));
676}}
677
678try {{
679  await bridge.resolveProxyTarget(
680    new URL('https://rebind.example/'),
681    filter,
682    async () => resolvesTo(['93.184.216.34', '10.0.0.5'])
683  );
684  throw new Error('mixed public/private DNS answers should fail closed');
685}} catch (error) {{
686  if (!String(error.message).includes('private/internal host')) {{
687    throw error;
688  }}
689}}
690
691try {{
692  await bridge.resolveProxyTarget(
693    new URL('https://unresolvable.example/'),
694    filter,
695    async () => {{ throw new Error('lookup failed'); }}
696  );
697  throw new Error('DNS lookup failures should fail closed');
698}} catch (error) {{
699  if (!String(error.message).includes('DNS resolution failed')) {{
700    throw error;
701  }}
702}}
703}})().catch((error) => {{
704  console.error(error.stack || error);
705  process.exit(1);
706}});
707",
708            bridge_path = serde_json::to_string(&bridge_path.display().to_string())
709                .expect("path should serialize"),
710        );
711
712        let output = StdCommand::new(resolve_node_path(None))
713            .arg("-e")
714            .arg(node_script)
715            .output()
716            .expect("node should run bridge proxy assertions");
717
718        assert!(
719            output.status.success(),
720            "node assertions failed: {}",
721            String::from_utf8_lossy(&output.stderr)
722        );
723    }
724
725    #[test]
726    fn bridge_script_exports_data_only_extract_helpers() {
727        assert!(!BRIDGE_SCRIPT.contains("eval("));
728
729        let bridge_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/playwright_bridge.js");
730        let node_script = format!(
731            r"
732const bridge = require({bridge_path});
733
734const linksPlan = bridge.buildExtractionPlan({{ preset: 'links' }});
735if (linksPlan.selector !== 'a[href]' || linksPlan.preset !== 'links') {{
736  throw new Error('unexpected links plan: ' + JSON.stringify(linksPlan));
737}}
738
739const selectorPlan = bridge.buildExtractionPlan({{ selector: '.card' }});
740if (selectorPlan.selector !== '.card' || selectorPlan.preset !== null) {{
741  throw new Error('unexpected selector plan: ' + JSON.stringify(selectorPlan));
742}}
743
744const customElement = bridge.extractElementData(
745  {{
746    tagName: 'DIV',
747    textContent: '  Hello world  ',
748    attributes: [{{ name: 'data-id', value: '42' }}],
749    getAttribute(name) {{
750      return name === 'data-id' ? '42' : null;
751    }},
752    innerHTML: '<span>Hello world</span>',
753  }},
754  null
755);
756if (customElement.tag !== 'div' || customElement.text !== 'Hello world' || customElement.attributes['data-id'] !== '42') {{
757  throw new Error('unexpected custom element: ' + JSON.stringify(customElement));
758}}
759
760const linkElement = bridge.extractElementData(
761  {{
762    tagName: 'A',
763    textContent: ' Docs ',
764    attributes: [{{ name: 'href', value: '/docs' }}],
765    getAttribute(name) {{
766      return name === 'href' ? '/docs' : null;
767    }},
768    innerHTML: 'Docs',
769  }},
770  'links'
771);
772if (linkElement.attributes.href !== '/docs' || Object.keys(linkElement.attributes).length !== 1) {{
773  throw new Error('unexpected link element: ' + JSON.stringify(linkElement));
774}}
775
776const headingElement = bridge.extractElementData(
777  {{
778    tagName: 'H2',
779    textContent: ' Section ',
780    attributes: [{{ name: 'id', value: 'section' }}],
781    getAttribute() {{
782      return null;
783    }},
784    innerHTML: 'Section',
785  }},
786  'headings'
787);
788if (headingElement.tag !== 'h2' || headingElement.text !== 'Section' || Object.keys(headingElement.attributes).length !== 0) {{
789  throw new Error('unexpected heading element: ' + JSON.stringify(headingElement));
790}}
791
792const tableElement = bridge.extractElementData(
793  {{
794    tagName: 'TABLE',
795    textContent: '',
796    attributes: [],
797    getAttribute() {{
798      return null;
799    }},
800    innerHTML: '<tbody><tr><td>value</td></tr></tbody>',
801  }},
802  'tables'
803);
804if (tableElement.tag !== 'table' || !tableElement.text.includes('<tbody>')) {{
805  throw new Error('unexpected table element: ' + JSON.stringify(tableElement));
806}}
807",
808            bridge_path = serde_json::to_string(&bridge_path.display().to_string())
809                .expect("path should serialize"),
810        );
811
812        let output = StdCommand::new(resolve_node_path(None))
813            .arg("-e")
814            .arg(node_script)
815            .output()
816            .expect("node should run bridge helper assertions");
817
818        assert!(
819            output.status.success(),
820            "node assertions failed: {}",
821            String::from_utf8_lossy(&output.stderr)
822        );
823    }
824
825    #[test]
826    fn screenshot_data_carries_final_url() {
827        let output = parse_screenshot_data(
828            Some(json!({
829                "image": "abc123",
830                "finalUrl": "https://example.com/final",
831            })),
832            "https://example.com/start",
833        )
834        .unwrap();
835
836        assert_eq!(output.base64, "abc123");
837        assert_eq!(output.final_url, "https://example.com/final");
838    }
839
840    #[test]
841    fn extract_data_carries_final_url() {
842        let output = parse_extract_data(
843            Some(json!({
844                "elements": [
845                    {
846                        "tag": "a",
847                        "text": "Docs",
848                        "attributes": { "href": "/docs" },
849                    }
850                ],
851                "finalUrl": "https://example.com/final",
852            })),
853            "https://example.com/start",
854        )
855        .unwrap();
856
857        assert_eq!(output.final_url, "https://example.com/final");
858        assert_eq!(output.elements.len(), 1);
859        assert_eq!(output.elements[0].tag, "a");
860    }
861}