use std::process::Command;
mod common;
use common::TestBrowser;
fn run_cli(args: &[&str]) -> (String, String, i32) {
let output = Command::new(common::binary())
.args(args)
.output()
.expect("Failed to run chrome-agent");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let code = output.status.code().unwrap_or(-1);
(stdout, stderr, code)
}
fn goto_fixture(browser: &str, fixture: &str) -> bool {
let url = common::fixture_url(fixture);
let (_, stderr, code) = run_cli(&["--browser", browser, "goto", &url]);
if code != 0 {
return common::unavailable(&format!("goto {fixture} failed: {stderr}"));
}
true
}
fn extract_json(browser: &str) -> Option<serde_json::Value> {
let (stdout, stderr, code) = run_cli(&["--json", "--browser", browser, "extract"]);
if code != 0 {
eprintln!("extract failed: {stderr} {stdout}");
return None;
}
for line in stdout.lines() {
if line.starts_with('{')
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(line)
{
return Some(v);
}
}
None
}
fn extract_json_with_args(browser: &str, args: &[&str]) -> Option<serde_json::Value> {
let mut full_args = vec!["--json", "--browser", browser, "extract"];
full_args.extend_from_slice(args);
let (stdout, stderr, code) = run_cli(&full_args);
if code != 0 {
eprintln!("extract failed: {stderr} {stdout}");
return None;
}
for line in stdout.lines() {
if line.starts_with('{')
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(line)
{
return Some(v);
}
}
None
}
#[test]
fn extract_table_finds_product_rows() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-table");
if !goto_fixture(b.name(), "extract_table.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 5, "Should find 5 product rows, got {count}");
assert!(items.len() >= 5, "Should return 5 items");
let first = &items[0];
assert!(
first.get("title").and_then(|v| v.as_str()).is_some(),
"First item should have title: {first}"
);
assert!(
first.get("url").and_then(|v| v.as_str()).is_some(),
"First item should have URL: {first}"
);
let pattern = json["pattern"].as_str().unwrap_or("");
assert!(
pattern.contains("TR") || pattern.contains("tr"),
"Pattern should be TR-based, got: {pattern}"
);
}
#[test]
fn extract_cards_finds_articles() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-cards");
if !goto_fixture(b.name(), "extract_cards.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 4, "Should find 4 blog cards, got {count}");
let pattern = json["pattern"].as_str().unwrap_or("");
assert!(
pattern.contains("ARTICLE") || pattern.contains("article") || pattern.contains("post"),
"Pattern should be ARTICLE-based, got: {pattern}"
);
let first = &items[0];
let title = first.get("title").and_then(|v| v.as_str()).unwrap_or("");
assert!(
title.contains("Rust Async"),
"First title should mention Rust Async, got: {title}"
);
assert!(
items.iter().any(|item| item.get("date").is_some()),
"Should have date fields"
);
assert!(
items.iter().any(|item| item.get("image").is_some()),
"Should have image fields"
);
}
#[test]
fn extract_hn_like_finds_stories_not_vote_links() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-hn");
if !goto_fixture(b.name(), "extract_hn_like.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 4, "Should find 4 news items, got {count}");
for item in items {
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("");
assert!(
!title.contains("â–²") && title.len() > 5,
"Title should be article, not vote: '{title}'"
);
}
for item in items {
let url = item.get("url").and_then(|v| v.as_str()).unwrap_or("");
assert!(
!url.contains("/vote/"),
"URL should be article URL, not vote: {url}"
);
}
}
#[test]
fn extract_ecommerce_finds_products_not_nav() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-ecom");
if !goto_fixture(b.name(), "extract_ecommerce.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 4, "Should find 4 product cards, got {count}");
let pattern = json["pattern"].as_str().unwrap_or("");
assert!(
!pattern.to_uppercase().contains("NAV"),
"Should not extract nav pattern: {pattern}"
);
let first = &items[0];
let title = first.get("title").and_then(|v| v.as_str()).unwrap_or("");
assert!(
title.len() > 5,
"Product should have meaningful title, got: '{title}'"
);
assert!(
items.iter().any(|item| item.get("price").is_some()),
"Should have price fields"
);
assert!(
items.iter().any(|item| item.get("image").is_some()),
"Should have image fields"
);
}
#[test]
fn extract_list_finds_search_results() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-list");
if !goto_fixture(b.name(), "extract_list.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 4, "Should find >=4 search results, got {count}");
let pattern = json["pattern"].as_str().unwrap_or("");
assert!(
pattern.contains("LI") || pattern.contains("li"),
"Pattern should be LI-based, got: {pattern}"
);
for (i, item) in items.iter().enumerate() {
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("");
let url = item.get("url").and_then(|v| v.as_str()).unwrap_or("");
assert!(
title.len() > 5,
"Item {i} should have title, got: '{title}'"
);
assert!(!url.is_empty(), "Item {i} should have URL");
}
}
#[test]
fn extract_nested_nav_prefers_content_over_navigation() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-nav");
if !goto_fixture(b.name(), "extract_nested_nav.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 4, "Should find 4 feature cards, got {count}");
let titles: Vec<&str> = items
.iter()
.filter_map(|item| item.get("title").and_then(|v| v.as_str()))
.collect();
let nav_titles = ["Home", "Features", "Pricing", "Docs", "Blog", "Login"];
for title in &titles {
assert!(
!nav_titles.contains(title),
"Should not extract nav link '{title}'"
);
}
}
#[test]
fn extract_no_pattern_returns_error() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-nopattern");
if !goto_fixture(b.name(), "extract_no_pattern.html") {
return;
}
let (stdout, _, code) = run_cli(&["--json", "--browser", b.name(), "extract"]);
if code == 0 {
for line in stdout.lines() {
if line.starts_with('{')
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
{
let items = json["items"].as_array().map_or(0, std::vec::Vec::len);
assert!(
items <= 1,
"No-pattern page should have <=1 items, got {items}"
);
break;
}
}
}
}
#[test]
fn extract_mixed_finds_activity_feed() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-mixed");
if !goto_fixture(b.name(), "extract_mixed.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 4, "Should find 4 activity items, got {count}");
assert!(
items.iter().any(|item| item.get("date").is_some()),
"Should have dates"
);
assert!(
items.iter().any(|item| item.get("image").is_some()),
"Should have images"
);
}
#[test]
fn extract_with_selector_scopes_correctly() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-selector");
if !goto_fixture(b.name(), "extract_ecommerce.html") {
return;
}
let json = extract_json_with_args(b.name(), &["--selector", ".product-grid"]);
if let Some(json) = json {
let count = json["count"].as_u64().unwrap_or(0);
assert!(
count >= 4,
"Scoped extract should find 4 products, got {count}"
);
}
}
#[test]
fn extract_limit_caps_results() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-limit");
if !goto_fixture(b.name(), "extract_list.html") {
return;
}
let json = extract_json_with_args(b.name(), &["--limit", "2"]);
if let Some(json) = json {
let items_len = json["items"].as_array().map_or(0, std::vec::Vec::len);
assert_eq!(items_len, 2, "Limit should cap to 2 items, got {items_len}");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 4, "Total count should be >=4, got {count}");
}
}
#[test]
fn extract_link_heavy_nav_prefers_content() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-linknav");
if !goto_fixture(b.name(), "extract_link_heavy_nav.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 4, "Should find 4 job listings, got {count}");
for item in items {
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("");
assert!(
!title.starts_with("Page "),
"Should not extract nav link '{title}'"
);
}
assert!(
items.iter().any(|item| item.get("date").is_some()),
"Job listings should have dates"
);
}
#[test]
fn extract_faq_items() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-faq");
if !goto_fixture(b.name(), "extract_definition_list.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 5, "Should find 5 FAQ items, got {count}");
for (i, item) in items.iter().enumerate() {
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("");
assert!(
title.len() > 5,
"FAQ item {i} should have question, got: '{title}'"
);
}
}
#[test]
fn extract_semantic_classes_boost() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-semclass");
if !goto_fixture(b.name(), "extract_semantic_classes.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 4, "Should find 4 repo cards, got {count}");
let first_title = items[0].get("title").and_then(|v| v.as_str()).unwrap_or("");
assert!(
first_title.contains("chrome-agent") || first_title.contains("dev-browser"),
"First item should be repo, got: '{first_title}'"
);
assert!(
items.iter().any(|item| item.get("date").is_some()),
"Should have dates"
);
}
#[test]
fn extract_ads_interleaved_finds_articles() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-ads");
if !goto_fixture(b.name(), "extract_ads_interleaved.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 4, "Should find 4 articles, got {count}");
let pattern = json["pattern"].as_str().unwrap_or("");
assert!(
pattern.contains("ARTICLE") || pattern.contains("story"),
"Pattern should be article-based, got: {pattern}"
);
for item in items {
let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("");
assert!(
!title.contains("Sponsored"),
"Should not extract ads: '{title}'"
);
}
assert!(
items.iter().any(|item| item.get("date").is_some()),
"Should have dates"
);
}
#[test]
fn extract_flat_table_rows() {
if !common::browser_ready() {
return;
}
let b = TestBrowser::new("ext-ftable");
if !goto_fixture(b.name(), "extract_flat_table.html") {
return;
}
let json = extract_json(b.name());
let json = json.expect("extract should return JSON");
let items = json["items"].as_array().expect("items array");
let count = json["count"].as_u64().unwrap_or(0);
assert!(count >= 7, "Should find 7 leaderboard rows, got {count}");
let first = &items[0];
let title = first.get("title").and_then(|v| v.as_str()).unwrap_or("");
assert!(
title.contains("alice") || title.contains("dev"),
"First should be username, got: '{title}'"
);
let first_url = first.get("url").and_then(|v| v.as_str()).unwrap_or("");
assert!(
first_url.contains("/u/"),
"Should link to user profile, got: {first_url}"
);
}