* feat(gateway): full settings page polish with all tiers - Backend: add ActiveConfigSnapshot to expose resolved LLM backend, model, and enabled channels via /api/gateway/status - Add missing Agent settings (daily cost cap, actions/hour, local tools) - Add Sandbox, Routines, Safety, Skills, and Search setting groups - Settings import/export (JSON download + file upload) - Active env defaults shown as placeholders in Inference settings - Styled confirmation modals replace window.confirm() for remove actions - Global restart banner persists across settings subtab switches - Client-side validation with min/max constraints on number inputs - Accessibility: aria-label on inputs, role=status on save indicators - Settings search filters rows across current subtab - Smooth CSS transitions for conditional field visibility (showWhen) - Tunnel settings in Channels subtab - Mobile responsive settings layout at 768px breakpoint - i18n keys for toolbar, search, and import/export in en + zh-CN Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(gateway): polish settings page and remove registered tools debug section Remove the "Registered Tools" table from the extensions tab (debug info not useful to end users), clean up associated CSS/i18n/JS. Additional settings page UI polish: extension card state styling, layout refinements. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): address PR review feedback [skip-regression-check] - Use refreshCurrentSettingsTab() in SSE event handlers to reduce duplication - Remove unused formatGroupName/formatSettingLabel helpers - Use i18n keys for MCP Configure/Reconfigure buttons - Add data-i18n-placeholder to settings search input - Remove data-i18n from confirm modal button (set dynamically by showConfirmModal) - Fix cargo fmt in main.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): update tests for unified settings tab layout [skip-regression-check] - Update TABS list: replace extensions/skills with settings - Add settings_subtab/settings_subpanel selectors to helpers - Update test_connection, test_skills, test_extensions, test_wasm_lifecycle to navigate via Settings > subtab instead of top-level tabs - Move MCP card tests to use go_to_mcp() helper (MCP is now a separate subtab) - Remove tools table tests and mock_ext_apis tools= parameter - Fix CSP violation: replace inline onclick on confirm modal cancel button Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): address second round of PR review feedback [skip-regression-check] - Use I18n.t() for MCP empty state, export/import toasts, confirm modal - Fix CLI channel card using wrong channel key ('repl' -> 'cli') - Fix settings search counting hidden rows as visible - Add aria-label i18n for settings search input - Add common.loadFailed i18n key (en + zh-CN) - Update E2E tests: WASM channel tests use Channels subtab, remove tests use custom confirm modal instead of window.confirm Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): fix WASM channel card selector and skills remove confirm [skip-regression-check] - WASM channel tests: filter by display name to avoid matching built-in channel cards in the Channels subtab - Skills remove test: click confirm modal button instead of using window.confirm (skill removal now uses custom confirm modal) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): address third round of PR review feedback [skip-regression-check] - approval_needed SSE: refresh any active settings subtab, not just Extensions — approvals can surface from Channels/MCP setup flows too - renderCardsSkeleton: remove nested .extensions-list wrapper that caused skeleton cards to render constrained inside grid cells Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): fix auth_completed reload test race condition [skip-regression-check] Use expect_response to deterministically wait for the /api/extensions reload triggered by handleAuthCompleted → refreshCurrentSettingsTab, instead of a fixed 600ms sleep that was too short under CI load. Also remove stale /api/extensions/tools route handler. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): debug auth_completed reload test with function counter [skip-regression-check] Inject a counter wrapper around refreshCurrentSettingsTab to verify it's actually called, and wait for the async fetch to complete before asserting the reload count. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(gateway): localize all settings labels, descriptions, and channel cards [skip-regression-check] Move 120+ hardcoded strings in settings definitions (INFERENCE_SETTINGS, AGENT_SETTINGS, NETWORKING_SETTINGS) and channel card labels to i18n keys. Render functions now resolve labels via I18n.t() so the settings page translates when switching locales. Covers: group titles, setting labels/descriptions, built-in channel names/descriptions, and the "No settings found" empty state. Both en.js and zh-CN.js updated with all new cfg.* and channels.* keys. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): localize remaining hardcoded UI strings [skip-regression-check] - Fix export error toast using wrong i18n key (importFailed → exportFailed) - Replace "Failed to load settings:" with I18n.t('common.loadFailed') - Localize renderBuiltinChannelCard: "Built-in", "Active", "Inactive" - Localize settings placeholders: "env: ", "env default", "use env default" - Localize "✓ Saved" indicator - Add new i18n keys to both en.js and zh-CN.js Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): confirm modal a11y, Esc/click-outside, search guard [skip-regression-check] - Add role="dialog", aria-modal="true", aria-labelledby to confirm modal - Focus confirm button when modal opens - Close modal on Escape key or overlay click - Skip settings search on non-settings panels (Extensions/MCP/Skills/Channels) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): boolean tri-state, search reset on subtab switch, stale model suggestions [skip-regression-check] Address PR review feedback: - Boolean settings now use a tri-state select (env default / On / Off) instead of a checkbox, matching the pattern used by other select settings and allowing users to revert to the env default - Clear search input when switching settings subtabs so stale filters don't carry over to the new panel - Always assign model suggestions (even empty array) so stale IDs from a previous successful /v1/models fetch don't persist when the endpoint later returns empty Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): auth_completed handler, bedrock_cross_region select, integer-only number inputs [skip-regression-check] Address PR review feedback: - auth_completed SSE listener now delegates to handleAuthCompleted(data) instead of inlining logic with a bare closeConfigureModal() call, so only the matching extension's modal is dismissed - bedrock_cross_region changed from free text to select with the four valid values (us/eu/apac/global), matching backend validation - Number settings now use step=1 and parseInt() instead of parseFloat(), preventing fractional values that the backend (u32/u64) would reject Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
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:
- Mock LLM (
mock_llm.py) -- fake OpenAI-compat server with canned responses - 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
- Create
tests/e2e/scenarios/test_<name>.py - Use the
pagefixture for a fresh browser page - Use selectors from
helpers.py(updateSELdict if new elements are needed) - 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 most of test_tool_approval.py and parts of
test_extensions.py (auth card, configure modal). The waiting-approval
regression in test_tool_approval.py uses a real tool call instead so it can
exercise backend approval state.