Files
optimclaw/tests/e2e
b425213c53 feat(e2e): extensions tab tests, CI parallelization, and 3 production bug fixes (#584)
* feat(e2e): extensions tab tests, CI parallelization, and 3 bug fixes

## E2E test coverage
- Add tests/e2e/scenarios/test_extensions.py with 57 tests covering all
  extensions tab flows: installed WASM tool/MCP/channel cards, configure
  modal (open, fields, cancel, save, OAuth, error), auth card (token,
  OAuth, submit, cancel, error, multi-extension coexistence), activate
  flow, install/remove flows, WASM channel stepper states, and tab reload
  behaviour. All network calls intercepted via page.route() — no real
  binaries or external registries needed.
- Expand tests/e2e/helpers.py with 50+ new CSS selectors for the
  extensions tab UI.
- Add tests/e2e/README.md documentation on the page.route() mocking
  pattern, LIFO handler ordering, and page.evaluate() injection.

## CI parallelization
- Split .github/workflows/e2e.yml into a build job (compile once,
  upload artifact) and a 3-way parallel test matrix (core / features /
  extensions), matching the pattern in test.yml. Reduces wall-clock time
  from ~15–20 min serial to ~10–12 min. Adds an e2e roll-up job for
  branch protection.

## Bug fixes in app.js (found via test-driven code review)
- Fix null crash: renderExtensionCard() called ext.tools.length without
  a null guard; add ext.tools && check (regression: test_ext_tools_null).
- Fix modal UX: submitConfigureModal() closed the overlay before checking
  success, making failures unrecoverable without reopening; close only on
  success, re-enable buttons and keep modal open on failure
  (regression: test_configure_modal_stays_open_on_save_failure).
- Fix URL injection: all window.open() calls for server-supplied auth_url
  now go through openOAuthUrl() which rejects non-HTTPS schemes
  (regression: test_oauth_url_injection_blocked).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(e2e): prune extensions tests 57→46 by merging redundant setups

Merge 11 tests that shared identical fixture+navigation overhead:
- Group A: 3 empty-state tests → test_extensions_empty_tab_layout
- Group B: card_renders absorbs ext_tools_list_shown (same _WASM_TOOL fixture)
- Group B: auth_dot_unauthed + unauthed_shows_configure_btn → test_installed_wasm_tool_unauthed_state
- Group D: installed + configured states → test_wasm_channel_setup_states (identical UI)
- Group D: failed_state + stepper_failed_circle → test_wasm_channel_failed_renders
- Group G: 5 field badge tests → test_configure_modal_field_variants (4 fields, one pass)
- Group H: submit_success + enter_key_submits → test_auth_card_submit_success

Coverage preserved: all assertions kept, no unique behaviors removed.
Extensions CI job estimated to drop from ~7 min to ~5 min.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(e2e): fix configure_input selector scoping in merged field variants test

modal.locator(".configure-modal input[type='password']") scoped the absolute
selector inside .configure-modal, effectively searching for a nested
.configure-modal which never exists → count() == 0. Use page.locator()
instead, consistent with all other tests in the file.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(e2e): address PR review comments — replace fixed sleeps with deterministic waits

- Remove unnecessary wait_for_timeout(1000) from test_remove_cancelled_keeps_card
  (window.confirm = () => false is synchronous; DOM is unchanged when click() returns)
- Replace wait_for_timeout(800) with wait_for_function() for window._lastOpenedUrl
  checks in configure_modal_save_oauth and activate_with_auth_url_opens_popup
- Replace wait_for_timeout(300) with nth(1).wait_for(visible) in
  test_auth_card_multiple_extensions_coexist
- Remove wait_for_timeout(800/300) in test_auth_card_submit_empty_noop and
  test_auth_completed_sse_dismisses_card (both check synchronous JS side-effects)
- Add comment in test_oauth_url_injection_blocked explaining why timeout is kept
  (negative assertion — cannot use wait_for_function for absence of event)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(e2e): address remaining PR review comments

- Remove unused `import pytest` from test_extensions.py
- Fix unawaited coroutine bug: convert lambda route handlers to async def
  in test_extensions_tab_reloads_on_revisit and
  test_auth_completed_sse_triggers_extensions_reload (lambda r: r.fulfill(...)
  returns an unawaited coroutine; requests silently fell through to real server)
- Fix README.md example to use async def handler (same bug in docs)
- Harden openOAuthUrl() in app.js: use URL constructor instead of
  .startsWith() so non-string server-supplied values (objects, null, etc.)
  are safely rejected rather than throwing TypeError

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(e2e): address second round of PR review comments

- Add timeout-minutes to CI build job to prevent hung workflows
- Use parsed.href instead of raw url in openOAuthUrl for safety
- Remove unused MessageEvent variable in auth_completed test
- Replace wait_for_timeout(800) with expect_response in activate test
- Replace wait_for_timeout(300) with tab panel wait_for in reload test

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-06 08:27:38 +00:00
..

IronClaw E2E Tests

Browser-level end-to-end tests for the IronClaw web gateway using Python + Playwright.

Prerequisites

  • Python 3.11+
  • Rust toolchain (for building ironclaw)
  • Chromium (installed via Playwright)

Setup

cd tests/e2e
pip install -e .
playwright install chromium

Build ironclaw

The tests need the ironclaw binary built with libsql support:

cargo build --no-default-features --features libsql

Run tests

# From repo root
pytest tests/e2e/ -v

# Run a single scenario
pytest tests/e2e/scenarios/test_chat.py -v

# With visible browser (not headless)
HEADED=1 pytest tests/e2e/scenarios/test_connection.py -v

Architecture

Tests start two subprocesses:

  1. Mock LLM (mock_llm.py) -- fake OpenAI-compat server with canned responses
  2. IronClaw -- the real binary with gateway enabled, pointing to the mock LLM

Then Playwright drives a headless Chromium browser against the gateway, making DOM assertions.

Scenarios

File What it tests
test_connection.py Auth, tab navigation, connection status
test_chat.py Send message, SSE streaming, response rendering
test_skills.py ClawHub search, skill install/remove
test_tool_approval.py Tool approval overlay (approve, deny, always, params toggle)
test_sse_reconnect.py SSE reconnection handling
test_html_injection.py HTML injection security
test_extensions.py Extensions tab: install, remove, configure, OAuth, auth card, activate

Adding new scenarios

  1. Create tests/e2e/scenarios/test_<name>.py
  2. Use the page fixture for a fresh browser page
  3. Use selectors from helpers.py (update SEL dict if new elements are needed)
  4. Keep tests deterministic -- use the mock LLM, not real providers

Mocking API responses with page.route()

For tabs that depend on external data (extensions, jobs, memory, routines), use Playwright's page.route() to intercept the browser's HTTP requests to the ironclaw gateway and return deterministic fixture JSON. This avoids needing real installed binaries, live external services, or complex database setup.

Basic pattern

import json

async def test_something(page):
    # 1. Set up route intercepts BEFORE navigation triggers the fetch
    # Always use async def handlers — route.fulfill() is a coroutine and must be awaited.
    async def handle_tools(route):
        await route.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps({"tools": [{"name": "echo", "description": "Echo"}]}),
        )

    await page.route("**/api/extensions/tools", handle_tools)

    # 2. Navigate / interact to trigger the fetch
    await page.locator('.tab-bar button[data-tab="extensions"]').click()

    # 3. Assert on the rendered DOM
    rows = page.locator("#tools-tbody tr")
    assert await rows.count() == 1

Matching only the exact path

**/api/extensions matches http://host/api/extensions but NOT sub-paths like http://host/api/extensions/install. For the bare list endpoint, add a check inside the handler:

async def handle_ext_list(route):
    path = route.request.url.split("?")[0]
    if path.endswith("/api/extensions"):
        await route.fulfill(json={"extensions": []})
    else:
        await route.continue_()   # Let sub-paths through to the real server

await page.route("**/api/extensions*", handle_ext_list)

Mocking method-specific behaviour (GET vs POST)

async def handle_setup(route):
    if route.request.method == "GET":
        await route.fulfill(json={"secrets": [...]})
    else:  # POST
        await route.fulfill(json={"success": True})

await page.route("**/api/extensions/my-ext/setup", handle_setup)

Counting calls (for reload tests)

calls = []

async def counting_handler(route):
    calls.append(1)
    await route.fulfill(json={"extensions": []})

await page.route("**/api/extensions", counting_handler)
# ... interact ...
assert len(calls) == 2   # called twice (initial + after some action)

Applying the pattern to other tabs

Tab Key API endpoints to mock
Jobs /api/jobs, /api/jobs/{id}, /api/jobs/{id}/events
Memory /api/memory/search, /api/memory/tree, /api/memory/read
Routines /api/routines, /api/routines/{id}/runs

Injecting state directly via page.evaluate()

For purely client-side UI (components rendered entirely in JS without API calls), call the JavaScript function directly to skip the network layer entirely:

# Show an approval card without needing a real tool execution
await page.evaluate("""
    showApproval({
        request_id: 'test-001',
        thread_id: currentThreadId,
        tool_name: 'shell',
        description: 'Run something',
    })
""")

This is the pattern used in test_tool_approval.py and parts of test_extensions.py (auth card, configure modal).