mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ec04354c6b
commit
4566181f40
+11
-4
@@ -45,12 +45,13 @@ SEL = {
|
||||
"approval_always_btn": ".approval-actions button.always",
|
||||
"approval_deny_btn": ".approval-actions button.deny",
|
||||
"approval_resolved": ".approval-resolved",
|
||||
# Extensions tab – sections
|
||||
# Settings subtabs
|
||||
"settings_subtab": '.settings-subtab[data-settings-subtab="{subtab}"]',
|
||||
"settings_subpanel": "#settings-{subtab}",
|
||||
# Extensions section
|
||||
"extensions_list": "#extensions-list",
|
||||
"available_wasm_list": "#available-wasm-list",
|
||||
"mcp_servers_list": "#mcp-servers-list",
|
||||
"tools_tbody": "#tools-tbody",
|
||||
"tools_empty": "#tools-empty",
|
||||
# Extensions tab – cards
|
||||
"ext_card_installed": "#extensions-list .ext-card",
|
||||
"ext_card_available": "#available-wasm-list .ext-card.ext-available",
|
||||
@@ -92,6 +93,12 @@ SEL = {
|
||||
"ext_stepper": ".ext-stepper",
|
||||
"stepper_step": ".stepper-step",
|
||||
"stepper_circle": ".stepper-circle",
|
||||
# Confirm modal (custom, replaces window.confirm)
|
||||
"confirm_modal": "#confirm-modal",
|
||||
"confirm_modal_btn": "#confirm-modal-btn",
|
||||
"confirm_modal_cancel": "#confirm-modal-cancel-btn",
|
||||
# Channels subtab – cards
|
||||
"channels_ext_card": "#settings-channels-content .ext-card",
|
||||
# Toast notifications
|
||||
"toast": ".toast",
|
||||
"toast_success": ".toast.toast-success",
|
||||
@@ -106,7 +113,7 @@ SEL = {
|
||||
"routines_empty": "#routines-empty",
|
||||
}
|
||||
|
||||
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
|
||||
TABS = ["chat", "memory", "jobs", "routines", "settings"]
|
||||
|
||||
# Auth token used across all tests
|
||||
AUTH_TOKEN = "e2e-test-token"
|
||||
|
||||
@@ -87,23 +87,21 @@ _REGISTRY_MCP = {
|
||||
"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.
|
||||
"""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="extensions")).click()
|
||||
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
|
||||
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 three parallel fetches then renders. Wait for the
|
||||
# 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(
|
||||
@@ -111,19 +109,39 @@ async def go_to_extensions(page):
|
||||
).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.
|
||||
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
|
||||
)
|
||||
|
||||
Must be called BEFORE navigating to the extensions tab.
|
||||
|
||||
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 []})
|
||||
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.
|
||||
# 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"):
|
||||
@@ -133,13 +151,9 @@ async def mock_ext_apis(page, *, installed=None, tools=None, registry=None):
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -151,46 +165,17 @@ async def wait_for_toast(page, text: str, *, timeout: int = 5000):
|
||||
# ─── 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=[])
|
||||
"""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["tab_panel"].format(tab="extensions"))
|
||||
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()
|
||||
|
||||
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 ───────────────────────────────────────
|
||||
|
||||
@@ -248,9 +233,9 @@ async def test_installed_wasm_tool_authed_shows_reconfigure_btn(page):
|
||||
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)
|
||||
await go_to_mcp(page)
|
||||
|
||||
card = page.locator(SEL["ext_card_installed"]).first
|
||||
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
|
||||
@@ -260,9 +245,9 @@ async def test_installed_mcp_server_active(page):
|
||||
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)
|
||||
await go_to_mcp(page)
|
||||
|
||||
card = page.locator(SEL["ext_card_installed"]).first
|
||||
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
|
||||
|
||||
@@ -270,7 +255,7 @@ async def test_installed_mcp_server_inactive_shows_activate(page):
|
||||
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)
|
||||
await go_to_mcp(page)
|
||||
|
||||
mcp_list = page.locator(SEL["mcp_servers_list"])
|
||||
card = mcp_list.locator(".ext-card").first
|
||||
@@ -285,7 +270,7 @@ async def test_mcp_server_installed_auth_dot(page):
|
||||
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)
|
||||
await go_to_mcp(page)
|
||||
|
||||
mcp_list = page.locator(SEL["mcp_servers_list"])
|
||||
card = mcp_list.locator(".ext-card").first
|
||||
@@ -299,8 +284,9 @@ async def test_mcp_server_installed_auth_dot(page):
|
||||
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 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
|
||||
|
||||
@@ -446,9 +432,9 @@ async def test_install_wasm_channel_triggers_configure(page):
|
||||
|
||||
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)
|
||||
await go_to_channels(page)
|
||||
|
||||
install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first
|
||||
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()
|
||||
|
||||
@@ -523,13 +509,14 @@ async def test_remove_installed_extension_confirmed(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()
|
||||
|
||||
# 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",
|
||||
@@ -543,13 +530,14 @@ async def test_remove_cancelled_keeps_card(page):
|
||||
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()
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -973,14 +961,10 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
|
||||
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)
|
||||
@@ -989,6 +973,9 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
|
||||
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',
|
||||
@@ -999,14 +986,11 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
|
||||
|
||||
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)
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -1026,9 +1010,9 @@ async def test_activate_mcp_server_success(page):
|
||||
|
||||
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
|
||||
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
||||
await go_to_extensions(page)
|
||||
await go_to_mcp(page)
|
||||
|
||||
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
|
||||
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):
|
||||
@@ -1051,9 +1035,9 @@ async def test_activate_awaiting_token_opens_configure(page):
|
||||
|
||||
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)
|
||||
await go_to_mcp(page)
|
||||
|
||||
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
|
||||
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()
|
||||
|
||||
@@ -1070,9 +1054,9 @@ async def test_activate_failure_shows_error_toast(page):
|
||||
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)
|
||||
await go_to_mcp(page)
|
||||
|
||||
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
|
||||
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()
|
||||
|
||||
@@ -1088,9 +1072,9 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
|
||||
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)
|
||||
await go_to_mcp(page)
|
||||
|
||||
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
|
||||
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()
|
||||
|
||||
@@ -1106,7 +1090,7 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
|
||||
# ─── 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."""
|
||||
"""loadExtensions() is called again when re-navigating to the extensions subtab."""
|
||||
call_count = []
|
||||
|
||||
async def counting_handler(route):
|
||||
@@ -1121,14 +1105,10 @@ async def test_extensions_tab_reloads_on_revisit(page):
|
||||
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
|
||||
@@ -1148,48 +1128,6 @@ async def test_extensions_tab_reloads_on_revisit(page):
|
||||
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.
|
||||
@@ -1267,9 +1205,9 @@ async def test_oauth_url_injection_blocked(page):
|
||||
)
|
||||
|
||||
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
||||
await go_to_extensions(page)
|
||||
await go_to_mcp(page)
|
||||
|
||||
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
|
||||
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()
|
||||
|
||||
|
||||
@@ -4,11 +4,18 @@ import pytest
|
||||
from helpers import SEL
|
||||
|
||||
|
||||
async def go_to_skills(page):
|
||||
"""Navigate to Settings > Skills subtab."""
|
||||
await page.locator(SEL["tab_button"].format(tab="settings")).click()
|
||||
await page.locator(SEL["settings_subtab"].format(subtab="skills")).click()
|
||||
await page.locator(SEL["settings_subpanel"].format(subtab="skills")).wait_for(
|
||||
state="visible", timeout=5000
|
||||
)
|
||||
|
||||
|
||||
async def test_skills_tab_visible(page):
|
||||
"""Skills tab shows the search interface."""
|
||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
||||
panel = page.locator(SEL["tab_panel"].format(tab="skills"))
|
||||
await panel.wait_for(state="visible", timeout=5000)
|
||||
"""Skills subtab shows the search interface."""
|
||||
await go_to_skills(page)
|
||||
|
||||
search_input = page.locator(SEL["skill_search_input"])
|
||||
assert await search_input.is_visible(), "Skills search input not visible"
|
||||
@@ -16,7 +23,7 @@ async def test_skills_tab_visible(page):
|
||||
|
||||
async def test_skills_search(page):
|
||||
"""Search ClawHub for skills and verify results appear."""
|
||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
||||
await go_to_skills(page)
|
||||
|
||||
search_input = page.locator(SEL["skill_search_input"])
|
||||
await search_input.fill("markdown")
|
||||
@@ -35,7 +42,7 @@ async def test_skills_search(page):
|
||||
|
||||
async def test_skills_install_and_remove(page):
|
||||
"""Install a skill from search results, then remove it."""
|
||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
||||
await go_to_skills(page)
|
||||
|
||||
# Search
|
||||
search_input = page.locator(SEL["skill_search_input"])
|
||||
@@ -68,10 +75,14 @@ async def test_skills_install_and_remove(page):
|
||||
installed_count = await installed.count()
|
||||
assert installed_count >= 1, "Skill should appear in installed list after install"
|
||||
|
||||
# Remove the skill (confirm is already overridden)
|
||||
# Remove the skill via confirm modal
|
||||
remove_btn = installed.first.locator("button", has_text="Remove")
|
||||
if await remove_btn.count() > 0:
|
||||
await remove_btn.click()
|
||||
# Confirm in the modal
|
||||
confirm_btn = page.locator(SEL["confirm_modal_btn"])
|
||||
await confirm_btn.wait_for(state="visible", timeout=5000)
|
||||
await confirm_btn.click()
|
||||
# Wait for the card to disappear or list to shrink
|
||||
await page.wait_for_timeout(3000)
|
||||
new_count = await page.locator(SEL["skill_installed"]).count()
|
||||
|
||||
@@ -507,10 +507,10 @@ async def test_configure_noninstalled(ironclaw_server):
|
||||
|
||||
|
||||
async def test_extensions_tab_shows_registry(page):
|
||||
"""Extensions tab loads and shows available extensions from registry."""
|
||||
tab_btn = page.locator(SEL["tab_button"].format(tab="extensions"))
|
||||
await tab_btn.click()
|
||||
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
|
||||
"""Extensions subtab loads and shows available extensions from registry."""
|
||||
await page.locator(SEL["tab_button"].format(tab="settings")).click()
|
||||
await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click()
|
||||
panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions"))
|
||||
await panel.wait_for(state="visible", timeout=5000)
|
||||
|
||||
available_section = page.locator(SEL["available_wasm_list"])
|
||||
|
||||
@@ -214,6 +214,7 @@ async fn start_test_server_with_provider(
|
||||
cost_guard: None,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
});
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
@@ -705,6 +706,7 @@ async fn test_no_llm_provider_returns_503() {
|
||||
cost_guard: None,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
});
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
|
||||
@@ -234,6 +234,7 @@ impl GatewayWorkflowHarness {
|
||||
cost_guard: Some(Arc::clone(&components.cost_guard)),
|
||||
routine_engine: Arc::clone(&routine_slot),
|
||||
startup_time: Instant::now(),
|
||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
});
|
||||
|
||||
let mut agent = Agent::new(
|
||||
|
||||
@@ -62,6 +62,7 @@ async fn start_test_server() -> (
|
||||
cost_guard: None,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
});
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user