Files
optimclaw/tests/e2e/scenarios/test_wasm_lifecycle.py
T
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

518 lines
20 KiB
Python

"""Comprehensive WASM extension lifecycle e2e tests.
Tests the full extension pipeline: registry → install → fields → configure →
activate → tools → remove → reinstall. Validates response fields, not just
status codes, to catch production bugs like missing capabilities, wrong
activation state, and stale registry flags.
Lifecycle stages are expressed as scoped fixtures so each test requests the
state it needs explicitly rather than relying on module-global flags.
"""
from pathlib import Path
import pytest
from helpers import SEL, api_get, api_post
async def _get_extension(base_url, name):
"""Get a specific extension from the extensions list, or None."""
r = await api_get(base_url, "/api/extensions")
for ext in r.json().get("extensions", []):
if ext["name"] == name:
return ext
return None
async def _ensure_removed(base_url, name):
"""Remove extension if already installed (idempotent cleanup)."""
ext = await _get_extension(base_url, name)
if ext:
await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30)
async def _install_extension(base_url, name):
"""Install an extension and assert success."""
r = await api_post(
base_url,
"/api/extensions/install",
json={"name": name},
timeout=180,
)
assert r.status_code == 200, f"Install HTTP error: {r.status_code} {r.text[:300]}"
data = r.json()
assert data.get("success") is True, f"Install failed: {data.get('message', '')}"
return data
@pytest.fixture(scope="module", autouse=True)
async def extension_lifecycle_cleanup(ironclaw_server):
"""Start and end the module with a clean extension set."""
await _ensure_removed(ironclaw_server, "web-search")
await _ensure_removed(ironclaw_server, "gmail")
yield
await _ensure_removed(ironclaw_server, "web-search")
await _ensure_removed(ironclaw_server, "gmail")
@pytest.fixture(scope="module")
async def web_search_installed(ironclaw_server, extension_lifecycle_cleanup):
"""Install web-search once for tests that require the pre-configure state."""
data = await _install_extension(ironclaw_server, "web-search")
return {"name": "web-search", "install": data}
@pytest.fixture(scope="module")
async def web_search_configured(ironclaw_server, web_search_installed):
"""Configure web-search once for tests that require the active state."""
r = await api_post(
ironclaw_server,
"/api/extensions/web-search/setup",
json={"secrets": {"brave_api_key": "test-key-123"}},
timeout=30,
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, f"Configure failed: {data.get('message', '')}"
assert data.get("activated") is True, "Should auto-activate after configure"
return {"name": "web-search", "configure": data}
@pytest.fixture(scope="module")
async def gmail_installed(ironclaw_server, extension_lifecycle_cleanup):
"""Install gmail once for multi-extension and OAuth setup assertions."""
data = await _install_extension(ironclaw_server, "gmail")
return {"name": "gmail", "install": data}
@pytest.fixture(scope="module")
async def web_search_removed(ironclaw_server, web_search_configured):
"""Remove web-search once for post-uninstall assertions."""
r = await api_post(
ironclaw_server, "/api/extensions/web-search/remove", timeout=30
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, f"Remove failed: {data.get('message', '')}"
return {"name": "web-search", "remove": data}
@pytest.fixture(scope="module")
async def web_search_reinstalled(ironclaw_server, web_search_removed):
"""Reinstall web-search after removal to verify saved-secret recovery."""
await _ensure_removed(ironclaw_server, "web-search")
data = await _install_extension(ironclaw_server, "web-search")
return {"name": "web-search", "install": data}
# ── Section A: Registry Validation ──────────────────────────────────────
async def test_registry_lists_extensions(ironclaw_server):
"""Registry endpoint returns entries from the embedded catalog."""
r = await api_get(ironclaw_server, "/api/extensions/registry")
assert r.status_code == 200
data = r.json()
assert "entries" in data
names = [e["name"] for e in data["entries"]]
assert "web-search" in names
assert "gmail" in names
async def test_registry_entry_fields(ironclaw_server):
"""Every registry entry has all required fields with correct types."""
r = await api_get(ironclaw_server, "/api/extensions/registry")
entries = r.json()["entries"]
assert len(entries) > 0, "Registry should have entries"
for entry in entries:
assert "name" in entry and isinstance(entry["name"], str) and entry["name"]
assert "display_name" in entry and isinstance(entry["display_name"], str)
assert "kind" in entry and isinstance(entry["kind"], str)
assert "description" in entry and isinstance(entry["description"], str)
assert "installed" in entry and isinstance(entry["installed"], bool)
assert "keywords" in entry and isinstance(entry["keywords"], list)
async def test_registry_installed_flag_false_initially(ironclaw_server):
"""Before any install, all registry entries have installed=False."""
# Clean up in case previous test run left extensions installed
await _ensure_removed(ironclaw_server, "web-search")
await _ensure_removed(ironclaw_server, "gmail")
r = await api_get(ironclaw_server, "/api/extensions/registry")
entries = r.json()["entries"]
for entry in entries:
if entry["name"] in ("web-search", "gmail"):
assert entry["installed"] is False, (
f"{entry['name']} should not be installed yet"
)
async def test_registry_search_filters(ironclaw_server):
"""Search query filters registry results."""
r = await api_get(
ironclaw_server, "/api/extensions/registry", params={"query": "search"}
)
assert r.status_code == 200
entries = r.json()["entries"]
names = [e["name"] for e in entries]
assert "web-search" in names
async def test_registry_search_no_match(ironclaw_server):
"""Nonsense query returns empty results."""
r = await api_get(
ironclaw_server,
"/api/extensions/registry",
params={"query": "xyznonexistent999"},
)
assert r.status_code == 200
assert len(r.json()["entries"]) == 0
# ── Section B: Install Lifecycle (web-search) ───────────────────────────
async def test_install_web_search(web_search_installed):
"""Install web-search from registry. Asserts success — failure here means
the registry/download/build pipeline is broken."""
assert "message" in web_search_installed["install"]
async def test_installed_extension_fields(ironclaw_server, web_search_installed):
"""After install, extension list shows correct fields."""
ext = await _get_extension(ironclaw_server, "web-search")
assert ext is not None, "web-search not in extensions list after install"
assert ext["kind"] == "wasm_tool"
assert ext["needs_setup"] is True, "Should need setup (has brave_api_key secret)"
assert ext["authenticated"] is False, "Should not be authenticated before configure"
async def test_installed_in_registry(ironclaw_server, web_search_installed):
"""Registry marks installed extension with installed=True."""
r = await api_get(ironclaw_server, "/api/extensions/registry")
entries = r.json()["entries"]
ws_entry = next((e for e in entries if e["name"] == "web-search"), None)
assert ws_entry is not None
assert ws_entry["installed"] is True, "Registry should show installed=True"
async def test_setup_schema_has_secrets(ironclaw_server, web_search_installed):
"""Setup schema returns brave_api_key with correct field info."""
r = await api_get(ironclaw_server, "/api/extensions/web-search/setup")
assert r.status_code == 200
data = r.json()
assert "secrets" in data
secrets = {s["name"]: s for s in data["secrets"]}
assert "brave_api_key" in secrets, (
f"brave_api_key not in setup schema secrets: {list(secrets.keys())}"
)
key_info = secrets["brave_api_key"]
assert key_info["provided"] is False, "Should not be provided yet"
async def test_extension_not_authenticated_before_configure(
ironclaw_server, web_search_installed
):
"""Installed but not configured extension is not authenticated."""
ext = await _get_extension(ironclaw_server, "web-search")
assert ext is not None
# Before configuring secrets, extension shouldn't be fully authenticated
assert ext["needs_setup"] is True, "Should still need setup before configure"
async def test_activate_before_configure_rejected(ironclaw_server, web_search_installed):
"""Activating a tool that needs setup secrets is rejected."""
r = await api_post(
ironclaw_server, "/api/extensions/web-search/activate", timeout=30
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is False, (
f"Activate should fail before configure: {data}"
)
msg = data.get("message", "").lower()
assert "requires configuration" in msg or "setup" in msg, (
f"Error should mention configuration: {data.get('message')}"
)
# ── Section C: Configure + Activate (web-search) ────────────────────────
async def test_configure_rejects_unknown_secret(ironclaw_server, web_search_installed):
"""Submitting an unknown secret name is rejected."""
r = await api_post(
ironclaw_server,
"/api/extensions/web-search/setup",
json={"secrets": {"fake_unknown_key": "value"}},
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is False, f"Should reject unknown secret: {data}"
assert "unknown" in data.get("message", "").lower() or "not found" in data.get(
"message", ""
).lower(), f"Error should mention unknown secret: {data.get('message')}"
async def test_configure_with_valid_secret(web_search_configured):
"""Configure with valid brave_api_key succeeds and auto-activates."""
assert web_search_configured["configure"].get("activated") is True
async def test_extension_active_after_configure(ironclaw_server, web_search_configured):
"""After configure, extension shows authenticated=True and active=True."""
ext = await _get_extension(ironclaw_server, "web-search")
assert ext is not None
assert ext["authenticated"] is True, "Should be authenticated after configure"
assert ext["active"] is True, "Should be active after auto-activation"
assert len(ext.get("tools", [])) > 0, "Should have tools registered"
async def test_setup_shows_provided(ironclaw_server, web_search_configured):
"""After configure, setup schema shows secret as provided."""
r = await api_get(ironclaw_server, "/api/extensions/web-search/setup")
assert r.status_code == 200
secrets = {s["name"]: s for s in r.json()["secrets"]}
assert "brave_api_key" in secrets
assert secrets["brave_api_key"]["provided"] is True
async def test_tools_registered_after_activate(
ironclaw_server, web_search_configured
):
"""After activation, extension tools appear in the tools endpoint."""
r = await api_get(ironclaw_server, "/api/extensions/tools")
assert r.status_code == 200
tool_names = [t["name"] for t in r.json()["tools"]]
assert "web-search" in tool_names, (
f"web-search tool not found in tools list: {tool_names}"
)
async def test_activate_already_active_idempotent(
ironclaw_server, web_search_configured
):
"""Activating an already-active extension succeeds (idempotent)."""
r = await api_post(
ironclaw_server, "/api/extensions/web-search/activate", timeout=30
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, (
f"Re-activation should succeed: {data.get('message', '')}"
)
async def test_configure_empty_secret_skipped(ironclaw_server, web_search_configured):
"""Submitting an empty string for a secret skips it (doesn't overwrite)."""
r = await api_post(
ironclaw_server,
"/api/extensions/web-search/setup",
json={"secrets": {"brave_api_key": ""}},
timeout=30,
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True
# Verify the secret is still provided (not cleared)
r2 = await api_get(ironclaw_server, "/api/extensions/web-search/setup")
secrets = {s["name"]: s for s in r2.json()["secrets"]}
assert secrets["brave_api_key"]["provided"] is True, (
"Empty value should not clear existing secret"
)
# ── Section D: Install gmail (multi-extension) ──────────────────────────
async def test_install_gmail(gmail_installed):
"""Install gmail from registry (second extension, tests isolation)."""
assert "message" in gmail_installed["install"]
async def test_gmail_fields(ironclaw_server, gmail_installed):
"""Gmail extension has correct field values (OAuth-based auth)."""
ext = await _get_extension(ironclaw_server, "gmail")
assert ext is not None, "gmail not in extensions list"
assert ext["kind"] == "wasm_tool"
assert ext["has_auth"] is True, "Gmail should have OAuth auth"
async def test_both_extensions_listed(
ironclaw_server, web_search_configured, gmail_installed
):
"""Both web-search and gmail appear in extensions list (no clobbering)."""
r = await api_get(ironclaw_server, "/api/extensions")
names = [e["name"] for e in r.json()["extensions"]]
assert "web-search" in names, f"web-search missing from: {names}"
assert "gmail" in names, f"gmail missing from: {names}"
async def test_gmail_setup_schema_auto_resolves(ironclaw_server, gmail_installed):
"""Gmail setup schema returns empty secrets (builtin creds auto-resolve)."""
r = await api_get(ironclaw_server, "/api/extensions/gmail/setup")
assert r.status_code == 200
data = r.json()
secrets = data.get("secrets", [])
# Builtin Google credentials auto-resolve client_id/client_secret via
# is_auto_resolved_oauth_field(), so the setup schema should have no
# user-facing secrets (or only auto-generated ones).
user_facing = [s for s in secrets if not s.get("auto_generate", False)]
assert len(user_facing) == 0, (
f"Gmail should have no user-facing secrets (auto-resolved), got: "
f"{[s['name'] for s in user_facing]}"
)
# ── Section E: Remove + Cleanup ─────────────────────────────────────────
async def test_remove_web_search(web_search_removed):
"""Remove web-search succeeds."""
assert web_search_removed["remove"].get("success") is True
async def test_removed_not_in_extensions(ironclaw_server, web_search_removed):
"""Removed extension no longer appears in extensions list."""
ext = await _get_extension(ironclaw_server, "web-search")
assert ext is None, "web-search should not be in extensions list after removal"
async def test_removed_extension_not_listed(ironclaw_server, web_search_removed):
"""Removed extension should not appear in the extension tools list."""
r = await api_get(ironclaw_server, "/api/extensions/tools")
assert r.status_code == 200
tool_names = [t["name"] for t in r.json()["tools"]]
assert "web-search" not in tool_names, (
f"Removed web-search tool should not remain registered: {tool_names}"
)
async def test_removed_not_in_registry_installed(ironclaw_server, web_search_removed):
"""Registry shows removed extension as installed=False."""
r = await api_get(ironclaw_server, "/api/extensions/registry")
ws_entry = next(
(e for e in r.json()["entries"] if e["name"] == "web-search"), None
)
assert ws_entry is not None
assert ws_entry["installed"] is False, "Registry should show installed=False"
async def test_activate_after_remove_uses_replacement_bytes_not_cached_module(
ironclaw_server, wasm_tools_dir, web_search_removed
):
"""After removal, activation must use the replacement bytes rather than a stale cache."""
wasm_path = Path(wasm_tools_dir) / "web-search.wasm"
wasm_path.write_bytes(b"not-a-valid-wasm-component")
r = await api_post(
ironclaw_server, "/api/extensions/web-search/activate", timeout=30
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is False, (
f"Activation should fail against replacement bytes, got: {data}"
)
async def test_reinstall_after_remove(ironclaw_server, web_search_reinstalled):
"""Extension can be reinstalled after removal without stale activation errors."""
ext = await _get_extension(ironclaw_server, "web-search")
assert ext is not None, "web-search not found after reinstall"
assert ext["active"] is True, "Reinstalled tool should auto-activate via saved secrets"
assert ext["authenticated"] is True, "Saved secret should still authenticate on reinstall"
# Verify no stale activation error from previous install
assert ext.get("activation_error") is None or ext.get("activation_error") == "", (
f"Reinstalled extension should have no stale activation error: {ext}"
)
# ── Section F: Error Paths ──────────────────────────────────────────────
async def test_install_nonexistent(ironclaw_server):
"""Installing a nonexistent extension returns an error."""
r = await api_post(
ironclaw_server,
"/api/extensions/install",
json={"name": "nonexistent-tool-xyz-999"},
timeout=30,
)
if r.status_code == 200:
assert r.json().get("success") is False
else:
assert r.status_code >= 400
async def test_install_empty_name(ironclaw_server):
"""Installing with empty name returns an error."""
r = await api_post(
ironclaw_server,
"/api/extensions/install",
json={"name": ""},
timeout=10,
)
if r.status_code == 200:
assert r.json().get("success") is False
else:
assert r.status_code >= 400
async def test_remove_noninstalled(ironclaw_server):
"""Removing a non-installed extension returns an error."""
r = await api_post(
ironclaw_server, "/api/extensions/nonexistent-xyz/remove", timeout=10
)
if r.status_code == 200:
assert r.json().get("success") is False
else:
assert r.status_code >= 400
async def test_activate_noninstalled(ironclaw_server):
"""Activating a non-installed extension returns an error."""
r = await api_post(
ironclaw_server, "/api/extensions/nonexistent-xyz/activate", timeout=10
)
if r.status_code == 200:
assert r.json().get("success") is False
else:
assert r.status_code >= 400
async def test_setup_noninstalled(ironclaw_server):
"""Setup for non-installed extension returns an error."""
r = await api_get(ironclaw_server, "/api/extensions/nonexistent-xyz/setup")
# May return 500 or a JSON error
assert r.status_code >= 400 or r.json().get("success") is False
async def test_configure_noninstalled(ironclaw_server):
"""Configure for non-installed extension returns an error."""
r = await api_post(
ironclaw_server,
"/api/extensions/nonexistent-xyz/setup",
json={"secrets": {}},
timeout=10,
)
if r.status_code == 200:
assert r.json().get("success") is False
else:
assert r.status_code >= 400
# ── Section G: Browser UI ──────────────────────────────────────────────
async def test_extensions_tab_shows_registry(page):
"""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"])
await available_section.wait_for(state="visible", timeout=10000)