Skip to content

Commit 2896191

Browse files
committed
test(ie-shell): add E2E test harness with BrowserHandle and test server #10
BrowserHandle drives headless browser via JSON protocol with typed methods (navigate, get_source, get_tabs, etc.) and 10s command timeout. TestServer serves HTML fixtures from tests/fixtures/ directory. E2E test suites: - navigation.rs: 14 tests (fetch, status, 404, redirect, history, HTTPS-only) - tabs.rs: 7 tests (lifecycle, switching, multi-tab navigation) - bookmarks.rs: 4 tests (add/list, persistence, isolation) Also fixes smoke_test.rs to use isolated data dirs per test.
1 parent 1dac8d6 commit 2896191

9 files changed

Lines changed: 781 additions & 5 deletions

File tree

crates/ie-shell/tests/bookmarks.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
mod harness;
2+
3+
use harness::BrowserHandle;
4+
5+
#[tokio::test]
6+
async fn test_bookmark_add_and_list() {
7+
let mut browser = BrowserHandle::spawn().await;
8+
browser.bookmark_add("https://example.com", "Example").await;
9+
let bookmarks = browser.bookmark_list().await;
10+
assert_eq!(bookmarks.len(), 1);
11+
assert_eq!(bookmarks[0].url, "https://example.com");
12+
assert_eq!(bookmarks[0].title, "Example");
13+
browser.quit().await;
14+
}
15+
16+
#[tokio::test]
17+
async fn test_bookmark_multiple() {
18+
let mut browser = BrowserHandle::spawn().await;
19+
browser.bookmark_add("https://example.com", "Example").await;
20+
browser.bookmark_add("https://rust-lang.org", "Rust").await;
21+
browser.bookmark_add("https://github.com", "GitHub").await;
22+
let bookmarks = browser.bookmark_list().await;
23+
assert_eq!(bookmarks.len(), 3);
24+
browser.quit().await;
25+
}
26+
27+
#[tokio::test]
28+
async fn test_bookmark_persists_across_restart() {
29+
let data_dir = tempfile::tempdir().unwrap();
30+
let data_dir_str = data_dir.path().to_string_lossy().to_string();
31+
32+
// First session: add bookmark
33+
{
34+
let mut browser = BrowserHandle::spawn_with_data_dir(&data_dir_str).await;
35+
browser.bookmark_add("https://example.com", "Example").await;
36+
browser.quit().await;
37+
}
38+
39+
// Second session: verify bookmark persists
40+
{
41+
let mut browser = BrowserHandle::spawn_with_data_dir(&data_dir_str).await;
42+
let bookmarks = browser.bookmark_list().await;
43+
assert_eq!(bookmarks.len(), 1);
44+
assert_eq!(bookmarks[0].url, "https://example.com");
45+
browser.quit().await;
46+
}
47+
}
48+
49+
#[tokio::test]
50+
async fn test_bookmark_isolation() {
51+
let mut browser_a = BrowserHandle::spawn().await;
52+
let mut browser_b = BrowserHandle::spawn().await;
53+
54+
browser_a
55+
.bookmark_add("https://example.com", "Example")
56+
.await;
57+
58+
let bookmarks_b = browser_b.bookmark_list().await;
59+
assert_eq!(bookmarks_b.len(), 0);
60+
61+
browser_a.quit().await;
62+
browser_b.quit().await;
63+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
pub mod server;
2+
3+
use std::time::Duration;
4+
5+
use serde::Deserialize;
6+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
7+
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
8+
use tokio::time::timeout;
9+
10+
const CMD_TIMEOUT: Duration = Duration::from_secs(10);
11+
12+
pub struct BrowserHandle {
13+
child: Child,
14+
stdin: BufWriter<ChildStdin>,
15+
reader: BufReader<ChildStdout>,
16+
_data_dir: tempfile::TempDir,
17+
}
18+
19+
#[derive(Debug, Deserialize)]
20+
pub struct NavigateResponse {
21+
pub status: u64,
22+
pub url: String,
23+
}
24+
25+
#[derive(Debug, Deserialize)]
26+
pub struct TabInfo {
27+
pub id: u64,
28+
pub url: Option<String>,
29+
pub title: String,
30+
pub state: String,
31+
}
32+
33+
#[derive(Debug, Deserialize)]
34+
pub struct BookmarkInfo {
35+
pub url: String,
36+
pub title: String,
37+
}
38+
39+
fn binary_path() -> String {
40+
let mut path = std::env::current_exe()
41+
.unwrap()
42+
.parent()
43+
.unwrap()
44+
.parent()
45+
.unwrap()
46+
.to_path_buf();
47+
path.push("ie-shell");
48+
path.to_string_lossy().to_string()
49+
}
50+
51+
impl BrowserHandle {
52+
pub async fn spawn() -> Self {
53+
Self::spawn_with_args(&["--allow-http"]).await
54+
}
55+
56+
pub async fn spawn_https_only() -> Self {
57+
Self::spawn_with_args(&[]).await
58+
}
59+
60+
pub async fn spawn_with_data_dir(data_dir: &str) -> Self {
61+
let child_result = Command::new(binary_path())
62+
.args(["--headless", "--allow-http", "--data-dir", data_dir])
63+
.stdin(std::process::Stdio::piped())
64+
.stdout(std::process::Stdio::piped())
65+
.stderr(std::process::Stdio::inherit())
66+
.spawn();
67+
let mut child = child_result.expect("failed to spawn ie-shell");
68+
let stdin = BufWriter::new(child.stdin.take().unwrap());
69+
let reader = BufReader::new(child.stdout.take().unwrap());
70+
// Use a dummy TempDir that won't be used for actual storage
71+
let _data_dir = tempfile::tempdir().unwrap();
72+
Self {
73+
child,
74+
stdin,
75+
reader,
76+
_data_dir,
77+
}
78+
}
79+
80+
async fn spawn_with_args(extra_args: &[&str]) -> Self {
81+
let data_dir = tempfile::tempdir().unwrap();
82+
let data_dir_str = data_dir.path().to_string_lossy().to_string();
83+
let mut args = vec!["--headless", "--data-dir", &data_dir_str];
84+
args.extend(extra_args);
85+
let child_result = Command::new(binary_path())
86+
.args(&args)
87+
.stdin(std::process::Stdio::piped())
88+
.stdout(std::process::Stdio::piped())
89+
.stderr(std::process::Stdio::inherit())
90+
.spawn();
91+
let mut child = child_result.expect("failed to spawn ie-shell");
92+
let stdin = BufWriter::new(child.stdin.take().unwrap());
93+
let reader = BufReader::new(child.stdout.take().unwrap());
94+
Self {
95+
child,
96+
stdin,
97+
reader,
98+
_data_dir: data_dir,
99+
}
100+
}
101+
102+
pub async fn send_command(&mut self, cmd: serde_json::Value) -> serde_json::Value {
103+
let json = serde_json::to_string(&cmd).unwrap();
104+
self.stdin
105+
.write_all(json.as_bytes())
106+
.await
107+
.expect("write to stdin");
108+
self.stdin.write_all(b"\n").await.expect("write newline");
109+
self.stdin.flush().await.expect("flush stdin");
110+
111+
let mut line = String::new();
112+
timeout(CMD_TIMEOUT, self.reader.read_line(&mut line))
113+
.await
114+
.unwrap_or_else(|_| panic!("command timed out after {CMD_TIMEOUT:?}: {cmd}"))
115+
.expect("read from stdout");
116+
117+
serde_json::from_str(&line).unwrap_or_else(|e| {
118+
panic!("invalid JSON response: {e}\nraw: {line}\ncommand was: {cmd}")
119+
})
120+
}
121+
122+
pub async fn navigate(&mut self, url: &str) -> NavigateResponse {
123+
let resp = self
124+
.send_command(serde_json::json!({"cmd": "navigate", "url": url}))
125+
.await;
126+
assert!(
127+
resp["ok"].as_bool().unwrap(),
128+
"navigate failed: {}",
129+
resp["error"]
130+
);
131+
serde_json::from_value(resp["data"].clone()).unwrap()
132+
}
133+
134+
pub async fn navigate_raw(&mut self, url: &str) -> serde_json::Value {
135+
self.send_command(serde_json::json!({"cmd": "navigate", "url": url}))
136+
.await
137+
}
138+
139+
pub async fn get_source(&mut self) -> String {
140+
let resp = self
141+
.send_command(serde_json::json!({"cmd": "get_source"}))
142+
.await;
143+
assert!(
144+
resp["ok"].as_bool().unwrap(),
145+
"get_source failed: {}",
146+
resp["error"]
147+
);
148+
resp["data"].as_str().unwrap().to_string()
149+
}
150+
151+
pub async fn get_tabs(&mut self) -> Vec<TabInfo> {
152+
let resp = self
153+
.send_command(serde_json::json!({"cmd": "get_tabs"}))
154+
.await;
155+
assert!(
156+
resp["ok"].as_bool().unwrap(),
157+
"get_tabs failed: {}",
158+
resp["error"]
159+
);
160+
serde_json::from_value(resp["data"].clone()).unwrap()
161+
}
162+
163+
pub async fn new_tab(&mut self) -> u64 {
164+
let resp = self
165+
.send_command(serde_json::json!({"cmd": "new_tab"}))
166+
.await;
167+
assert!(
168+
resp["ok"].as_bool().unwrap(),
169+
"new_tab failed: {}",
170+
resp["error"]
171+
);
172+
resp["data"]["id"].as_u64().unwrap()
173+
}
174+
175+
pub async fn close_tab(&mut self, id: u64) -> serde_json::Value {
176+
self.send_command(serde_json::json!({"cmd": "close_tab", "id": id}))
177+
.await
178+
}
179+
180+
pub async fn switch_tab(&mut self, id: u64) -> serde_json::Value {
181+
self.send_command(serde_json::json!({"cmd": "switch_tab", "id": id}))
182+
.await
183+
}
184+
185+
pub async fn go_back(&mut self) -> serde_json::Value {
186+
self.send_command(serde_json::json!({"cmd": "go_back"}))
187+
.await
188+
}
189+
190+
pub async fn go_forward(&mut self) -> serde_json::Value {
191+
self.send_command(serde_json::json!({"cmd": "go_forward"}))
192+
.await
193+
}
194+
195+
pub async fn bookmark_add(&mut self, url: &str, title: &str) {
196+
let resp = self
197+
.send_command(serde_json::json!({"cmd": "bookmark_add", "url": url, "title": title}))
198+
.await;
199+
assert!(
200+
resp["ok"].as_bool().unwrap(),
201+
"bookmark_add failed: {}",
202+
resp["error"]
203+
);
204+
}
205+
206+
pub async fn bookmark_list(&mut self) -> Vec<BookmarkInfo> {
207+
let resp = self
208+
.send_command(serde_json::json!({"cmd": "bookmark_list"}))
209+
.await;
210+
assert!(
211+
resp["ok"].as_bool().unwrap(),
212+
"bookmark_list failed: {}",
213+
resp["error"]
214+
);
215+
serde_json::from_value(resp["data"].clone()).unwrap()
216+
}
217+
218+
pub async fn quit(&mut self) {
219+
let _ = self.send_command(serde_json::json!({"cmd": "quit"})).await;
220+
let _ = timeout(Duration::from_secs(2), self.child.wait()).await;
221+
}
222+
}
223+
224+
impl Drop for BrowserHandle {
225+
fn drop(&mut self) {
226+
// Best-effort kill
227+
let _ = self.child.start_kill();
228+
}
229+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
use std::net::SocketAddr;
2+
use std::path::PathBuf;
3+
4+
pub struct TestServer {
5+
addr: SocketAddr,
6+
_handle: std::thread::JoinHandle<()>,
7+
}
8+
9+
impl TestServer {
10+
pub fn start() -> Self {
11+
Self::start_with_handler(|path| {
12+
let fixtures_dir = fixtures_dir();
13+
let file_path = fixtures_dir.join(path.trim_start_matches('/'));
14+
if file_path.exists() {
15+
let body = std::fs::read_to_string(&file_path).unwrap();
16+
(200, "text/html", body)
17+
} else {
18+
(404, "text/html", "Not Found".to_string())
19+
}
20+
})
21+
}
22+
23+
pub fn start_with_handler<F>(handler: F) -> Self
24+
where
25+
F: Fn(&str) -> (u16, &'static str, String) + Send + 'static,
26+
{
27+
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
28+
let addr = listener.local_addr().unwrap();
29+
let handle = std::thread::spawn(move || {
30+
for stream in listener.incoming() {
31+
let Ok(mut stream) = stream else { break };
32+
let mut buf = [0u8; 4096];
33+
let n = std::io::Read::read(&mut stream, &mut buf).unwrap_or(0);
34+
let request = String::from_utf8_lossy(&buf[..n]);
35+
let path = request
36+
.lines()
37+
.next()
38+
.and_then(|line| line.split_whitespace().nth(1))
39+
.unwrap_or("/");
40+
let (status, content_type, body) = handler(path);
41+
let response = format!(
42+
"HTTP/1.1 {status} OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
43+
body.len()
44+
);
45+
let _ = std::io::Write::write_all(&mut stream, response.as_bytes());
46+
}
47+
});
48+
Self {
49+
addr,
50+
_handle: handle,
51+
}
52+
}
53+
54+
pub fn url(&self, path: &str) -> String {
55+
format!("http://127.0.0.1:{}{path}", self.addr.port())
56+
}
57+
}
58+
59+
fn fixtures_dir() -> PathBuf {
60+
let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
61+
// Go up from crates/ie-shell to project root
62+
dir.pop();
63+
dir.pop();
64+
dir.push("tests/fixtures");
65+
dir
66+
}

0 commit comments

Comments
 (0)