---
phase: 19-ship-examples-and-playwright-e2e
plan: 02
type: execute
wave: 2
depends_on: [19-01]
files_modified:
  - crates/mcp-e2e-tests/Cargo.toml
  - crates/mcp-e2e-tests/src/lib.rs
  - crates/mcp-e2e-tests/src/server.rs
  - crates/mcp-e2e-tests/src/bridge.rs
  - crates/mcp-e2e-tests/tests/chess.rs
  - crates/mcp-e2e-tests/tests/map.rs
  - crates/mcp-e2e-tests/tests/dataviz.rs
autonomous: true
requirements: [SHIP-03, SHIP-04, SHIP-05]

must_haves:
  truths:
    - "Running `cargo test -p mcp-e2e-tests -- --test-threads=1` executes all E2E tests"
    - "The embedded axum test server serves widget HTML files from each example's widgets/ directory"
    - "Mock MCP bridge is injected via CDP evaluate_on_new_document BEFORE page navigation"
    - "Chess tests verify: board renders 64 squares, pieces visible, piece selection highlights, valid moves fetched, move execution, status update, new game button"
    - "Map tests verify: map container renders, city markers appear after search, city list populates, city detail panel shows on selection"
    - "Data viz tests verify: chart canvas/SVG appears after tool call, data table populates with rows, chart type switching re-renders chart"
    - "Browser is launched headless with chromiumoxide using auto-downloaded Chromium"
    - "All tests use #[tokio::test(flavor = \"multi_thread\")] for async compatibility"
  artifacts:
    - path: "crates/mcp-e2e-tests/Cargo.toml"
      provides: "E2E test crate with chromiumoxide, axum, tower-http dependencies"
      contains: "chromiumoxide"
    - path: "crates/mcp-e2e-tests/src/lib.rs"
      provides: "Shared test utilities: browser lifecycle, test server, mock bridge injection"
      contains: "get_or_launch_browser"
    - path: "crates/mcp-e2e-tests/src/server.rs"
      provides: "Embedded axum HTTP server for serving widget files in tests"
      contains: "start_test_server"
    - path: "crates/mcp-e2e-tests/src/bridge.rs"
      provides: "Mock MCP bridge JavaScript generation from JSON response map"
      contains: "inject_mock_bridge"
    - path: "crates/mcp-e2e-tests/tests/chess.rs"
      provides: "Chess widget E2E tests (ported from Playwright)"
      contains: "chess_board_renders"
    - path: "crates/mcp-e2e-tests/tests/map.rs"
      provides: "Map widget E2E tests"
      contains: "map_container_renders"
    - path: "crates/mcp-e2e-tests/tests/dataviz.rs"
      provides: "Data viz widget E2E tests"
      contains: "chart_renders_after_query"
  key_links:
    - from: "crates/mcp-e2e-tests/tests/chess.rs"
      to: "crates/mcp-e2e-tests/src/lib.rs"
      via: "Tests import inject_mock_bridge, start_test_server, get_or_launch_browser"
      pattern: "use mcp_e2e_tests::"
    - from: "crates/mcp-e2e-tests/src/server.rs"
      to: "examples/mcp-apps-chess/widgets/board.html"
      via: "Test server serves static files from example widget directories"
      pattern: "ServeDir::new"
    - from: "crates/mcp-e2e-tests/src/bridge.rs"
      to: "crates/mcp-e2e-tests/tests/chess.rs"
      via: "Tests pass serde_json::Value response maps to inject_mock_bridge"
      pattern: "inject_mock_bridge"
---

<objective>
Build a Rust-native E2E test crate using chromiumoxide (CDP) that validates all three MCP App widget examples render and interact correctly in a headless browser with mock bridge injection.

Purpose: Automated browser tests prove the complete widget pipeline works end-to-end -- from widget HTML loading through bridge mock injection to user interaction verification. This replaces the Node.js Playwright tests with a pure-Rust solution.

Output: `crates/mcp-e2e-tests/` with embedded test server, mock bridge injection, and passing test suites for chess (10 tests), map (5 tests), and dataviz (5 tests).
</objective>

<execution_context>
@./.claude/get-shit-done/workflows/execute-plan.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-ship-examples-and-playwright-e2e/19-CONTEXT.md
@.planning/phases/19-ship-examples-and-playwright-e2e/19-RESEARCH.md
@.planning/phases/19-ship-examples-and-playwright-e2e/19-01-SUMMARY.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase + research. -->

From chromiumoxide API (verified in research):
```rust
use chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::page::Page;
use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;

// Launch browser (headless):
let config = BrowserConfig::builder()
    .chrome_executable(info.executable_path)
    .arg("--headless")
    .arg("--disable-gpu")
    .arg("--no-sandbox")
    .build()?;
let (browser, mut handler) = Browser::launch(config).await?;
tokio::spawn(async move { while handler.next().await.is_some() {} });

// Create new page:
let page = browser.new_page("about:blank").await?;

// Inject script before page load:
page.evaluate_on_new_document(AddScriptToEvaluateOnNewDocumentParams::new(script)).await?;

// Navigate:
page.goto(url).await?;
page.wait_for_navigation().await?;

// Find elements:
let elements = page.find_elements(".square").await?;
let element = page.find_element("#board").await?;

// Evaluate JS:
let result: serde_json::Value = page.evaluate("document.querySelectorAll('.square').length").await?
    .into_value()?;
```

From chromiumoxide fetcher API:
```rust
use chromiumoxide::fetcher::{BrowserFetcher, BrowserFetcherOptions};

let download_path = std::env::temp_dir().join("mcp-e2e-chromium");
tokio::fs::create_dir_all(&download_path).await?;
let fetcher = BrowserFetcher::new(
    BrowserFetcherOptions::builder()
        .with_path(&download_path)
        .build()?
);
let info = fetcher.fetch().await?;
// info.executable_path is the path to the downloaded chromium binary
```

From existing Playwright chess tests (tests/chess-widget.spec.ts -- test scenarios to port):
```
1. should display the chess board -> #board visible, 64 .square elements
2. should show initial status as white to move -> #status contains "White to move"
3. should call chess_new_game on initialization -> tool call log has chess_new_game
4. should highlight selected piece -> click .square[data-file="4"][data-rank="1"], check .selected class
5. should show valid moves when piece is selected -> click e2, check chess_valid_moves called
6. should make a move when clicking valid destination -> click e2 then e4, check chess_move called
7. should update status after move -> after move, #status contains "Black"
8. should persist state via widget state API -> getState has gameState property
9. should start new game when button clicked -> click #newGameBtn, chess_new_game called twice
10. should handle tool call errors gracefully -> error handler, #board still visible
```

From axum + tower-http (matching root crate version 0.8.x):
```rust
use axum::Router;
use tower_http::services::ServeDir;

let app = Router::new()
    .nest_service("/chess", ServeDir::new("examples/mcp-apps-chess/widgets"))
    .nest_service("/map", ServeDir::new("examples/mcp-apps-map/widgets"))
    .nest_service("/dataviz", ServeDir::new("examples/mcp-apps-dataviz/widgets"));

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); });
```

Mock bridge pattern (from research -- JSON lookup table approach):
```javascript
window.mcpBridge = {
    callTool: async (name, args) => {
        const responses = RESPONSES_JSON;
        const handler = responses[name];
        if (handler) return JSON.parse(JSON.stringify(handler));
        return { error: `Unknown tool: ${name}` };
    },
    getState: () => ({}),
    setState: (s) => {},
    getHost: () => ({ type: 'standalone', capabilities: { tools: true, resources: true } }),
    __toolCallLog: [],
    __getToolCallLog: function() { return this.__toolCallLog; },
};
// Wrap callTool to log:
const _origCallTool = window.mcpBridge.callTool;
window.mcpBridge.callTool = async function(name, args) {
    const result = await _origCallTool(name, args);
    window.mcpBridge.__toolCallLog.push({ name, args, result });
    return result;
};
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Build E2E test infrastructure (server, bridge, browser lifecycle)</name>
  <files>
    crates/mcp-e2e-tests/Cargo.toml
    crates/mcp-e2e-tests/src/lib.rs
    crates/mcp-e2e-tests/src/server.rs
    crates/mcp-e2e-tests/src/bridge.rs
  </files>
  <action>
Replace the placeholder crate from plan 19-01 with the full E2E test infrastructure.

**Cargo.toml** -- Replace the placeholder with full dependencies:
```toml
[package]
name = "mcp-e2e-tests"
version = "0.1.0"
edition = "2021"
publish = false
description = "End-to-end browser tests for MCP App widgets"

[dependencies]
chromiumoxide = { version = "0.9", features = ["_fetcher-rustls-tokio", "zip0"] }
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures = "0.3"
anyhow = "1"
```

IMPORTANT: The `chromiumoxide` feature flag naming may differ from research. At implementation time, check the actual crate on crates.io. Common feature names include `_fetcher-rustls-tokio`, `fetcher-rustls-tokio`, or similar. The `zip0` feature enables zip extraction for the downloaded Chromium archive (per RESEARCH.md recommendation). If the exact feature name causes a compilation error, check `cargo metadata` or the chromiumoxide Cargo.toml on GitHub for the correct feature flags. The goal is: (a) async tokio runtime, (b) rustls TLS, (c) auto-download fetcher, (d) zip extraction support.

**src/lib.rs** -- Shared test utilities re-exporting from submodules:

```rust
pub mod server;
pub mod bridge;

pub use server::start_test_server;
pub use bridge::inject_mock_bridge;
```

Also include browser lifecycle management in lib.rs:

1. Define `pub async fn get_or_launch_browser() -> anyhow::Result<Browser>`:
   - Use `tokio::sync::OnceCell<Browser>` (or equivalent) for lazy initialization.
   - On first call:
     a. Create download path at `std::env::temp_dir().join("mcp-e2e-chromium")`.
     b. Create directory with `tokio::fs::create_dir_all`.
     c. Use `BrowserFetcher` to download Chromium (this caches automatically).
     d. Build `BrowserConfig` with `--headless`, `--disable-gpu`, `--no-sandbox` flags.
     e. Launch browser, spawn handler task via `tokio::spawn`.
     f. Return browser.
   - On subsequent calls: return cached browser.

   IMPORTANT: Due to the static lifetime requirements, the exact pattern may need adjustment. Consider using `tokio::sync::OnceCell` from tokio 1.x, or `std::sync::OnceLock` with a blocking init. The key requirement is: download Chromium once per test run, not once per test.

   FALLBACK: If shared browser across test files is too complex due to process isolation (each `tests/*.rs` file runs as a separate binary), then provide a `launch_browser()` async function that each test file calls in its own setup. The fetcher caches the download on disk, so subsequent calls skip the download.

2. Define `pub async fn new_page_with_bridge(browser: &Browser, responses: &serde_json::Value) -> anyhow::Result<Page>`:
   - Create new page: `browser.new_page("about:blank").await?`
   - Inject mock bridge: `inject_mock_bridge(&page, responses).await?`
   - Return page.

**src/server.rs** -- Embedded axum HTTP server:

1. Implement `pub async fn start_test_server() -> anyhow::Result<(std::net::SocketAddr, tokio::task::JoinHandle<()>)>`:
   - Resolve workspace root by walking up from `CARGO_MANIFEST_DIR` (the crate is at `crates/mcp-e2e-tests/`, workspace root is `../../`). Use `PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples")` and canonicalize.
   - Build axum Router with three nest_service routes:
     - `/chess` -> ServeDir from `{examples_root}/mcp-apps-chess/widgets`
     - `/map` -> ServeDir from `{examples_root}/mcp-apps-map/widgets`
     - `/dataviz` -> ServeDir from `{examples_root}/mcp-apps-dataviz/widgets`
   - Add CORS layer via tower-http (allow all origins -- test server only).
   - Bind to `127.0.0.1:0` (OS-assigned free port).
   - Spawn server on tokio task.
   - Return `(addr, handle)`.

**src/bridge.rs** -- Mock MCP bridge JavaScript generation:

1. Implement `pub async fn inject_mock_bridge(page: &Page, responses: &serde_json::Value) -> anyhow::Result<()>`:
   - Serialize `responses` to JSON string with `serde_json::to_string(responses)?`.
   - Build JavaScript string that:
     a. Creates `window.mcpBridge` object with `callTool`, `getState`, `setState`, `getHost` methods.
     b. `callTool(name, args)` looks up `name` in the responses object. If found, returns a deep clone (via `JSON.parse(JSON.stringify(...))`). If not found, returns `{ error: "Unknown tool" }`.
     c. Maintains a `__toolCallLog` array and wraps `callTool` to push `{ name, args, result }` on each call.
     d. `getState()` returns an internal state object.
     e. `setState(s)` merges into internal state and dispatches `widgetStateUpdate` CustomEvent.
     f. `getHost()` returns `{ type: 'standalone', capabilities: { tools: true, resources: true } }`.
   - Call `page.evaluate_on_new_document(AddScriptToEvaluateOnNewDocumentParams::new(script)).await?`.
   - Return Ok(()).

2. Implement `pub async fn get_tool_call_log(page: &Page) -> anyhow::Result<Vec<serde_json::Value>>`:
   - Evaluate `window.mcpBridge.__getToolCallLog()` via `page.evaluate()`.
   - Parse result as `Vec<serde_json::Value>`.
   - Return.

3. Implement `pub async fn get_widget_state(page: &Page) -> anyhow::Result<serde_json::Value>`:
   - Evaluate `window.mcpBridge.getState()` via `page.evaluate()`.
   - Return result.
  </action>
  <verify>
    <automated>cd /Users/guy/Development/mcp/sdk/rust-mcp-sdk && cargo check -p mcp-e2e-tests 2>&1 | tail -10</automated>
  </verify>
  <done>E2E test crate compiles with chromiumoxide, axum, and tower-http dependencies; lib.rs exports launch_browser, start_test_server, inject_mock_bridge; no test failures on check</done>
</task>

<task type="auto">
  <name>Task 2: Write chess, map, and data viz E2E test suites</name>
  <files>
    crates/mcp-e2e-tests/tests/chess.rs
    crates/mcp-e2e-tests/tests/map.rs
    crates/mcp-e2e-tests/tests/dataviz.rs
  </files>
  <action>
Write three test files porting the existing Playwright chess tests and creating new map + dataviz tests.

**CRITICAL -- E2E Selector Contracts (cross-referenced from 19-01-PLAN Task 1):**

Chess widget (board.html) -- verified from existing source:
- `#board` -- board container div
- `.square` -- individual board squares (64 total), with `data-file` and `data-rank` attributes
- `.square.selected` -- selected square has "selected" class
- `#status` -- game status text
- `#newGameBtn` -- new game button
- `#loading` -- loading indicator

Map widget (map.html) -- verified from existing source:
- `#map` -- Leaflet map container div
- `#searchInput` -- search input field
- `#categoryFilter` -- category filter select
- `#searchBtn` -- search button
- `.city-item` -- city list items (class, not ID)
- `#cityCount` -- city count span
- `#cityDetail` -- city detail panel (has `.visible` class when shown)
- `#loading` -- loading indicator

Dataviz widget (dashboard.html) -- contractual IDs from 19-01-PLAN:
- `#chart` -- Chart.js canvas element
- `#chartType` -- chart type select (bar/line/pie)
- `#dataTable` -- data table with `thead` and `tbody`
- `#queryInput` -- SQL query textarea
- `#runQueryBtn` -- run query button
- `#loading` -- loading indicator

CRITICAL: All tests MUST use `#[tokio::test(flavor = "multi_thread")]` (not the default single-threaded runtime). The chromiumoxide handler requires a multi-thread executor.

CRITICAL: Call `inject_mock_bridge` (or `new_page_with_bridge`) BEFORE `page.goto()`. If called after navigation, the mock bridge won't be available when widget scripts execute.

CRITICAL: Replace all Playwright `waitForTimeout(ms)` patterns with element-based waits. Use `page.find_element(selector)` which retries until found (with a timeout). If chromiumoxide's `find_element` doesn't have built-in retry, wrap it in a polling loop with a 5-second timeout and 100ms interval. Define a helper:
```rust
async fn wait_for_element(page: &Page, selector: &str, timeout_ms: u64) -> anyhow::Result<Element> {
    let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
    loop {
        match page.find_element(selector).await {
            Ok(el) => return Ok(el),
            Err(_) if std::time::Instant::now() < deadline => {
                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            },
            Err(e) => anyhow::bail!("Timed out waiting for selector '{}': {}", selector, e),
        }
    }
}
```

**tests/chess.rs** -- Port all 10 Playwright chess tests:

Mock response data (define as constants at top of file):
- `INITIAL_GAME_STATE`: JSON matching the chess board initial state (8x8 board array with pieces, turn: "white", history: [], castling rights, status: "inprogress"). Must match the exact shape expected by the chess widget (board is array of 8 arrays of 8 nullable piece objects with `type` and `color` fields).
- `MOVE_RESPONSE`: Success response with updated state (turn: "black", history: ["e2e4"]).
- `VALID_MOVES_E2`: Response for e2 pawn valid moves: `{ position: "e2", moves: ["e3", "e4"] }`.

Each test function:
1. Start test server (call `start_test_server()`).
2. Launch browser (call `launch_browser()` or `get_or_launch_browser()`).
3. Create page with mock bridge (responses include chess_new_game, chess_move, chess_valid_moves).
4. Navigate to `http://{addr}/chess/board.html`.
5. Assert conditions.

Tests to implement:
```rust
#[tokio::test(flavor = "multi_thread")]
async fn chess_board_renders_64_squares()
// Navigate, find all .square elements, assert count == 64

#[tokio::test(flavor = "multi_thread")]
async fn chess_initial_status_shows_white_to_move()
// Find #status element, evaluate innerText, assert contains "White to move"

#[tokio::test(flavor = "multi_thread")]
async fn chess_calls_new_game_on_init()
// Wait for tool calls, get_tool_call_log, find chess_new_game entry

#[tokio::test(flavor = "multi_thread")]
async fn chess_highlights_selected_piece()
// Click .square[data-file="4"][data-rank="1"], check element has "selected" class

#[tokio::test(flavor = "multi_thread")]
async fn chess_fetches_valid_moves_on_select()
// Click e2, wait, get_tool_call_log, find chess_valid_moves entry

#[tokio::test(flavor = "multi_thread")]
async fn chess_makes_move_on_valid_click()
// Click e2, wait, click e4 (data-file="4", data-rank="3"), check chess_move in log

#[tokio::test(flavor = "multi_thread")]
async fn chess_status_updates_after_move()
// Make move e2e4, check #status contains "Black"

#[tokio::test(flavor = "multi_thread")]
async fn chess_persists_state_via_bridge()
// Wait for init, get_widget_state, assert "gameState" key exists

#[tokio::test(flavor = "multi_thread")]
async fn chess_new_game_button_resets()
// Wait for init, click #newGameBtn, wait, check chess_new_game called >= 2 times

#[tokio::test(flavor = "multi_thread")]
async fn chess_handles_error_gracefully()
// Use error responses (chess_new_game returns error), navigate, assert #board still visible
```

For clicking specific squares: Use `page.evaluate()` with JS like `document.querySelector('.square[data-file="4"][data-rank="1"]').click()` since chromiumoxide's element click may not support attribute selectors directly.

**tests/map.rs** -- Map widget E2E tests:

Mock response data:
- `SEARCH_CITIES_RESPONSE`: JSON with `{ count: 3, cities: [...], map_state: {...} }` containing 3 sample cities (Tokyo, Paris, New York) with coordinates.
- `CITY_DETAILS_RESPONSE`: JSON with `{ found: true, city: {...}, suggested_view: { center: {...}, zoom: 12 } }`.

Tests:
```rust
#[tokio::test(flavor = "multi_thread")]
async fn map_container_renders()
// Navigate to /map/map.html, find #map element, assert visible

#[tokio::test(flavor = "multi_thread")]
async fn map_search_populates_city_list()
// Navigate, wait for search_cities tool call, find .city-item elements, assert count > 0

#[tokio::test(flavor = "multi_thread")]
async fn map_markers_appear_on_map()
// Navigate, wait for search, evaluate JS to count Leaflet markers on map

#[tokio::test(flavor = "multi_thread")]
async fn map_city_count_displays()
// Find #cityCount element, assert text is "3" (or matches city count)

#[tokio::test(flavor = "multi_thread")]
async fn map_city_detail_shows_on_click()
// Click first .city-item, wait for get_city_details tool call, find #cityDetail with class "visible" (selector: "#cityDetail.visible")
```

NOTE: The map widget uses Leaflet.js from CDN (`https://unpkg.com/leaflet@1.9.4/`). The headless browser must have internet access for tiles/library. If the CDN is unreachable, Leaflet won't load and the map won't render. This is acceptable -- the research notes this as a known consideration (Pitfall 4). Do NOT attempt to bundle Leaflet locally; just ensure tests skip gracefully if CDN is unreachable (wrap assertions in a way that the error message is clear).

**tests/dataviz.rs** -- Data viz widget E2E tests:

Mock response data:
- `EXECUTE_QUERY_RESPONSE`: JSON with `{ columns: ["Genre", "TrackCount"], rows: [["Rock", 1297], ["Latin", 579], ...], row_count: 10 }`.
- `LIST_TABLES_RESPONSE`: JSON with `{ tables: ["Album", "Artist", ...] }`.

Tests:
```rust
#[tokio::test(flavor = "multi_thread")]
async fn dataviz_chart_renders_after_query()
// Navigate to /dataviz/dashboard.html, wait for execute_query call, find #chart canvas element

#[tokio::test(flavor = "multi_thread")]
async fn dataviz_data_table_populates()
// Navigate, wait for query, find #dataTable tbody tr elements, assert count > 0

#[tokio::test(flavor = "multi_thread")]
async fn dataviz_table_has_correct_columns()
// Find #dataTable thead th elements, assert first is "Genre", second is "TrackCount"

#[tokio::test(flavor = "multi_thread")]
async fn dataviz_chart_type_switch()
// Navigate, wait for chart render, change #chartType select to "pie", verify #chart canvas still present
// (We can't easily verify the chart type visually -- just verify the canvas is re-rendered)

#[tokio::test(flavor = "multi_thread")]
async fn dataviz_calls_list_tables_on_init()
// Wait for tool calls, check list_tables in log
```

For select element interaction: Use `page.evaluate()` to change value and dispatch change event:
```javascript
const select = document.getElementById('chartType');
select.value = 'pie';
select.dispatchEvent(new Event('change'));
```
  </action>
  <verify>
    <automated>cd /Users/guy/Development/mcp/sdk/rust-mcp-sdk && cargo test -p mcp-e2e-tests -- --test-threads=1 2>&1 | tail -30</automated>
  </verify>
  <done>All three test files compile; chess tests (10), map tests (5), and dataviz tests (5) pass when run with cargo test -p mcp-e2e-tests -- --test-threads=1; test output shows 20 passed, 0 failed</done>
</task>

</tasks>

<verification>
1. `cargo check -p mcp-e2e-tests` compiles the full E2E test crate with all dependencies
2. `cargo test -p mcp-e2e-tests -- --test-threads=1` runs all tests
3. Chess tests: 10 tests pass (board render, status, new game init, piece select, valid moves, move execution, status update, state persistence, new game button, error handling)
4. Map tests: 5 tests pass (container render, city list, markers, count display, detail panel)
5. Data viz tests: 5 tests pass (chart render, table populate, column headers, chart type switch, list tables init)
6. `cargo clippy -p mcp-e2e-tests -- -D warnings` passes with zero warnings
7. `cargo fmt -p mcp-e2e-tests --check` passes
</verification>

<success_criteria>
- `cargo test -p mcp-e2e-tests -- --test-threads=1` executes all 20 E2E tests and all pass
- The test crate uses chromiumoxide for CDP browser automation with auto-downloaded Chromium
- An embedded axum server serves widget files from the three example directories at OS-assigned ports
- Mock bridge injection via evaluate_on_new_document provides canned tool responses without a real MCP server
- Each test file validates the happy path and key interactions for its widget, matching the test scenarios defined in CONTEXT.md
- Just recipes `just test-e2e`, `just test-e2e-chess`, `just test-e2e-map`, `just test-e2e-dataviz` work correctly
</success_criteria>

<output>
After completion, create `.planning/phases/19-ship-examples-and-playwright-e2e/19-02-SUMMARY.md`
</output>
