Skip to content

Commit 6df4738

Browse files
authored
Merge pull request #60 from pixlie/feature/real-world-tests
feat: add real-world integration tests (#56)
2 parents f1045d5 + 3d8ed4a commit 6df4738

4 files changed

Lines changed: 626 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,4 @@ chrono = { version = "0.4", features = ["serde"] }
8686

8787
[dev-dependencies]
8888
tempfile = "3.0"
89+
serial_test = "3.0"

src/html_parser.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,68 @@ impl HtmlNode {
4242

4343
None
4444
}
45+
46+
/// Find elements by CSS-like path (ignoring IDs)
47+
/// Example: "html body center table tbody tr td table tbody tr.athing.submission td.title"
48+
pub fn find_by_path(&self, path: &str) -> Vec<&HtmlNode> {
49+
let path_parts: Vec<&str> = path.split_whitespace().collect();
50+
if path_parts.is_empty() {
51+
return vec![];
52+
}
53+
54+
let mut results = Vec::new();
55+
self.find_by_path_recursive(&path_parts, 0, &mut results);
56+
results
57+
}
58+
59+
fn find_by_path_recursive<'a>(
60+
&'a self,
61+
path_parts: &[&str],
62+
depth: usize,
63+
results: &mut Vec<&'a HtmlNode>,
64+
) {
65+
if depth >= path_parts.len() {
66+
return;
67+
}
68+
69+
let current_part = path_parts[depth];
70+
71+
// Check if current node matches the current path part
72+
if self.matches_path_part(current_part) {
73+
if depth == path_parts.len() - 1 {
74+
// This is the final part, add to results
75+
results.push(self);
76+
} else {
77+
// Continue searching in children for the next part
78+
for child in &self.children {
79+
child.find_by_path_recursive(path_parts, depth + 1, results);
80+
}
81+
}
82+
}
83+
84+
// Also check children for the current part (to handle non-matching intermediate nodes)
85+
for child in &self.children {
86+
child.find_by_path_recursive(path_parts, depth, results);
87+
}
88+
}
89+
90+
fn matches_path_part(&self, part: &str) -> bool {
91+
// Parse part like "tr.athing.submission" or just "td"
92+
if let Some(dot_pos) = part.find('.') {
93+
let tag = &part[..dot_pos];
94+
let classes_str = &part[dot_pos + 1..];
95+
let required_classes: Vec<&str> = classes_str.split('.').collect();
96+
97+
// Check tag matches and all required classes are present
98+
self.tag == tag
99+
&& required_classes
100+
.iter()
101+
.all(|class| self.classes.contains(&class.to_string()))
102+
} else {
103+
// Just a tag name
104+
self.tag == part
105+
}
106+
}
45107
}
46108

47109
pub struct HtmlParser {
@@ -360,4 +422,75 @@ mod tests {
360422
assert!(!parser.is_same_domain("https://other.com/page", "example.com"));
361423
assert!(!parser.is_same_domain("https://notexample.com/page", "example.com"));
362424
}
425+
426+
#[test]
427+
fn test_find_by_path() {
428+
let parser = HtmlParser::new();
429+
let html = r#"<html>
430+
<body>
431+
<center>
432+
<table>
433+
<tbody>
434+
<tr>
435+
<td>
436+
<table>
437+
<tbody>
438+
<tr class="athing submission">
439+
<td class="title">First Item</td>
440+
</tr>
441+
<tr class="athing submission">
442+
<td class="title">Second Item</td>
443+
</tr>
444+
</tbody>
445+
</table>
446+
</td>
447+
</tr>
448+
</tbody>
449+
</table>
450+
</center>
451+
</body>
452+
</html>"#;
453+
let tree = parser.parse(html);
454+
455+
// Test finding elements by path
456+
let results = tree.find_by_path(
457+
"html body center table tbody tr td table tbody tr.athing.submission td.title",
458+
);
459+
assert_eq!(results.len(), 2);
460+
assert_eq!(results[0].content, "First Item");
461+
assert_eq!(results[1].content, "Second Item");
462+
463+
// Test finding by simple tag path
464+
let body_results = tree.find_by_path("html body");
465+
assert_eq!(body_results.len(), 1);
466+
assert_eq!(body_results[0].tag, "body");
467+
468+
// Test non-existent path
469+
let empty_results = tree.find_by_path("html body div.nonexistent");
470+
assert_eq!(empty_results.len(), 0);
471+
}
472+
473+
#[test]
474+
fn test_matches_path_part() {
475+
let node = HtmlNode::new(
476+
"tr".to_string(),
477+
vec!["athing".to_string(), "submission".to_string()],
478+
None,
479+
String::new(),
480+
);
481+
482+
// Test matching with classes
483+
assert!(node.matches_path_part("tr.athing.submission"));
484+
assert!(node.matches_path_part("tr.athing"));
485+
assert!(node.matches_path_part("tr"));
486+
487+
// Test non-matching
488+
assert!(!node.matches_path_part("td.athing"));
489+
assert!(!node.matches_path_part("tr.nonexistent"));
490+
491+
// Test simple tag matching
492+
let simple_node = HtmlNode::new("div".to_string(), vec![], None, String::new());
493+
assert!(simple_node.matches_path_part("div"));
494+
assert!(!simple_node.matches_path_part("span"));
495+
}
363496
}

tests/README.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Real-World Integration Tests
2+
3+
This directory contains integration tests that crawl real websites to verify SmartCrawler's complete functionality in real-world scenarios, including domain-level duplicate filtering.
4+
5+
## Prerequisites
6+
7+
Before running these tests, you need to have a WebDriver server running:
8+
9+
### Option 1: GeckoDriver (Firefox) - Recommended
10+
```bash
11+
# Install geckodriver and start it
12+
geckodriver
13+
```
14+
15+
### Option 2: ChromeDriver
16+
```bash
17+
# Download ChromeDriver and start it on port 4444
18+
chromedriver --port=4444
19+
```
20+
21+
### Option 3: Docker
22+
```bash
23+
# Run Chrome in headless mode with WebDriver
24+
docker run -d -p 4444:4444 selenium/standalone-chrome:latest
25+
```
26+
27+
## Running the Tests
28+
29+
The real-world tests are ignored by default to prevent them from running during normal development. To run them explicitly:
30+
31+
```bash
32+
# Run all real-world tests (they run serially to avoid WebDriver conflicts)
33+
cargo test real_world -- --ignored
34+
35+
# Run a specific test
36+
cargo test test_hacker_news_submissions -- --ignored
37+
cargo test test_mykin_ai_team_member -- --ignored
38+
39+
# Test WebDriver connection
40+
cargo test test_webdriver_connection -- --ignored
41+
```
42+
43+
### Serial Execution
44+
45+
**Important**: Real-world tests are configured to run serially (one at a time) using the `serial_test` crate. This prevents WebDriver session conflicts that occur when multiple tests try to use the same WebDriver port (4444) simultaneously.
46+
47+
The `#[serial]` attribute ensures that:
48+
- Tests won't interfere with each other's WebDriver sessions
49+
- Each test gets exclusive access to the WebDriver instance
50+
- Tests are more reliable and predictable
51+
52+
## Test Descriptions
53+
54+
### `test_hacker_news_submissions`
55+
- **URL**: https://news.ycombinator.com/
56+
- **Purpose**: Verifies the complete SmartCrawler pipeline including domain-level duplicate filtering
57+
- **Pipeline**: Full 3-phase crawling with link discovery, root URL prioritization, and duplicate analysis
58+
- **Path**: `html body center table tbody tr td table tbody tr.athing.submission td.title`
59+
- **Expected**: Exactly 30 elements matching this path (including filtered duplicates)
60+
- **Features Tested**: Link discovery, domain duplicate detection, filtered content identification
61+
62+
### `test_mykin_ai_team_member`
63+
- **URL**: https://mykin.ai/company
64+
- **Purpose**: Verifies the complete SmartCrawler pipeline on a modern web framework
65+
- **Pipeline**: Full 3-phase crawling with link discovery, root URL prioritization, and duplicate analysis
66+
- **Path**: `html.w-mod-js.w-mod-ix body div.page-wrapper main.main-wrapper section.section_team div.padding-global div.container-medium div.team_collection.is-desktop.w-dyn-list div.team_collection-list.w-dyn-items div.w-dyn-item a.team_card.w-inline-block div.team_content h4`
67+
- **Expected**: At least one element containing "Kasper Juul" (excluding filtered duplicates)
68+
- **Features Tested**: Complex CSS class matching, content filtering, modern web app crawling
69+
70+
## Manual Verification
71+
72+
If a test fails, you should manually verify the webpage structure:
73+
74+
1. Visit the URL in your browser
75+
2. Inspect the HTML structure using browser developer tools
76+
3. Check if the CSS path still matches the expected elements
77+
4. Update the test path if the website structure has changed
78+
79+
## SmartCrawler Pipeline Features Tested
80+
81+
The real-world tests exercise the complete SmartCrawler functionality:
82+
83+
### Phase 1: Preparation Stage
84+
- **Link Discovery**: Extracts same-domain links from initial page
85+
- **Root URL Prioritization**: Automatically adds domain root URLs
86+
- **URL Collection**: Gathers up to 3 URLs per domain for analysis
87+
88+
### Phase 2: Processing Stage
89+
- **Sequential Processing**: Processes URLs in priority order (user → root → discovered)
90+
- **Content Extraction**: Retrieves HTML source and parses into structured tree
91+
- **Status Tracking**: Monitors fetch success/failure for each URL
92+
93+
### Phase 3: Analysis Stage
94+
- **Domain Duplicate Detection**: Identifies common elements across domain pages
95+
- **Content Filtering**: Marks duplicate patterns as `[FILTERED DUPLICATE]`
96+
- **Result Preparation**: Provides clean, filtered HTML trees for analysis
97+
98+
## Notes
99+
100+
- These tests depend on external websites and may fail if:
101+
- The websites are down or inaccessible
102+
- The website structure has changed
103+
- Network connectivity issues
104+
- WebDriver is not running or accessible
105+
- Element IDs are ignored in path matching to make tests more robust against dynamic content
106+
- Tests include verbose output to help with debugging when they fail
107+
- **Serial execution**: Tests run one at a time to prevent WebDriver session conflicts - this is automatic and requires no special flags
108+
- **Full pipeline testing**: Each test exercises the complete 3-phase SmartCrawler workflow
109+
- **Duplicate filtering**: Tests verify that domain-level filtering correctly identifies and marks duplicate content

0 commit comments

Comments
 (0)