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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Viewport {
16 pub width: u32,
17 pub height: u32,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ExtractionPreset {
24 Links,
25 Headings,
26 Tables,
27}
28
29#[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#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ScreenshotOutput {
40 pub base64: String,
41 pub final_url: String,
42}
43
44#[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#[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#[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#[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
107const 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
112pub struct PlaywrightBridge {
117 _child: Child,
118 stdin: BufWriter<tokio::process::ChildStdin>,
119 stdout: BufReader<tokio::process::ChildStdout>,
120 next_id: AtomicU64,
121 _bridge_script: PathBuf,
123}
124
125impl PlaywrightBridge {
126 pub async fn start(playwright_path: Option<&Path>) -> Result<Self, PlaywrightError> {
133 let script_path = write_bridge_script_temp_file().await?;
134
135 let node_path = resolve_node_path(playwright_path);
137
138 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 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 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 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 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 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
397fn resolve_node_path(explicit: Option<&Path>) -> PathBuf {
401 if let Some(p) = explicit {
402 return p.to_path_buf();
403 }
404
405 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}