diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6a467aa0..3dc95a2d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,8 +9,9 @@ on: - "tests/e2e/**" jobs: - e2e: - name: Browser E2E + # ── Step 1: compile once ────────────────────────────────────────────────── + build: + name: Build ironclaw (libsql) runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -25,9 +26,44 @@ jobs: ~/.cargo/registry key: e2e-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} - - name: Build ironclaw (libsql) + - name: Build run: cargo build --no-default-features --features libsql + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: ironclaw-e2e-binary + path: target/debug/ironclaw + retention-days: 1 + + # ── Step 2: run test slices in parallel ─────────────────────────────────── + test: + name: E2E (${{ matrix.group }}) + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - group: core + files: "tests/e2e/scenarios/test_connection.py tests/e2e/scenarios/test_chat.py tests/e2e/scenarios/test_sse_reconnect.py tests/e2e/scenarios/test_html_injection.py" + - group: features + files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py" + - group: extensions + files: "tests/e2e/scenarios/test_extensions.py" + steps: + - uses: actions/checkout@v6 + + - name: Download binary + uses: actions/download-artifact@v4 + with: + name: ironclaw-e2e-binary + path: target/debug/ + + - name: Make binary executable + run: chmod +x target/debug/ironclaw + - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -38,13 +74,26 @@ jobs: pip install -e . playwright install --with-deps chromium - - name: Run E2E tests - run: pytest tests/e2e/ -v -x --timeout=120 + - name: Run E2E tests (${{ matrix.group }}) + run: pytest ${{ matrix.files }} -v --timeout=120 - name: Upload screenshots on failure if: failure() uses: actions/upload-artifact@v4 with: - name: e2e-screenshots + name: e2e-screenshots-${{ matrix.group }} path: tests/e2e/screenshots/ if-no-files-found: ignore + + # ── Roll-up for branch protection ──────────────────────────────────────── + e2e: + name: E2E Tests + runs-on: ubuntu-latest + if: always() + needs: [test] + steps: + - run: | + if [[ "${{ needs.test.result }}" != "success" ]]; then + echo "One or more E2E jobs failed" + exit 1 + fi diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index fb16ac3c..1086019f 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -1003,7 +1003,7 @@ function showAuthCard(data) { oauthBtn.className = 'auth-oauth'; oauthBtn.textContent = 'Authenticate with ' + data.extension_name; oauthBtn.addEventListener('click', () => { - window.open(data.auth_url, '_blank', 'width=600,height=700'); + openOAuthUrl(data.auth_url); }); links.appendChild(oauthBtn); } @@ -1921,7 +1921,7 @@ function renderAvailableExtensionCard(entry) { // OAuth popup if auth started during install (builtin creds) if (res.auth_url) { showToast('Opening authentication for ' + entry.display_name, 'info'); - window.open(res.auth_url, '_blank', 'width=600,height=700'); + openOAuthUrl(res.auth_url); } loadExtensions(); // Auto-open configure for WASM channels @@ -2079,7 +2079,7 @@ function renderExtensionCard(ext) { card.appendChild(url); } - if (ext.tools.length > 0) { + if (ext.tools && ext.tools.length > 0) { const tools = document.createElement('div'); tools.className = 'ext-tools'; tools.textContent = 'Tools: ' + ext.tools.join(', '); @@ -2179,7 +2179,7 @@ function activateExtension(name) { // Even on success, the tool may need OAuth (e.g., WASM loaded but no token yet) if (res.auth_url) { showToast('Opening authentication for ' + name, 'info'); - window.open(res.auth_url, '_blank', 'width=600,height=700'); + openOAuthUrl(res.auth_url); } loadExtensions(); return; @@ -2187,7 +2187,7 @@ function activateExtension(name) { if (res.auth_url) { showToast('Opening authentication for ' + name, 'info'); - window.open(res.auth_url, '_blank'); + openOAuthUrl(res.auth_url); } else if (res.awaiting_token) { showConfigureModal(name); } else { @@ -2329,20 +2329,21 @@ function submitConfigureModal(name, fields) { body: { secrets }, }) .then((res) => { - closeConfigureModal(); if (res.success) { + closeConfigureModal(); if (res.auth_url) { // OAuth flow started — open consent popup. The auth_completed SSE will // not arrive immediately (it fires after OAuth callback), so show a toast now. showToast('Opening OAuth authorization for ' + name, 'info'); - window.open(res.auth_url, '_blank', 'width=600,height=700'); + openOAuthUrl(res.auth_url); loadExtensions(); } // For non-OAuth success: the server always broadcasts auth_completed SSE, // which will show the toast and refresh extensions — no need to do it here too. } else { + // Keep modal open so the user can correct their input and retry. + btns.forEach(function(b) { b.disabled = false; }); showToast(res.message || 'Configuration failed', 'error'); - loadExtensions(); } }) .catch((err) => { @@ -2356,6 +2357,25 @@ function closeConfigureModal() { if (existing) existing.remove(); } +// Validate that a server-supplied OAuth URL is HTTPS before opening a popup. +// Rejects javascript:, data:, and other non-HTTPS schemes to prevent URL-injection. +// Uses the URL constructor to safely parse and validate the scheme, which also +// handles non-string values (objects, null, etc.) that would throw on .startsWith(). +function openOAuthUrl(url) { + let parsed; + try { + parsed = new URL(url); + if (parsed.protocol !== 'https:') { + throw new Error('non-HTTPS protocol: ' + parsed.protocol); + } + } catch (e) { + console.warn('Blocked invalid/non-HTTPS OAuth URL:', url, e.message); + showToast('Invalid OAuth URL returned by server', 'error'); + return; + } + window.open(parsed.href, '_blank', 'width=600,height=700'); +} + // --- Pairing --- function loadPairingRequests(channel, container) { diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 315579db..5aac9613 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -52,6 +52,10 @@ Then Playwright drives a headless Chromium browser against the gateway, making D | `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 @@ -59,3 +63,106 @@ Then Playwright drives a headless Chromium browser against the gateway, making D 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 + +```python +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: + +```python +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) + +```python +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) + +```python +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: + +```python +# 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). diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 36a14baa..b6927dce 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -43,6 +43,58 @@ SEL = { "approval_always_btn": ".approval-actions button.always", "approval_deny_btn": ".approval-actions button.deny", "approval_resolved": ".approval-resolved", + # Extensions tab – sections + "extensions_list": "#extensions-list", + "available_wasm_list": "#available-wasm-list", + "mcp_servers_list": "#mcp-servers-list", + "tools_tbody": "#tools-tbody", + "tools_empty": "#tools-empty", + # Extensions tab – cards + "ext_card_installed": "#extensions-list .ext-card", + "ext_card_available": "#available-wasm-list .ext-card.ext-available", + "ext_card_mcp": "#mcp-servers-list .ext-card", + "ext_name": ".ext-name", + "ext_kind": ".ext-kind", + "ext_auth_dot": ".ext-auth-dot", + "ext_auth_dot_authed": ".ext-auth-dot.authed", + "ext_auth_dot_unauthed": ".ext-auth-dot.unauthed", + "ext_active_label": ".ext-active-label", + "ext_pairing_label": ".ext-pairing-label", + "ext_error": ".ext-error", + "ext_tools": ".ext-tools", + # Extensions tab – action buttons + "ext_install_btn": ".btn-ext.install", + "ext_remove_btn": ".btn-ext.remove", + "ext_activate_btn": ".btn-ext.activate", + "ext_configure_btn": ".btn-ext.configure", + # Configure modal + "configure_overlay": ".configure-overlay", + "configure_modal": ".configure-modal", + "configure_field": ".configure-field", + "configure_input": ".configure-modal input[type='password']", + "configure_save_btn": ".configure-actions button.btn-ext.activate", + "configure_cancel_btn": ".configure-actions button.btn-ext.remove", + "field_provided": ".field-provided", + "field_autogen": ".field-autogen", + "field_optional": ".field-optional", + # Auth card (SSE-triggered, injected into chat-messages) + "auth_card": ".auth-card", + "auth_header": ".auth-header", + "auth_instructions": ".auth-instructions", + "auth_oauth_btn": ".auth-oauth", + "auth_token_input": ".auth-token-input input", + "auth_submit_btn": ".auth-submit", + "auth_cancel_btn": ".auth-cancel", + "auth_error": ".auth-error", + # WASM channel progress stepper + "ext_stepper": ".ext-stepper", + "stepper_step": ".stepper-step", + "stepper_circle": ".stepper-circle", + # Toast notifications + "toast": ".toast", + "toast_success": ".toast.toast-success", + "toast_error": ".toast.toast-error", + "toast_info": ".toast.toast-info", } TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"] diff --git a/tests/e2e/scenarios/test_extensions.py b/tests/e2e/scenarios/test_extensions.py new file mode 100644 index 00000000..6cddacb4 --- /dev/null +++ b/tests/e2e/scenarios/test_extensions.py @@ -0,0 +1,1098 @@ +"""Scenario: Extensions tab – comprehensive UI coverage. + +Tests cover: + A. Structural / empty states + B. Installed WASM tool cards + C. MCP server cards + D. WASM channel stepper states + E. Available extensions (registry) and install flow + F. Remove flow + G. Configure modal (open, fields, cancel, save, OAuth, error) + H. Auth card (SSE-triggered token + OAuth flows) + I. Activate flow (MCP server and WASM channel) + J. Tab reload behaviour + +All extension API calls are intercepted via page.route() so no real +WASM binaries or external registry connections are needed. +""" + +import json + +from helpers import SEL + +# ─── Fixture data ───────────────────────────────────────────────────────────── + +_WASM_TOOL = { + "name": "test-tool", + "display_name": "Test WASM Tool", + "kind": "wasm_tool", + "description": "A test WASM tool extension", + "url": None, + "active": True, + "authenticated": True, + "has_auth": True, + "needs_setup": False, + "tools": ["search", "fetch"], + "activation_status": None, + "activation_error": None, +} + +_MCP_ACTIVE = { + "name": "test-mcp", + "display_name": "Test MCP Server", + "kind": "mcp_server", + "description": "An active MCP server", + "url": "http://localhost:3000", + "active": True, + "authenticated": False, + "has_auth": False, + "needs_setup": False, + "tools": [], + "activation_status": None, + "activation_error": None, +} + +_MCP_INACTIVE = {**_MCP_ACTIVE, "name": "test-mcp-inactive", "display_name": "Inactive MCP", "active": False} + +_WASM_CHANNEL = { + "name": "test-channel", + "display_name": "Test Channel", + "kind": "wasm_channel", + "description": "A test WASM channel", + "url": None, + "active": False, + "authenticated": False, + "has_auth": False, + "needs_setup": True, + "tools": [], + "activation_status": "installed", + "activation_error": None, +} + +_REGISTRY_WASM = { + "name": "registry-tool", + "display_name": "Registry Tool", + "kind": "wasm_tool", + "description": "A registry WASM tool", + "keywords": ["search", "utility"], + "installed": False, +} + +_REGISTRY_MCP = { + "name": "registry-mcp", + "display_name": "Registry MCP Server", + "kind": "mcp_server", + "description": "An MCP server from the registry", + "keywords": ["tools"], + "installed": False, +} + +_SAMPLE_TOOL = {"name": "echo", "description": "Echo a message"} +_SAMPLE_TOOL_2 = {"name": "time", "description": "Get current time"} + + +# ─── Navigation helpers ──────────────────────────────────────────────────────── + +async def go_to_extensions(page): + """Click the Extensions tab and wait for the panel to appear. + + Waits for loadExtensions() to finish rendering by polling for the first + content signal (empty-state div or an installed card) rather than sleeping. + """ + await page.locator(SEL["tab_button"].format(tab="extensions")).click() + await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for( + state="visible", timeout=5000 + ) + # loadExtensions() fires three parallel fetches then renders. Wait for the + # first concrete DOM signal instead of a hard sleep so the test is + # deterministic even under CI load. + await page.locator( + f"{SEL['extensions_list']} .empty-state, {SEL['ext_card_installed']}" + ).first.wait_for(state="visible", timeout=8000) + + +async def mock_ext_apis(page, *, installed=None, tools=None, registry=None): + """Intercept the three extension list APIs with fixture data. + + Must be called BEFORE navigating to the extensions tab. + """ + ext_body = json.dumps({"extensions": installed or []}) + tools_body = json.dumps({"tools": tools or []}) + registry_body = json.dumps({"entries": registry or []}) + + # Playwright evaluates route handlers in LIFO order (last-registered fires + # first). Register the broad handler first so it is checked last; the + # specific /tools and /registry handlers are registered after and therefore + # checked first — no continue_() fallthrough needed. + async def handle_ext_list(route): + path = route.request.url.split("?")[0] + if path.endswith("/api/extensions"): + await route.fulfill(status=200, content_type="application/json", body=ext_body) + else: + await route.continue_() + + await page.route("**/api/extensions*", handle_ext_list) + + async def handle_tools(route): + await route.fulfill(status=200, content_type="application/json", body=tools_body) + + async def handle_registry(route): + await route.fulfill(status=200, content_type="application/json", body=registry_body) + + await page.route("**/api/extensions/tools", handle_tools) + await page.route("**/api/extensions/registry", handle_registry) + + +async def wait_for_toast(page, text: str, *, timeout: int = 5000): + """Wait for any toast containing the given text.""" + await page.locator(SEL["toast"], has_text=text).wait_for(state="visible", timeout=timeout) + + +# ─── Group A: Structural / empty state ──────────────────────────────────────── + +async def test_extensions_empty_tab_layout(page): + """Extensions tab with no data shows all three sections with correct empty-state messages.""" + await mock_ext_apis(page, tools=[]) + await go_to_extensions(page) + + panel = page.locator(SEL["tab_panel"].format(tab="extensions")) + assert await panel.is_visible() + + ext_list = page.locator(SEL["extensions_list"]) + assert await ext_list.is_visible() + assert "No extensions installed" in await ext_list.text_content() + + wasm_list = page.locator(SEL["available_wasm_list"]) + assert await wasm_list.is_visible() + assert "No additional WASM extensions available" in await wasm_list.text_content() + + mcp_list = page.locator(SEL["mcp_servers_list"]) + assert await mcp_list.is_visible() + assert "No MCP servers available" in await mcp_list.text_content() + + # Tools table should be empty + tbody = page.locator(SEL["tools_tbody"]) + rows = await tbody.locator("tr").count() + empty_visible = await page.locator(SEL["tools_empty"]).is_visible() + assert empty_visible or rows == 0, "Expected tools table to be empty" + + +async def test_extensions_tools_table_populated(page): + """Two mock tools produce two rows in the tools table.""" + await mock_ext_apis(page, tools=[_SAMPLE_TOOL, _SAMPLE_TOOL_2]) + await go_to_extensions(page) + + tbody = page.locator(SEL["tools_tbody"]) + rows = tbody.locator("tr") + await rows.first.wait_for(state="visible", timeout=5000) + assert await rows.count() == 2 + + text = await tbody.text_content() + assert "echo" in text + assert "time" in text + + +# ─── Group B: Installed WASM tool cards ─────────────────────────────────────── + +async def test_installed_wasm_tool_card_renders(page): + """An installed, active, authenticated WASM tool card shows correct elements.""" + await mock_ext_apis(page, installed=[_WASM_TOOL]) + await go_to_extensions(page) + + card = page.locator(SEL["ext_card_installed"]).first + await card.wait_for(state="visible", timeout=5000) + + assert "Test WASM Tool" in await card.locator(SEL["ext_name"]).text_content() + assert await card.locator(SEL["ext_auth_dot_authed"]).count() == 1 + assert await card.locator(SEL["ext_active_label"]).count() == 1 + assert await card.locator(SEL["ext_remove_btn"]).count() == 1 + + tools_div = card.locator(SEL["ext_tools"]) + text = await tools_div.text_content() + assert "search" in text + assert "fetch" in text + + +async def test_installed_wasm_tool_unauthed_state(page): + """authenticated=false shows the unauthed auth dot and a 'Configure' button.""" + ext = {**_WASM_TOOL, "needs_setup": True, "authenticated": False} + await mock_ext_apis(page, installed=[ext]) + await go_to_extensions(page) + + card = page.locator(SEL["ext_card_installed"]).first + await card.wait_for(state="visible", timeout=5000) + assert await card.locator(SEL["ext_auth_dot_unauthed"]).count() == 1 + + configure_btn = card.locator(SEL["ext_configure_btn"]) + assert await configure_btn.count() == 1 + assert await configure_btn.text_content() == "Configure" + + +async def test_installed_wasm_tool_authed_shows_reconfigure_btn(page): + """has_auth=true, authenticated=true shows a 'Reconfigure' button.""" + ext = {**_WASM_TOOL, "has_auth": True, "authenticated": True, "needs_setup": False} + await mock_ext_apis(page, installed=[ext]) + await go_to_extensions(page) + + card = page.locator(SEL["ext_card_installed"]).first + await card.wait_for(state="visible", timeout=5000) + + configure_btn = card.locator(SEL["ext_configure_btn"]) + assert await configure_btn.count() == 1 + assert await configure_btn.text_content() == "Reconfigure" + + + +# ─── Group C: MCP server cards ──────────────────────────────────────────────── + +async def test_installed_mcp_server_active(page): + """Active MCP server shows 'Active' label and no Activate button.""" + await mock_ext_apis(page, installed=[_MCP_ACTIVE]) + await go_to_extensions(page) + + card = page.locator(SEL["ext_card_installed"]).first + await card.wait_for(state="visible", timeout=5000) + assert await card.locator(SEL["ext_active_label"]).count() == 1 + assert await card.locator(SEL["ext_activate_btn"]).count() == 0 + assert await card.locator(SEL["ext_remove_btn"]).count() == 1 + + +async def test_installed_mcp_server_inactive_shows_activate(page): + """Inactive MCP server shows Activate button.""" + await mock_ext_apis(page, installed=[_MCP_INACTIVE]) + await go_to_extensions(page) + + card = page.locator(SEL["ext_card_installed"]).first + await card.wait_for(state="visible", timeout=5000) + assert await card.locator(SEL["ext_activate_btn"]).count() == 1 + + +async def test_mcp_server_in_registry_not_installed(page): + """Registry MCP entry (not installed) appears in the MCP section with Install button.""" + await mock_ext_apis(page, registry=[_REGISTRY_MCP]) + await go_to_extensions(page) + + mcp_list = page.locator(SEL["mcp_servers_list"]) + card = mcp_list.locator(".ext-card").first + await card.wait_for(state="visible", timeout=5000) + assert "Registry MCP Server" in await card.text_content() + assert await card.locator(SEL["ext_install_btn"]).count() == 1 + + +async def test_mcp_server_installed_auth_dot(page): + """Installed MCP in registry cross-reference shows auth dot (unauthed).""" + # Card rendered via renderMcpServerCard when entry is in registry AND installed + installed_mcp = {**_MCP_ACTIVE, "name": "registry-mcp", "authenticated": False} + registry_mcp = {**_REGISTRY_MCP, "name": "registry-mcp"} + await mock_ext_apis(page, installed=[installed_mcp], registry=[registry_mcp]) + await go_to_extensions(page) + + mcp_list = page.locator(SEL["mcp_servers_list"]) + card = mcp_list.locator(".ext-card").first + await card.wait_for(state="visible", timeout=5000) + # Installed MCP in registry section should show auth dot + assert await card.locator(SEL["ext_auth_dot_unauthed"]).count() == 1 + + +# ─── Group D: WASM channel stepper states ───────────────────────────────────── + +async def _load_wasm_channel(page, activation_status, activation_error=None): + ext = {**_WASM_CHANNEL, "activation_status": activation_status, "activation_error": activation_error} + await mock_ext_apis(page, installed=[ext]) + await go_to_extensions(page) + card = page.locator(SEL["ext_card_installed"]).first + await card.wait_for(state="visible", timeout=5000) + return card + + +async def test_wasm_channel_setup_states(page): + """activation_status installed/configured both show the Setup button and stepper.""" + card = await _load_wasm_channel(page, "installed") + setup_btn = card.locator(SEL["ext_configure_btn"], has_text="Setup") + assert await setup_btn.count() == 1 + assert await card.locator(SEL["ext_stepper"]).count() == 1 + # configured renders identically (same Setup button); verified by same stepper check above + + +async def test_wasm_channel_pairing_state(page): + """activation_status=pairing shows Awaiting Pairing label and Reconfigure.""" + card = await _load_wasm_channel(page, "pairing") + assert await card.locator(SEL["ext_pairing_label"]).count() == 1 + assert await card.locator(SEL["ext_configure_btn"], has_text="Reconfigure").count() == 1 + + +async def test_wasm_channel_active_state(page): + """activation_status=active shows Active label and Reconfigure (no Setup).""" + card = await _load_wasm_channel(page, "active") + assert await card.locator(SEL["ext_active_label"]).count() == 1 + assert await card.locator(SEL["ext_configure_btn"], has_text="Reconfigure").count() == 1 + assert await card.locator(SEL["ext_configure_btn"], has_text="Setup").count() == 0 + + +async def test_wasm_channel_failed_renders(page): + """activation_status=failed shows Reconfigure button and ✗ in the stepper circles.""" + card = await _load_wasm_channel(page, "failed", activation_error="Module crashed") + assert await card.locator(SEL["ext_configure_btn"], has_text="Reconfigure").count() == 1 + circles = card.locator(SEL["ext_stepper"]).locator(SEL["stepper_circle"]) + count = await circles.count() + assert count > 0 + texts = [await circles.nth(i).text_content() for i in range(count)] + assert any("\u2717" in t for t in texts), f"Expected ✗ in stepper circles: {texts}" + + +# ─── Group E: Available extensions (registry) and install ───────────────────── + +async def test_available_wasm_card_renders(page): + """Registry WASM entry shows in #available-wasm-list with Install button.""" + await mock_ext_apis(page, registry=[_REGISTRY_WASM]) + await go_to_extensions(page) + + wasm_list = page.locator(SEL["available_wasm_list"]) + card = wasm_list.locator(".ext-card").first + await card.wait_for(state="visible", timeout=5000) + assert "Registry Tool" in await card.text_content() + assert "A registry WASM tool" in await card.text_content() + assert await card.locator(SEL["ext_install_btn"]).count() == 1 + + +async def test_available_wasm_keywords_shown(page): + """Registry entry with keywords shows them on the card.""" + await mock_ext_apis(page, registry=[_REGISTRY_WASM]) + await go_to_extensions(page) + + card = page.locator(SEL["available_wasm_list"]).locator(".ext-card").first + await card.wait_for(state="visible", timeout=5000) + text = await card.text_content() + assert "search" in text or "utility" in text + + +async def test_install_wasm_success(page): + """Clicking Install on a registry card calls the install API and refreshes the list.""" + installed_after = { + **_WASM_TOOL, + "name": "registry-tool", + "display_name": "Registry Tool", + } + install_called = [] + + await mock_ext_apis(page, registry=[_REGISTRY_WASM]) + + async def handle_install(route): + install_called.append(True) + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"success": True}), + ) + + await page.route("**/api/extensions/install", handle_install) + + # After install, loadExtensions() refetches the list; serve the installed ext + async def handle_ext_after(route): + path = route.request.url.split("?")[0] + if path.endswith("/api/extensions"): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"extensions": [installed_after]}), + ) + else: + await route.continue_() + + await go_to_extensions(page) + + # Override the ext list handler for subsequent calls + await page.route("**/api/extensions*", handle_ext_after) + + install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first + await install_btn.wait_for(state="visible", timeout=5000) + await install_btn.click() + + # Wait for reload: installed card should appear + installed = page.locator(SEL["ext_card_installed"]) + await installed.first.wait_for(state="visible", timeout=8000) + assert len(install_called) >= 1, "Install API was not called" + + +async def test_install_wasm_failure(page): + """Failed install response shows an error toast.""" + await mock_ext_apis(page, registry=[_REGISTRY_WASM]) + + async def handle_install(route): + await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "message": "Build failed"})) + + await page.route("**/api/extensions/install", handle_install) + await go_to_extensions(page) + + install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first + await install_btn.wait_for(state="visible", timeout=5000) + await install_btn.click() + + await wait_for_toast(page, "Build failed") + + +async def test_install_wasm_channel_triggers_configure(page): + """Installing a wasm_channel extension auto-opens the configure modal.""" + registry_channel = {**_REGISTRY_WASM, "kind": "wasm_channel", "name": "test-channel", "display_name": "Test Channel"} + await mock_ext_apis(page, registry=[registry_channel]) + + setup_payload = {"secrets": [{"name": "token", "prompt": "Enter token", "provided": False, "optional": False, "auto_generate": False}]} + + async def handle_channel_setup(route): + await route.fulfill(status=200, content_type="application/json", body=json.dumps(setup_payload)) + + async def handle_channel_install(route): + await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True})) + + await page.route("**/api/extensions/test-channel/setup", handle_channel_setup) + await page.route("**/api/extensions/install", handle_channel_install) + await go_to_extensions(page) + + install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first + await install_btn.wait_for(state="visible", timeout=5000) + await install_btn.click() + + # Configure modal should appear + modal = page.locator(SEL["configure_modal"]) + await modal.wait_for(state="visible", timeout=8000) + assert await modal.is_visible() + + +# ─── Group F: Remove flow ───────────────────────────────────────────────────── + +async def test_remove_installed_extension_confirmed(page): + """Confirming remove dismisses the card and shows a success toast.""" + remove_called = [] + + await mock_ext_apis(page, installed=[_WASM_TOOL]) + + async def handle_remove(route): + remove_called.append(True) + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"success": True}), + ) + + await page.route("**/api/extensions/test-tool/remove", handle_remove) + + # After remove, list is empty + async def handle_ext_empty(route): + path = route.request.url.split("?")[0] + if path.endswith("/api/extensions"): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"extensions": []}), + ) + else: + await route.continue_() + + await go_to_extensions(page) + # Override for subsequent calls + await page.route("**/api/extensions*", handle_ext_empty) + + # Auto-accept confirm dialog + await page.evaluate("window.confirm = () => true") + + card = page.locator(SEL["ext_card_installed"]).first + await card.wait_for(state="visible", timeout=5000) + await card.locator(SEL["ext_remove_btn"]).click() + + # Card should disappear + await page.wait_for_function( + "() => document.querySelectorAll('#extensions-list .ext-card').length === 0", + timeout=8000, + ) + assert len(remove_called) >= 1, "Remove API was not called" + + +async def test_remove_cancelled_keeps_card(page): + """Cancelling the confirm dialog keeps the extension card.""" + await mock_ext_apis(page, installed=[_WASM_TOOL]) + await go_to_extensions(page) + + # Reject the confirm dialog + await page.evaluate("window.confirm = () => false") + + card = page.locator(SEL["ext_card_installed"]).first + await card.wait_for(state="visible", timeout=5000) + await card.locator(SEL["ext_remove_btn"]).click() + + assert await page.locator(SEL["ext_card_installed"]).count() >= 1, "Card should remain after cancel" + + +# ─── Group G: Configure modal ───────────────────────────────────────────────── + +async def _open_configure_modal(page, secrets): + """Mock the setup endpoint and trigger showConfigureModal via JS.""" + body = json.dumps({"secrets": secrets}) + + async def handle_setup(route): + await route.fulfill(status=200, content_type="application/json", body=body) + + await page.route("**/api/extensions/test-ext/setup", handle_setup) + await page.evaluate("showConfigureModal('test-ext')") + await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000) + + +async def test_configure_modal_field_variants(page): + """Configure modal renders all field badge variants correctly in one pass.""" + await _open_configure_modal( + page, + [ + {"name": "api_key", "prompt": "Enter API key", "provided": False, "optional": False, "auto_generate": False}, + {"name": "token", "prompt": "API Token", "provided": True, "optional": False, "auto_generate": False}, + {"name": "extra", "prompt": "Extra setting", "provided": False, "optional": True, "auto_generate": False}, + {"name": "secret", "prompt": "Secret value", "provided": False, "optional": False, "auto_generate": True}, + ], + ) + modal = page.locator(SEL["configure_modal"]) + assert await modal.is_visible() + text = await modal.text_content() + # Basic field with label and input + assert "Enter API key" in text + assert await page.locator(SEL["configure_input"]).count() >= 1 + # Provided badge and at least one input with 'already set'/'keep' placeholder + assert await modal.locator(SEL["field_provided"]).count() >= 1 + inputs = page.locator(SEL["configure_input"]) + input_count = await inputs.count() + placeholders = [await inputs.nth(i).get_attribute("placeholder") or "" for i in range(input_count)] + assert any("already set" in p or "keep" in p for p in placeholders), f"No provided placeholder: {placeholders}" + # Optional label + assert "(optional)" in text + # Auto-generate hint + assert "Auto-generated" in text + # Modal heading contains extension name + assert "test-ext" in await page.locator(".configure-modal h3").text_content() + + +async def test_configure_modal_cancel_closes(page): + """Clicking Cancel dismisses the configure overlay.""" + await _open_configure_modal( + page, + [{"name": "token", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}], + ) + await page.locator(SEL["configure_cancel_btn"]).click() + await page.locator(SEL["configure_overlay"]).wait_for(state="hidden", timeout=3000) + + +async def test_configure_modal_backdrop_click_closes(page): + """Clicking outside the modal (on the overlay backdrop) dismisses it.""" + await _open_configure_modal( + page, + [{"name": "token", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}], + ) + # Click the overlay element itself (outside the modal box) + overlay = page.locator(SEL["configure_overlay"]) + box = await overlay.bounding_box() + # Click at the very top-left corner of the overlay, outside the centered modal + await page.mouse.click(box["x"] + 5, box["y"] + 5) + await overlay.wait_for(state="hidden", timeout=3000) + + +async def test_configure_modal_save_success(page): + """Filling in a value and clicking Save closes the modal on success.""" + async def handle_setup(route): + if route.request.method == "GET": + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"secrets": [{"name": "token", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]}), + ) + else: + await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True})) + + await page.route("**/api/extensions/test-ext/setup", handle_setup) + await page.evaluate("showConfigureModal('test-ext')") + await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000) + await page.locator(SEL["configure_input"]).fill("mytoken123") + await page.locator(SEL["configure_save_btn"]).click() + await page.locator(SEL["configure_overlay"]).wait_for(state="hidden", timeout=5000) + + +async def test_configure_modal_save_oauth(page): + """Save response with auth_url opens a popup via window.open.""" + await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }") + + async def handle_setup(route): + if route.request.method == "GET": + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"secrets": [{"name": "t", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]}), + ) + else: + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}), + ) + + await page.route("**/api/extensions/test-ext/setup", handle_setup) + await page.evaluate("showConfigureModal('test-ext')") + await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000) + await page.locator(SEL["configure_input"]).fill("ignored") + await page.locator(SEL["configure_save_btn"]).click() + + await page.wait_for_function("() => window._lastOpenedUrl !== null && window._lastOpenedUrl !== undefined", timeout=5000) + opened = await page.evaluate("window._lastOpenedUrl") + assert opened is not None, "window.open was not called" + assert "oauth" in opened or "example.com" in opened + + +async def test_configure_modal_save_failure(page): + """Save failure response shows an error toast.""" + async def handle_setup(route): + if route.request.method == "GET": + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"secrets": [{"name": "t", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]}), + ) + else: + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"success": False, "message": "Invalid API key"}), + ) + + await page.route("**/api/extensions/test-ext/setup", handle_setup) + await page.evaluate("showConfigureModal('test-ext')") + await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000) + await page.locator(SEL["configure_input"]).fill("badkey") + await page.locator(SEL["configure_save_btn"]).click() + + await wait_for_toast(page, "Invalid API key") + + +async def test_configure_modal_enter_key_submits(page): + """Pressing Enter in the input field submits the form.""" + save_called = [] + + async def handle_setup(route): + if route.request.method == "GET": + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"secrets": [{"name": "t", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]}), + ) + else: + save_called.append(True) + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"success": True}), + ) + + await page.route("**/api/extensions/test-ext/setup", handle_setup) + await page.evaluate("showConfigureModal('test-ext')") + await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000) + await page.locator(SEL["configure_input"]).fill("mytoken") + await page.locator(SEL["configure_input"]).press("Enter") + + await page.locator(SEL["configure_overlay"]).wait_for(state="hidden", timeout=5000) + assert len(save_called) >= 1, "Save was not called on Enter key" + + + +# ─── Group H: Auth card (SSE-triggered) ─────────────────────────────────────── + +async def _show_auth_card(page, **kwargs): + """Inject an auth card via JS and wait for it to appear.""" + payload = json.dumps(kwargs) + await page.evaluate(f"showAuthCard({payload})") + await page.locator(SEL["auth_card"]).wait_for(state="visible", timeout=5000) + + +async def test_auth_card_token_only(page): + """Auth card with no auth_url shows token input, Submit, Cancel, but no OAuth button.""" + await _show_auth_card(page, extension_name="github", instructions="Paste your GitHub token") + + card = page.locator(SEL["auth_card"]) + assert await card.locator(SEL["auth_header"]).text_content() == "Authentication required for github" + assert "Paste your GitHub token" in await card.locator(SEL["auth_instructions"]).text_content() + assert await card.locator(SEL["auth_token_input"]).count() == 1 + assert await card.locator(SEL["auth_submit_btn"]).count() == 1 + assert await card.locator(SEL["auth_cancel_btn"]).count() == 1 + assert await card.locator(SEL["auth_oauth_btn"]).count() == 0 + + +async def test_auth_card_with_oauth(page): + """Auth card with auth_url shows the OAuth button.""" + await _show_auth_card(page, extension_name="slack", auth_url="https://slack.com/oauth/authorize") + + card = page.locator(SEL["auth_card"]) + oauth_btn = card.locator(SEL["auth_oauth_btn"]) + assert await oauth_btn.count() == 1 + assert "slack" in await oauth_btn.text_content() + + +async def test_auth_card_with_setup_url(page): + """Auth card with setup_url shows a 'Get your token' link.""" + await _show_auth_card(page, extension_name="openai", setup_url="https://platform.openai.com/api-keys") + + card = page.locator(SEL["auth_card"]) + link = card.locator("a", has_text="Get your token") + assert await link.count() == 1 + href = await link.get_attribute("href") + assert "openai" in href or "platform" in href + + +async def test_auth_card_submit_success(page): + """Submitting a valid token via click or Enter removes the auth card.""" + submit_called = [] + + async def handle_auth(route): + submit_called.append(True) + await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "message": "Authenticated!"})) + + await page.route("**/api/chat/auth-token", handle_auth) + + # Test click submit + await _show_auth_card(page, extension_name="myext", instructions="Enter token") + await page.locator(SEL["auth_token_input"]).fill("valid-token-123") + await page.locator(SEL["auth_submit_btn"]).click() + await page.locator(SEL["auth_card"]).wait_for(state="hidden", timeout=5000) + assert len(submit_called) >= 1 + + # Test Enter key submit (re-show card for a different extension) + await page.evaluate("showAuthCard({extension_name: 'myext2', instructions: 'Again'})") + await page.locator(SEL["auth_card"]).wait_for(state="visible", timeout=5000) + await page.locator(SEL["auth_token_input"]).fill("another-token") + await page.locator(SEL["auth_token_input"]).press("Enter") + await page.locator(SEL["auth_card"]).wait_for(state="hidden", timeout=5000) + assert len(submit_called) >= 2 + + +async def test_auth_card_submit_empty_noop(page): + """Clicking Submit with an empty token does nothing (card stays).""" + await _show_auth_card(page, extension_name="myext") + await page.locator(SEL["auth_submit_btn"]).click() + assert await page.locator(SEL["auth_card"]).count() == 1, "Card should remain for empty submit" + + +async def test_auth_card_submit_error(page): + """A failed token submission shows the error message and re-enables buttons.""" + async def handle_auth(route): + await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "message": "Bad token"})) + + await page.route("**/api/chat/auth-token", handle_auth) + await _show_auth_card(page, extension_name="myext") + await page.locator(SEL["auth_token_input"]).fill("wrong-token") + await page.locator(SEL["auth_submit_btn"]).click() + + error = page.locator(SEL["auth_error"]) + await error.wait_for(state="visible", timeout=5000) + assert "Bad token" in await error.text_content() + # Buttons should be re-enabled + submit = page.locator(SEL["auth_submit_btn"]) + assert not await submit.is_disabled() + + +async def test_auth_card_cancel_removes_card(page): + """Clicking Cancel removes the auth card.""" + async def handle_cancel(route): + await route.fulfill(status=200, content_type="application/json", body="{}") + + await page.route("**/api/chat/auth-cancel", handle_cancel) + await _show_auth_card(page, extension_name="myext") + await page.locator(SEL["auth_cancel_btn"]).click() + await page.locator(SEL["auth_card"]).wait_for(state="hidden", timeout=3000) + + + +async def test_auth_card_replaces_existing_same_extension(page): + """Calling showAuthCard twice for the same extension replaces the old card.""" + await _show_auth_card(page, extension_name="myext", instructions="First") + await _show_auth_card(page, extension_name="myext", instructions="Second") + + cards = page.locator(SEL["auth_card"] + '[data-extension-name="myext"]') + assert await cards.count() == 1, "Duplicate auth cards for same extension" + assert "Second" in await page.locator(SEL["auth_instructions"]).text_content() + + +async def test_auth_card_multiple_extensions_coexist(page): + """Auth cards for different extensions can coexist.""" + await page.evaluate('showAuthCard({extension_name: "ext-a", instructions: "Token A"})') + await page.evaluate('showAuthCard({extension_name: "ext-b", instructions: "Token B"})') + await page.locator(SEL["auth_card"]).nth(1).wait_for(state="visible", timeout=3000) + assert await page.locator(SEL["auth_card"]).count() == 2 + + +async def test_auth_completed_sse_dismisses_card(page): + """Simulating the auth_completed SSE event removes the auth card.""" + await _show_auth_card(page, extension_name="myext") + + # Simulate the auth_completed SSE event being fired + await page.evaluate(""" + // Call the handler the same way the SSE listener does + removeAuthCard('myext'); + """) + + assert await page.locator(SEL["auth_card"] + '[data-extension-name="myext"]').count() == 0 + + +# ─── Group I: Activate flow ──────────────────────────────────────────────────── + +async def test_activate_mcp_server_success(page): + """Clicking Activate on an inactive MCP server calls the activate API.""" + activate_called = [] + + async def handle_activate(route): + activate_called.append(True) + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"success": True}), + ) + + await mock_ext_apis(page, installed=[_MCP_INACTIVE]) + await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) + await go_to_extensions(page) + + activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + await activate_btn.wait_for(state="visible", timeout=5000) + + async with page.expect_response("**/api/extensions/test-mcp-inactive/activate", timeout=5000): + await activate_btn.click() + + assert len(activate_called) >= 1, "Activate API was not called" + + +async def test_activate_awaiting_token_opens_configure(page): + """Activate response with awaiting_token=true opens the configure modal.""" + await mock_ext_apis(page, installed=[_MCP_INACTIVE]) + + async def handle_activate(route): + await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "awaiting_token": True})) + + setup_payload = {"secrets": [{"name": "t", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]} + + async def handle_setup(route): + await route.fulfill(status=200, content_type="application/json", body=json.dumps(setup_payload)) + + await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) + await page.route("**/api/extensions/test-mcp-inactive/setup", handle_setup) + await go_to_extensions(page) + + activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + await activate_btn.wait_for(state="visible", timeout=5000) + await activate_btn.click() + + modal = page.locator(SEL["configure_modal"]) + await modal.wait_for(state="visible", timeout=8000) + assert await modal.is_visible() + + +async def test_activate_failure_shows_error_toast(page): + """Failed activate shows an error toast with the message.""" + await mock_ext_apis(page, installed=[_MCP_INACTIVE]) + + async def handle_activate(route): + await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "message": "Config missing"})) + + await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) + await go_to_extensions(page) + + activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + await activate_btn.wait_for(state="visible", timeout=5000) + await activate_btn.click() + + await wait_for_toast(page, "Config missing") + + +async def test_activate_with_auth_url_opens_popup(page): + """Activate response with auth_url calls window.open.""" + await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }") + await mock_ext_apis(page, installed=[_MCP_INACTIVE]) + + async def handle_activate(route): + await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"})) + + await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) + await go_to_extensions(page) + + activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + await activate_btn.wait_for(state="visible", timeout=5000) + await activate_btn.click() + + await page.wait_for_function("() => window._lastOpenedUrl !== null && window._lastOpenedUrl !== undefined", timeout=5000) + opened = await page.evaluate("window._lastOpenedUrl") + assert opened is not None, "window.open was not called" + assert "example.com" in opened + + +# ─── Group J: Tab reload behaviour ──────────────────────────────────────────── + +async def test_extensions_tab_reloads_on_revisit(page): + """loadExtensions() is called again when re-navigating to the extensions tab.""" + call_count = [] + + async def counting_handler(route): + path = route.request.url.split("?")[0] + if path.endswith("/api/extensions"): + call_count.append(1) + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"extensions": []}), + ) + else: + await route.continue_() + + async def handle_tools(route): + await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}') + + async def handle_registry(route): + await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') + + await page.route("**/api/extensions/tools", handle_tools) + await page.route("**/api/extensions/registry", handle_registry) + await page.route("**/api/extensions*", counting_handler) + + # First visit + await go_to_extensions(page) + count_after_first = len(call_count) + assert count_after_first >= 1, "loadExtensions not called on first visit" + + # Navigate away + await page.locator(SEL["tab_button"].format(tab="chat")).click() + await page.locator(SEL["tab_panel"].format(tab="chat")).wait_for( + state="visible", timeout=5000 + ) + + # Return to extensions + await go_to_extensions(page) + count_after_second = len(call_count) + assert count_after_second > count_after_first, "loadExtensions not called on return visit" + + +async def test_auth_completed_sse_triggers_extensions_reload(page): + """auth_completed SSE event while on the extensions tab triggers a reload.""" + reload_count = [] + + async def counting_handler(route): + path = route.request.url.split("?")[0] + if path.endswith("/api/extensions"): + reload_count.append(1) + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"extensions": []}), + ) + else: + await route.continue_() + + async def handle_tools(route): + await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}') + + async def handle_registry(route): + await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') + + await page.route("**/api/extensions/tools", handle_tools) + await page.route("**/api/extensions/registry", handle_registry) + await page.route("**/api/extensions*", counting_handler) + + await go_to_extensions(page) + count_before = len(reload_count) + + # Simulate auth_completed by calling loadExtensions directly (as the SSE handler does) + await page.evaluate(""" + // Simulate what the auth_completed SSE handler does when currentTab === 'extensions' + if (typeof loadExtensions === 'function') { + loadExtensions(); + } + """) + + await page.wait_for_timeout(600) + assert len(reload_count) > count_before, "loadExtensions was not called after auth_completed" + + +# ─── Regression tests ───────────────────────────────────────────────────────── +# Each test below is a regression for a specific bug found after the initial +# test suite was written. The bug description is in the docstring. + +async def test_ext_tools_null_does_not_crash(page): + """Regression: ext.tools null dereference crashes the extensions tab. + + Bug: renderExtensionCard() called ext.tools.length without a null guard. + If the backend returns tools: null (or omits the field), the tab silently + breaks and no cards render at all. + """ + ext_with_null_tools = {**_WASM_TOOL, "tools": None} + await mock_ext_apis(page, installed=[ext_with_null_tools]) + await go_to_extensions(page) + + # The card must render without a JS error + card = page.locator(SEL["ext_card_installed"]).first + await card.wait_for(state="visible", timeout=5000) + assert "Test WASM Tool" in await card.text_content() + # No .ext-tools element should appear (null → skip rendering) + assert await card.locator(SEL["ext_tools"]).count() == 0 + + +async def test_configure_modal_stays_open_on_save_failure(page): + """Regression: configure modal closed before checking success, so errors were unrecoverable. + + Bug: submitConfigureModal() called closeConfigureModal() unconditionally at + the top of .then(), then showed an error toast — but the modal was already + gone, forcing the user to click Setup/Configure again to retry. + Fix: modal now only closes on success; on failure it stays open for retry. + """ + async def handle_setup(route): + if route.request.method == "GET": + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"secrets": [{"name": "t", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]}), + ) + else: + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"success": False, "message": "Invalid API key"}), + ) + + await page.route("**/api/extensions/test-ext/setup", handle_setup) + await page.evaluate("showConfigureModal('test-ext')") + await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000) + await page.locator(SEL["configure_input"]).fill("badkey") + await page.locator(SEL["configure_save_btn"]).click() + + # Toast appears with the error message + await wait_for_toast(page, "Invalid API key") + # Modal must still be visible so the user can correct their input and retry + assert await page.locator(SEL["configure_overlay"]).is_visible(), \ + "Configure modal should remain open after a save failure so the user can retry" + + +async def test_oauth_url_injection_blocked(page): + """Regression: window.open() was called with unvalidated server-supplied auth_url. + + Bug: activate/configure responses with auth_url were passed directly to + window.open() with no scheme validation. A compromised backend could supply + a javascript: or data: URL. + Fix: openOAuthUrl() rejects any URL that does not start with https://. + """ + await page.evaluate("window._openedUrl = null; window.open = (url) => { window._openedUrl = url; }") + await mock_ext_apis(page, installed=[_MCP_INACTIVE]) + + async def handle_activate(route): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"success": True, "auth_url": "javascript:alert('xss')"}), + ) + + await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) + await go_to_extensions(page) + + activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + await activate_btn.wait_for(state="visible", timeout=5000) + await activate_btn.click() + + # Give the JS time to run (if it was going to call window.open, it would have by now) + await page.wait_for_timeout(600) + opened = await page.evaluate("window._openedUrl") + assert opened is None, f"window.open should NOT be called for non-HTTPS URLs, but got: {opened}"