mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-29 08:59:31 +00:00
* feat(extensions): unify auth and configure into single entrypoint Refactors the extension lifecycle to eliminate the divergence between chat and gateway paths that caused Telegram setup via chat to fail (missing webhook secret auto-generation, no token validation). Key changes: - Rename save_setup_secrets() → configure(): single entrypoint for providing secrets to any extension (WasmChannel, WasmTool, MCP). Validates, stores, auto-generates, and activates. - Add configure_token(): convenience wrapper for single-token callers (chat auth card, WebSocket, agent auth mode). - Refactor auth() to pure status check: remove token parameter, delete token-storing branches from auth_mcp/auth_wasm_tool, rename auth_wasm_channel → auth_wasm_channel_status. - Add ConfigureResult/MissingSecret types for structured responses. - Replace hardcoded Telegram token validation with generic validation_endpoint from capabilities.json. - Update all callers (9 files) to use the new interface. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: use ValidationFailed error variant instead of string matching Replace brittle msg.contains("Invalid token") checks with a proper ExtensionError::ValidationFailed variant. configure() now returns this variant for token validation failures, and callers match on it directly instead of parsing error message strings. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review — SSRF protection, error typing, missing-secret selection, WS auth 1. SSRF: call validate_fetch_url() before validation_endpoint HTTP request 2. Transport errors map to ExtensionError::Other (not ValidationFailed) 3. configure_token() picks first *missing* secret, not first non-optional 4. WebSocket error path re-emits AuthRequired on ValidationFailed Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test: add regression tests for extension lifecycle refactoring - test_configure_token_picks_first_missing_secret: verifies multi-secret channels can be configured one secret at a time (commit ce106f4) - test_auth_is_read_only_for_wasm_channel: verifies auth() has no side effects and doesn't store secrets (commit 47f8eb6) - test_validation_failed_is_distinct_error_variant: verifies the typed error variant can be pattern-matched (commit a318161) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review comments — activation dispatch, dead code, caps consolidation - Fix configure() fallthrough bug: dispatch activation by ExtensionKind instead of unconditionally calling activate_wasm_channel() for all non-WasmTool types (MCP servers and channel relays now use their correct activation methods) - Remove dead MissingSecret struct and missing_secrets field (never populated, flagged by reviewer) - Consolidate capabilities file parsing in configure(): parse once and reuse for allowed names, validation_endpoint, and auto-generation - Fix auth() doc comment: note MCP OAuth side effects - Fix stale save_setup_secrets reference in server.rs comment - Add regression test for activation dispatch bug Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(extensions): fix 5 extension lifecycle bugs found during E2E testing Bug fixes in src/extensions/manager.rs: - Add auth guard to activate_wasm_tool() blocking activation when secrets are missing (NeedsSetup), matching activate_wasm_channel() behavior - Evict WasmToolRuntime module cache on remove() so reinstall uses fresh binary - Clear activation_errors on remove() for both WasmTool and WasmChannel - Clean up in-progress OAuth flows on remove() (abort TCP listener, purge pending flow entries) Bug fix in src/channels/web/server.rs: - Broadcast AuthCompleted SSE event on expired OAuth callback so web UI doesn't stay stuck showing "auth required" E2E test coverage: - test_wasm_lifecycle.py: 35 tests covering install/configure/activate/ remove/reinstall lifecycle with regression tests for bugs 1 and 3 - test_extension_oauth.py: 9 tests covering OAuth round-trip flow - test_tool_execution.py: 5 tests for tool invocation via chat - test_pairing.py: 4 tests for pairing request lifecycle - Enhanced conftest.py, helpers.py, mock_llm.py for OAuth mock support [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(web): unify extension auth UX and add lifecycle regressions * test: fix pending oauth flow fixtures after rebase * test(e2e): fix playwright route ordering for extensions reloads * test: address e2e review follow-ups * test: address remaining PR review comments --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1250 lines
52 KiB
Python
1250 lines
52 KiB
Python
"""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()
|
||
|
||
|
||
async def test_install_with_auth_url_opens_popup_and_shows_auth_prompt(page):
|
||
"""Install responses with auth_url should surface the same auth prompt used elsewhere."""
|
||
await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }")
|
||
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": True, "auth_url": "https://example.com/oauth"}),
|
||
)
|
||
|
||
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 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
|
||
await page.locator(SEL["auth_card"] + '[data-extension-name="registry-tool"]').wait_for(
|
||
state="visible", timeout=5000
|
||
)
|
||
|
||
|
||
# ─── 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 and shows the global auth prompt."""
|
||
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
|
||
await page.locator(SEL["auth_card"] + '[data-extension-name="test-ext"]').wait_for(
|
||
state="visible", timeout=5000
|
||
)
|
||
|
||
|
||
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 the global auth prompt 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_for_different_extension_replaces_existing_prompt(page):
|
||
"""A new auth prompt replaces the previous one to keep the UX modal and global."""
|
||
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"]).wait_for(state="visible", timeout=3000)
|
||
assert await page.locator(SEL["auth_card"]).count() == 1
|
||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="ext-a"]').count() == 0
|
||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="ext-b"]').count() == 1
|
||
|
||
|
||
async def test_auth_and_configure_helpers_escape_selector_sensitive_extension_names(page):
|
||
"""Quoted extension names should not break auth/configure modal helpers."""
|
||
result = await page.evaluate(
|
||
"""({ name }) => {
|
||
showAuthCard({ extension_name: name, instructions: 'Paste token' });
|
||
showAuthCardError(name, 'Bad token');
|
||
const errorText = document.querySelector('.auth-error')?.textContent || '';
|
||
removeAuthCard(name);
|
||
const authStillPresent = Array.from(document.querySelectorAll('.auth-card'))
|
||
.some((card) => card.getAttribute('data-extension-name') === name);
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'configure-overlay';
|
||
overlay.setAttribute('data-extension-name', name);
|
||
document.body.appendChild(overlay);
|
||
closeConfigureModal(name);
|
||
const configureStillPresent = Array.from(document.querySelectorAll('.configure-overlay'))
|
||
.some((node) => node.getAttribute('data-extension-name') === name);
|
||
|
||
return { errorText, authStillPresent, configureStillPresent };
|
||
}""",
|
||
{"name": 'quoted "ext" name'},
|
||
)
|
||
|
||
assert result["errorText"] == "Bad token"
|
||
assert result["authStillPresent"] is False
|
||
assert result["configureStillPresent"] is False
|
||
|
||
|
||
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("""
|
||
handleAuthCompleted({
|
||
extension_name: 'myext',
|
||
success: true,
|
||
message: 'Authenticated!',
|
||
});
|
||
""")
|
||
|
||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="myext"]').count() == 0
|
||
|
||
|
||
async def test_auth_completed_for_other_extension_keeps_configure_modal_open(page):
|
||
"""Auth completion should not close a different extension's configure modal."""
|
||
async def handle_setup(route):
|
||
await route.fulfill(
|
||
status=200,
|
||
content_type="application/json",
|
||
body=json.dumps({"secrets": [{"name": "token", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]}),
|
||
)
|
||
|
||
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.evaluate("""
|
||
handleAuthCompleted({
|
||
extension_name: 'other-ext',
|
||
success: true,
|
||
message: 'Other extension connected.',
|
||
});
|
||
""")
|
||
|
||
assert await page.locator(SEL["configure_overlay"]).is_visible(), (
|
||
"Configure modal should remain open when another extension finishes auth"
|
||
)
|
||
|
||
|
||
async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensions(page):
|
||
"""Failed auth_completed handling should clear stale UI and refresh extensions."""
|
||
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*", counting_handler)
|
||
await page.route("**/api/extensions/tools", handle_tools)
|
||
await page.route("**/api/extensions/registry", handle_registry)
|
||
|
||
await go_to_extensions(page)
|
||
count_before = len(reload_count)
|
||
|
||
await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth")
|
||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1
|
||
|
||
await page.evaluate("""
|
||
handleAuthCompleted({
|
||
extension_name: 'gmail',
|
||
success: false,
|
||
message: 'OAuth flow expired. Please try again.',
|
||
});
|
||
""")
|
||
|
||
await wait_for_toast(page, "OAuth flow expired. Please try again.")
|
||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0
|
||
assert (
|
||
await page.locator(
|
||
SEL["toast_error"], has_text="OAuth flow expired. Please try again."
|
||
).count()
|
||
>= 1
|
||
)
|
||
|
||
await page.wait_for_timeout(600)
|
||
assert len(reload_count) > count_before, "Extensions list did not reload after auth failure"
|
||
|
||
|
||
# ─── 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_and_shows_auth_prompt(page):
|
||
"""Activate response with auth_url calls window.open and shows the auth prompt."""
|
||
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
|
||
await page.locator(
|
||
SEL["auth_card"] + '[data-extension-name="test-mcp-inactive"]'
|
||
).wait_for(state="visible", timeout=5000)
|
||
|
||
|
||
# ─── 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*", counting_handler)
|
||
await page.route("**/api/extensions/tools", handle_tools)
|
||
await page.route("**/api/extensions/registry", handle_registry)
|
||
|
||
# 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*", counting_handler)
|
||
await page.route("**/api/extensions/tools", handle_tools)
|
||
await page.route("**/api/extensions/registry", handle_registry)
|
||
|
||
await go_to_extensions(page)
|
||
count_before = len(reload_count)
|
||
|
||
# Simulate auth_completed via the shared handler.
|
||
await page.evaluate("""
|
||
handleAuthCompleted({
|
||
extension_name: 'reload-ext',
|
||
success: true,
|
||
message: 'Reloaded.',
|
||
});
|
||
""")
|
||
|
||
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}"
|