Files
optimclaw/tests/e2e/scenarios/test_extensions.py
4566181f40 feat(gateway): unified settings page with subtabs (#1191)
* feat(gateway): full settings page polish with all tiers

- Backend: add ActiveConfigSnapshot to expose resolved LLM backend,
  model, and enabled channels via /api/gateway/status
- Add missing Agent settings (daily cost cap, actions/hour, local tools)
- Add Sandbox, Routines, Safety, Skills, and Search setting groups
- Settings import/export (JSON download + file upload)
- Active env defaults shown as placeholders in Inference settings
- Styled confirmation modals replace window.confirm() for remove actions
- Global restart banner persists across settings subtab switches
- Client-side validation with min/max constraints on number inputs
- Accessibility: aria-label on inputs, role=status on save indicators
- Settings search filters rows across current subtab
- Smooth CSS transitions for conditional field visibility (showWhen)
- Tunnel settings in Channels subtab
- Mobile responsive settings layout at 768px breakpoint
- i18n keys for toolbar, search, and import/export in en + zh-CN

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

* feat(gateway): polish settings page and remove registered tools debug section

Remove the "Registered Tools" table from the extensions tab (debug info
not useful to end users), clean up associated CSS/i18n/JS. Additional
settings page UI polish: extension card state styling, layout refinements.

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

* fix(gateway): address PR review feedback [skip-regression-check]

- Use refreshCurrentSettingsTab() in SSE event handlers to reduce duplication
- Remove unused formatGroupName/formatSettingLabel helpers
- Use i18n keys for MCP Configure/Reconfigure buttons
- Add data-i18n-placeholder to settings search input
- Remove data-i18n from confirm modal button (set dynamically by showConfirmModal)
- Fix cargo fmt in main.rs

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

* fix(e2e): update tests for unified settings tab layout [skip-regression-check]

- Update TABS list: replace extensions/skills with settings
- Add settings_subtab/settings_subpanel selectors to helpers
- Update test_connection, test_skills, test_extensions, test_wasm_lifecycle
  to navigate via Settings > subtab instead of top-level tabs
- Move MCP card tests to use go_to_mcp() helper (MCP is now a separate subtab)
- Remove tools table tests and mock_ext_apis tools= parameter
- Fix CSP violation: replace inline onclick on confirm modal cancel button

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

* fix(gateway): address second round of PR review feedback [skip-regression-check]

- Use I18n.t() for MCP empty state, export/import toasts, confirm modal
- Fix CLI channel card using wrong channel key ('repl' -> 'cli')
- Fix settings search counting hidden rows as visible
- Add aria-label i18n for settings search input
- Add common.loadFailed i18n key (en + zh-CN)
- Update E2E tests: WASM channel tests use Channels subtab,
  remove tests use custom confirm modal instead of window.confirm

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

* fix(e2e): fix WASM channel card selector and skills remove confirm [skip-regression-check]

- WASM channel tests: filter by display name to avoid matching built-in
  channel cards in the Channels subtab
- Skills remove test: click confirm modal button instead of using
  window.confirm (skill removal now uses custom confirm modal)

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

* fix(gateway): address third round of PR review feedback [skip-regression-check]

- approval_needed SSE: refresh any active settings subtab, not just
  Extensions — approvals can surface from Channels/MCP setup flows too
- renderCardsSkeleton: remove nested .extensions-list wrapper that
  caused skeleton cards to render constrained inside grid cells

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

* fix(e2e): fix auth_completed reload test race condition [skip-regression-check]

Use expect_response to deterministically wait for the /api/extensions
reload triggered by handleAuthCompleted → refreshCurrentSettingsTab,
instead of a fixed 600ms sleep that was too short under CI load.
Also remove stale /api/extensions/tools route handler.

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

* fix(e2e): debug auth_completed reload test with function counter [skip-regression-check]

Inject a counter wrapper around refreshCurrentSettingsTab to verify it's
actually called, and wait for the async fetch to complete before
asserting the reload count.

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

* feat(gateway): localize all settings labels, descriptions, and channel cards [skip-regression-check]

Move 120+ hardcoded strings in settings definitions (INFERENCE_SETTINGS,
AGENT_SETTINGS, NETWORKING_SETTINGS) and channel card labels to i18n
keys. Render functions now resolve labels via I18n.t() so the settings
page translates when switching locales.

Covers: group titles, setting labels/descriptions, built-in channel
names/descriptions, and the "No settings found" empty state.

Both en.js and zh-CN.js updated with all new cfg.* and channels.* keys.

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

* fix(gateway): localize remaining hardcoded UI strings [skip-regression-check]

- Fix export error toast using wrong i18n key (importFailed → exportFailed)
- Replace "Failed to load settings:" with I18n.t('common.loadFailed')
- Localize renderBuiltinChannelCard: "Built-in", "Active", "Inactive"
- Localize settings placeholders: "env: ", "env default", "use env default"
- Localize "✓ Saved" indicator
- Add new i18n keys to both en.js and zh-CN.js

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

* fix(gateway): confirm modal a11y, Esc/click-outside, search guard [skip-regression-check]

- Add role="dialog", aria-modal="true", aria-labelledby to confirm modal
- Focus confirm button when modal opens
- Close modal on Escape key or overlay click
- Skip settings search on non-settings panels (Extensions/MCP/Skills/Channels)

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

* fix(gateway): boolean tri-state, search reset on subtab switch, stale model suggestions [skip-regression-check]

Address PR review feedback:
- Boolean settings now use a tri-state select (env default / On / Off)
  instead of a checkbox, matching the pattern used by other select settings
  and allowing users to revert to the env default
- Clear search input when switching settings subtabs so stale filters
  don't carry over to the new panel
- Always assign model suggestions (even empty array) so stale IDs from a
  previous successful /v1/models fetch don't persist when the endpoint
  later returns empty

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

* fix(gateway): auth_completed handler, bedrock_cross_region select, integer-only number inputs [skip-regression-check]

Address PR review feedback:
- auth_completed SSE listener now delegates to handleAuthCompleted(data)
  instead of inlining logic with a bare closeConfigureModal() call, so
  only the matching extension's modal is dismissed
- bedrock_cross_region changed from free text to select with the four
  valid values (us/eu/apac/global), matching backend validation
- Number settings now use step=1 and parseInt() instead of parseFloat(),
  preventing fractional values that the backend (u32/u64) would reject

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-18 16:18:29 -07:00

1218 lines
52 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
}
# ─── Navigation helpers ────────────────────────────────────────────────────────
async def go_to_extensions(page):
"""Navigate to Settings > Extensions subtab and wait for content.
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="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="extensions")).wait_for(
state="visible", timeout=5000
)
# loadExtensions() fires 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 go_to_channels(page):
"""Navigate to Settings > Channels subtab and wait for content."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="channels")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="channels")).wait_for(
state="visible", timeout=5000
)
async def go_to_mcp(page):
"""Navigate to Settings > MCP subtab and wait for content."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="mcp")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="mcp")).wait_for(
state="visible", timeout=5000
)
await page.locator(
f"{SEL['mcp_servers_list']} .empty-state, {SEL['ext_card_mcp']}"
).first.wait_for(state="visible", timeout=8000)
async def mock_ext_apis(page, *, installed=None, registry=None):
"""Intercept the extension list APIs with fixture data.
Must be called BEFORE navigating to the extensions subtab.
"""
ext_body = json.dumps({"extensions": installed 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 /registry handler is 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_registry(route):
await route.fulfill(status=200, content_type="application/json", body=registry_body)
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 subtab with no data shows sections with correct empty-state messages."""
await mock_ext_apis(page)
await go_to_extensions(page)
panel = page.locator(SEL["settings_subpanel"].format(subtab="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()
# ─── 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_mcp(page)
card = page.locator(SEL["ext_card_mcp"]).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_mcp(page)
card = page.locator(SEL["ext_card_mcp"]).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_mcp(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_mcp(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_channels(page)
# Find the WASM channel card specifically (not built-in channel cards)
card = page.locator(SEL["channels_ext_card"], has_text="Test Channel").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_channels(page)
install_btn = page.locator(SEL["channels_ext_card"]).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)
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click()
# Confirm via custom modal
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
await page.locator(SEL["confirm_modal_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)
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click()
# Cancel via custom modal
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
await page.locator(SEL["confirm_modal_cancel"]).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_required_does_not_reopen_existing_configure_modal(page):
"""Regression: auth_required SSE should not clobber an already-open configure modal."""
result = await page.evaluate(
"""() => {
const overlay = document.createElement('div');
overlay.className = 'configure-overlay';
overlay.setAttribute('data-extension-name', 'telegram');
document.body.appendChild(overlay);
const originalShowConfigureModal = window.showConfigureModal;
const originalSetAuthFlowPending = window.setAuthFlowPending;
let showCalls = 0;
let pendingCalls = 0;
window.showConfigureModal = () => { showCalls += 1; };
window.setAuthFlowPending = () => { pendingCalls += 1; };
handleAuthRequired({ extension_name: 'telegram', instructions: 'pending', auth_url: null });
window.showConfigureModal = originalShowConfigureModal;
window.setAuthFlowPending = originalSetAuthFlowPending;
overlay.remove();
return { showCalls, pendingCalls };
}"""
)
assert result["showCalls"] == 0
assert result["pendingCalls"] == 0
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_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/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
# Inject a counter to confirm refreshCurrentSettingsTab is called
await page.evaluate("window.__refreshCount = 0; var _origRefresh = refreshCurrentSettingsTab; refreshCurrentSettingsTab = function() { window.__refreshCount++; _origRefresh(); };")
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
# Wait for the refresh to complete
await page.wait_for_function("() => window.__refreshCount > 0", timeout=5000)
# Give the async fetch time to complete
await page.wait_for_timeout(1000)
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_mcp(page)
activate_btn = page.locator(SEL["ext_card_mcp"]).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_mcp(page)
activate_btn = page.locator(SEL["ext_card_mcp"]).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_mcp(page)
activate_btn = page.locator(SEL["ext_card_mcp"]).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_mcp(page)
activate_btn = page.locator(SEL["ext_card_mcp"]).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 subtab."""
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_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/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"
# ─── 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_mcp(page)
activate_btn = page.locator(SEL["ext_card_mcp"]).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}"