Skip to content

Step 7: E2E test harness #10

@thomasnemer

Description

@thomasnemer

Parent: #2

Goal

Build the e2e test infrastructure: a BrowserHandle that drives the headless browser via the JSON protocol from Step 6, a local test server for fixtures, and a full suite of e2e tests covering navigation, tabs, and bookmarks.

Prerequisites

  • Step 6 (headless navigation pipeline with interactive JSON protocol)

File Structure

tests/
├── harness/
│   ├── mod.rs          # BrowserHandle — spawns and drives headless browser
│   └── server.rs       # Local HTTP test server for fixtures
├── fixtures/
│   ├── hello.html      # Simple page
│   ├── titled.html     # Page with specific <title>
│   └── large.html      # ~100KB page for basic perf sanity
├── navigation.rs       # Navigation e2e tests
├── tabs.rs             # Tab lifecycle e2e tests
└── bookmarks.rs        # Bookmark e2e tests

Implementation

BrowserHandle (tests/harness/mod.rs)

  • BrowserHandle struct:
    pub struct BrowserHandle {
        child: tokio::process::Child,
        stdin: BufWriter<tokio::process::ChildStdin>,
        reader: BufReader<tokio::process::ChildStdout>,
        data_dir: TempDir,
    }
  • BrowserHandle::spawn() -> Result<Self>:
    • Create TempDir for data isolation
    • Spawn cargo run -p ie-shell -- --headless --allow-http --data-dir <tempdir> as child process
    • Capture stdin/stdout, stderr inherited (for debugging)
    • Return handle
    • Note: --allow-http is required because the test server is HTTP
  • async fn send_command(&mut self, cmd: serde_json::Value) -> Result<serde_json::Value>:
    • Serialize cmd to JSON, write line to stdin, flush
    • Read one line from stdout, deserialize as JSON
    • Return response
  • High-level methods (all built on send_command):
    • async fn navigate(&mut self, url: &str) -> Result<NavigateResponse>
      • Sends {"cmd": "navigate", "url": "..."}, asserts ok: true, returns parsed data
    • async fn get_source(&mut self) -> Result<String>
      • Sends {"cmd": "get_source"}, returns source string
    • async fn get_tabs(&mut self) -> Result<Vec<TabInfo>>
      • Sends {"cmd": "get_tabs"}, returns parsed tab list
    • async fn new_tab(&mut self) -> Result<TabId>
      • Sends {"cmd": "new_tab"}, returns new tab id
    • async fn close_tab(&mut self, id: TabId) -> Result<()>
      • Sends {"cmd": "close_tab", "id": N}
    • async fn switch_tab(&mut self, id: TabId) -> Result<()>
      • Sends {"cmd": "switch_tab", "id": N}
    • async fn go_back(&mut self) -> Result<serde_json::Value>
      • Sends {"cmd": "go_back"}, returns response (may be ok or error)
    • async fn go_forward(&mut self) -> Result<serde_json::Value>
      • Sends {"cmd": "go_forward"}, returns response (may be ok or error)
    • async fn bookmark_add(&mut self, url: &str, title: &str) -> Result<()>
      • Sends {"cmd": "bookmark_add", "url": "...", "title": "..."}
    • async fn bookmark_list(&mut self) -> Result<Vec<BookmarkInfo>>
      • Sends {"cmd": "bookmark_list"}
    • async fn quit(&mut self) -> Result<()>
      • Sends {"cmd": "quit"}, waits for process exit
  • Drop impl:
    • Send quit command (best-effort)
    • Kill process if still alive after 2s timeout
  • Timeout on all commands: if no response within 10s, panic with descriptive message

Response types for harness

  • NavigateResponse { status: u16, url: String }
  • TabInfo { id: u64, url: Option<String>, title: String, state: String }
  • BookmarkInfo { url: String, title: String }

Test server (tests/harness/server.rs)

  • TestServer struct:
    pub struct TestServer {
        addr: SocketAddr,
        shutdown_tx: oneshot::Sender<()>,
        handle: JoinHandle<()>,
    }
  • TestServer::start() -> Self:
    • Bind hyper server to 127.0.0.1:0
    • Default handler: serve files from tests/fixtures/ based on URL path
      • /hello.html → read and return tests/fixtures/hello.html with Content-Type: text/html
      • /titled.html → return the titled fixture
      • /large.html → return the large fixture
      • Any other path → 404
    • Return server with bound address
  • TestServer::start_with_handler(handler: impl Fn(Request) -> Response) — custom handler for special cases (redirects, slow responses, errors)
  • TestServer::url(&self, path: &str) -> String — helper: format!("http://127.0.0.1:{}{}", self.addr.port(), path)
  • Drop impl: send shutdown signal, join handle

Test fixtures

  • tests/fixtures/hello.html:
    <!DOCTYPE html>
    <html><head><title>Hello</title></head><body><p>Hello World</p></body></html>
  • tests/fixtures/titled.html:
    <!DOCTYPE html>
    <html><head><title>Test Page Title</title></head><body><h1>Titled Page</h1></body></html>
  • tests/fixtures/large.html:
    • Generated or static ~100KB HTML file
    • Purpose: verify the browser handles larger payloads without issues

E2E Tests

Navigation tests (tests/navigation.rs)

  • test_navigate_and_get_source:
    • Start server, spawn browser
    • Navigate to /hello.html
    • get_source() → contains "Hello World"
  • test_navigate_status_code:
    • Navigate to /hello.html
    • Response status is 200
  • test_navigate_404:
    • Navigate to /nonexistent
    • Response status is 404
  • test_navigate_updates_url:
    • Navigate to /hello.html
    • get_tabs() → active tab URL matches server URL
  • test_navigate_redirect:
    • Start server with handler: /redirect → 302 to /hello.html
    • Navigate to /redirect
    • get_source() → contains "Hello World" (followed the redirect)
  • test_navigate_twice:
    • Navigate to /hello.html, verify source
    • Navigate to /titled.html, verify source changed
  • test_navigate_large_page:
    • Navigate to /large.html
    • get_source() → length matches fixture
  • test_navigate_invalid_url:
    • Navigate to empty string or garbage
    • Response has ok: false
  • test_navigate_connection_refused:
    • Navigate to http://127.0.0.1:1 (nothing listening)
    • Response has ok: false with error message

Navigation history tests (tests/navigation.rs)

  • test_navigate_go_back:
    • Navigate to /hello.html (A), navigate to /titled.html (B)
    • go_back() → response ok, source is A's content ("Hello World")
  • test_navigate_go_forward:
    • Navigate to A, navigate to B, go_back(), go_forward()
    • Source is B's content ("Titled Page")
  • test_go_back_at_start:
    • Navigate to A, go_back() → error response (no back history)
  • test_go_forward_at_end:
    • Navigate to A, go_forward() → error response (no forward history)

HTTPS-only tests (tests/navigation.rs)

  • test_https_only_blocks_http:
    • Spawn browser WITHOUT --allow-http
    • Navigate to http://127.0.0.1:PORT/hello.html
    • Response has ok: false with error about HTTP being blocked

Tab tests (tests/tabs.rs)

  • test_starts_with_one_tab:
    • Spawn browser, get_tabs() → 1 tab, state is "blank"
  • test_new_tab:
    • new_tab(), get_tabs() → 2 tabs
  • test_close_tab:
    • new_tab() (now 2), close_tab(id), get_tabs() → 1 tab
  • test_close_nonexistent_tab:
    • close_tab(9999) → error response
  • test_switch_tab:
    • new_tab() → tab 2. switch_tab(tab_1_id). get_tabs() → verify tab 1 is now active.
  • test_navigate_different_tabs:
    • Tab 1: navigate to /hello.html
    • new_tab() → tab 2
    • Tab 2: navigate to /titled.html
    • get_source() → contains "Titled Page" (active is tab 2)
    • switch_tab(tab_1_id), get_source() → contains "Hello World"
  • test_close_all_tabs:
    • Close the initial tab
    • get_tabs() → empty list

Bookmark tests (tests/bookmarks.rs)

  • test_bookmark_add_and_list:
    • bookmark_add("https://example.com", "Example")
    • bookmark_list() → 1 bookmark with correct URL and title
  • test_bookmark_multiple:
    • Add 3 bookmarks
    • bookmark_list() → 3 entries
  • test_bookmark_persists_across_restart:
    • Spawn browser with specific data-dir
    • Add bookmark, quit
    • Spawn new browser with same data-dir
    • bookmark_list() → bookmark still present
  • test_bookmark_isolation:
    • Two browser instances with different data-dirs
    • Add bookmark in browser A
    • Browser B's bookmark_list() → empty

Acceptance Criteria

  • cargo test --test navigation — all navigation e2e tests pass
  • cargo test --test tabs — all tab e2e tests pass
  • cargo test --test bookmarks — all bookmark e2e tests pass
  • mise run test runs all tests (unit + integration + e2e)
  • Tests are deterministic (no flakiness from race conditions)
  • Each test cleans up after itself (temp dirs, child processes)
  • Test output is readable: on failure, prints the command sent and response received

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions