1. P0-1: Восстановлены все веб-обработчики в app.js (openAddAccountWizard, handleNodeAccountChange, handleNodeModelChange, handleRefreshProviderModels, checkUpdates), связаны с потоками startDeviceAuth и startRedirectAuth, выбор слота обязателен и понятен пользователю. 2. P0-2: Добавлены адаптеры OpenRouter и NVIDIA NIM с поддержкой динамического base_url, множественных аккаунтов без ограничений, GET /models и честным отображением квот/«Н/Д». 3. P0-3: В мастере подключения локального провайдера реализована кнопка автопоиска (discover_local_models), отображение серверов, ошибок портов и автозаполнение. 4. P0-4: Полностью удален устаревший десктопный интерфейс CustomTkinter (router/ui/** 20 файлов, hermes_hub_app.py), зависимости customtkinter и pillow убраны из pyproject.toml и инсталляторов, оставлен единый ярлык «Hermes Hub». 5. 418 passed, 1 skipped, 4 deselected, ruff чисто.
387 lines
18 KiB
Python
387 lines
18 KiB
Python
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
import pytest
|
|
|
|
|
|
APP_JS_PATH = Path("src/antigravity_provider/router/web/static/app.js")
|
|
INDEX_HTML_PATH = Path("src/antigravity_provider/router/web/static/index.html")
|
|
|
|
|
|
@pytest.fixture
|
|
def app_js_content():
|
|
assert APP_JS_PATH.exists(), f"{APP_JS_PATH} does not exist"
|
|
return APP_JS_PATH.read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.fixture
|
|
def index_html_content():
|
|
assert INDEX_HTML_PATH.exists(), f"{INDEX_HTML_PATH} does not exist"
|
|
return INDEX_HTML_PATH.read_text(encoding="utf-8")
|
|
|
|
|
|
def _is_function_defined(code: str, func_name: str) -> bool:
|
|
patterns = [
|
|
rf"function\s+{re.escape(func_name)}\s*\(",
|
|
rf"async\s+function\s+{re.escape(func_name)}\s*\(",
|
|
rf"(?:const|let|var)\s+{re.escape(func_name)}\s*=\s*(?:async\s*)?(?:\([^)]*\)|[a-zA-Z0-9_$]+)\s*=>",
|
|
rf"(?:const|let|var)\s+{re.escape(func_name)}\s*=\s*(?:async\s*)?function\s*\(",
|
|
]
|
|
return any(re.search(p, code) for p in patterns)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_1_open_add_account_wizard_defined(app_js_content):
|
|
"""P0-1: openAddAccountWizard must be defined in app.js."""
|
|
assert _is_function_defined(app_js_content, "openAddAccountWizard"), (
|
|
"openAddAccountWizard is called in DOM/events but has 0 definitions in app.js"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_1_handle_node_account_change_defined(app_js_content):
|
|
"""P0-1: handleNodeAccountChange must be defined in app.js."""
|
|
assert _is_function_defined(app_js_content, "handleNodeAccountChange"), (
|
|
"handleNodeAccountChange is called in overview/routing diagrams but has 0 definitions in app.js"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_1_handle_node_model_change_defined(app_js_content):
|
|
"""P0-1: handleNodeModelChange must be defined in app.js."""
|
|
assert _is_function_defined(app_js_content, "handleNodeModelChange"), (
|
|
"handleNodeModelChange is called in overview/routing diagrams but has 0 definitions in app.js"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_1_handle_refresh_provider_models_defined(app_js_content):
|
|
"""P0-1: handleRefreshProviderModels must be defined in app.js."""
|
|
assert _is_function_defined(app_js_content, "handleRefreshProviderModels"), (
|
|
"handleRefreshProviderModels is called in overview/routing diagrams but has 0 definitions in app.js"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_1_check_updates_defined(app_js_content):
|
|
"""P0-1: checkUpdates must be defined in app.js."""
|
|
assert _is_function_defined(app_js_content, "checkUpdates"), (
|
|
"checkUpdates is called at DOMContentLoaded and button click but has 0 definitions in app.js"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_1_all_html_and_template_handlers_defined(app_js_content, index_html_content):
|
|
"""P0-1: All inline event handlers used in HTML and template literals must have matching definitions in app.js."""
|
|
combined = index_html_content + "\n" + app_js_content
|
|
handler_pattern = re.compile(r'on(?:click|change|submit|input|keydown|keyup|dragstart|dragover|dragleave|drop|dragend)\s*=\s*["\x27](?:return\s+)?([a-zA-Z0-9_$]+)\s*\(', re.IGNORECASE)
|
|
|
|
used_handlers = set(handler_pattern.findall(combined))
|
|
assert used_handlers, "No event handlers found in HTML/JS"
|
|
|
|
missing = [h for h in sorted(used_handlers) if not _is_function_defined(app_js_content, h)]
|
|
assert not missing, f"The following handlers are called in UI but missing function definitions: {missing}"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_1_wizard_contracts_and_actions(app_js_content):
|
|
"""P0-1: Wizard must use startDeviceAuth, startRedirectAuth, add_account and support manual slot selection."""
|
|
assert "showWizardStep1" in app_js_content
|
|
assert "showWizardStep2" in app_js_content
|
|
assert "showWizardStep3" in app_js_content
|
|
assert "finishAddAccount" in app_js_content
|
|
# Wizard slot selection & SSH port-forward tunnel hints
|
|
assert "buildSlotOptions" in app_js_content
|
|
assert "startDeviceAuth" in app_js_content
|
|
assert "startRedirectAuth" in app_js_content
|
|
assert "wiz-redirect-tunnel" in app_js_content or "ssh -L" in app_js_content
|
|
# Base url manual input for local LLM
|
|
assert "wiz-base-url-input" in app_js_content
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_1_node_dom_execution_contract():
|
|
"""P0-1: Execute targeted Node.js script to simulate DOM loading and verify all 5 handlers are callable."""
|
|
res = subprocess.run(
|
|
["node", "tests/test_web_handlers_dom_contract.js"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res.returncode == 0, f"Node DOM contract test failed:\nSTDOUT:\n{res.stdout}\nSTDERR:\n{res.stderr}"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_1_no_elementsmap_in_production_app_js(app_js_content):
|
|
"""P0-1 regression: elementsMap must NOT appear in production app.js.
|
|
elementsMap is a test-only artifact — its use in openAddAccountWizard caused
|
|
a ReferenceError in the real browser where elementsMap is undefined.
|
|
Any test-only element-caching must live inside the Node test sandbox only.
|
|
"""
|
|
assert 'elementsMap' not in app_js_content, (
|
|
"elementsMap must not appear in production app.js — "
|
|
"it is a test-only variable. openAddAccountWizard must not reference it."
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# TDD: owner-selected slots for every account-connect flow (P0-1 remainder)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.unit
|
|
def test_tdd_grok_codex_shows_slot_picker_before_device_auth(app_js_content):
|
|
"""TDD GAP-1: Grok/Codex device-auth must show owner slot picker BEFORE startDeviceAuth.
|
|
The grok/openai-codex branch in showWizardStep2 must include wiz-device-slot select.
|
|
"""
|
|
# Find the grok/openai-codex branch in showWizardStep2
|
|
# It must have a slot select element, not just device-auth-box
|
|
assert "wiz-device-slot" in app_js_content, (
|
|
"GAP-1: grok/codex flow must have 'wiz-device-slot' select element in the template"
|
|
)
|
|
# The slot select must use buildSlotOptions to populate options
|
|
assert "buildSlotOptions" in app_js_content
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_tdd_finish_add_account_sends_profile_id(app_js_content):
|
|
"""TDD GAP-2: finishAddAccount must send chosen profile_id/slot in add_account payload.
|
|
It must NOT silently find_free_slot for the owner.
|
|
"""
|
|
# finishAddAccount must read wiz-device-slot OR wiz-redirect-slot
|
|
# and include profile_id in the payload
|
|
assert "wiz-device-slot" in app_js_content or "wiz-redirect-slot" in app_js_content, (
|
|
"GAP-2: finishAddAccount must read a slot select element (wiz-device-slot or wiz-redirect-slot)"
|
|
)
|
|
# The payload passed to add_account must include profile_id
|
|
# Look for pattern: payload.profile_id or payload['profile_id']
|
|
finish_fn_match = re.search(
|
|
r'async function finishAddAccount\([^)]*\)\s*\{(.*?)\n\}',
|
|
app_js_content,
|
|
re.DOTALL
|
|
)
|
|
assert finish_fn_match, "finishAddAccount function not found"
|
|
finish_body = finish_fn_match.group(1)
|
|
# Must contain profile_id in the payload
|
|
assert "profile_id" in finish_body, (
|
|
"GAP-2: finishAddAccount must include 'profile_id' in the add_account payload"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_tdd_wizard_step3_dynamic_roles_from_snapshot_routing(app_js_content):
|
|
"""TDD GAP-3: Wizard step 3 must NOT hardcode only 6 legacy role IDs.
|
|
It must build role options from currentSnapshot.routing if snapshot agents exist.
|
|
"""
|
|
# Find showWizardStep3 function
|
|
step3_match = re.search(
|
|
r'function showWizardStep3\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\n$)',
|
|
app_js_content,
|
|
re.DOTALL
|
|
)
|
|
assert step3_match, "showWizardStep3 function not found"
|
|
step3_body = step3_match.group(1)
|
|
|
|
# Must reference currentSnapshot.routing for dynamic role building
|
|
assert "currentSnapshot.routing" in step3_body, (
|
|
"GAP-3: showWizardStep3 must use currentSnapshot.routing to build role options dynamically"
|
|
)
|
|
|
|
# The dynamic branch (roleIds.length > 0) must NOT be the fallback hardcoded list.
|
|
# Check that roleIds (from Object.keys(routing)) is used to build options dynamically.
|
|
# The presence of "roleIds.map" or "Object.keys(routing)" indicates dynamic path.
|
|
assert ("roleIds.map" in step3_body or "Object.keys" in step3_body), (
|
|
"GAP-3: showWizardStep3 must use roleIds.map(...) to build options dynamically from currentSnapshot.routing"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_tdd_all_new_template_handlers_defined(app_js_content):
|
|
"""TDD GAP-4: All onclick/onchange in app.js template literals must have function definitions.
|
|
After GAP-1/2/3 fixes, verify no new dangling handlers were introduced.
|
|
"""
|
|
combined = app_js_content # index.html already checked in existing test
|
|
handler_pattern = re.compile(
|
|
r'on(?:click|change|submit|input|keydown|keyup|dragstart|dragover|dragleave|drop|dragend)\s*=\s*["\'](?:return\s+)?([a-zA-Z0-9_$]+)\s*\(',
|
|
re.IGNORECASE
|
|
)
|
|
used_handlers = set(handler_pattern.findall(combined))
|
|
if not used_handlers:
|
|
pytest.skip("No event handlers found in app.js template literals")
|
|
missing = [h for h in sorted(used_handlers) if not _is_function_defined(app_js_content, h)]
|
|
assert not missing, f"GAP-4: Handlers called in templates but missing definitions: {missing}"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# TDD: P0-2b OpenRouter / NVIDIA wizard buttons
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_2b_wizard_openrouter_button_exists(app_js_content):
|
|
"""P0-2b: showWizardStep1 must have onclick showWizardStep2('openrouter')."""
|
|
assert "showWizardStep2('openrouter')" in app_js_content, (
|
|
"P0-2b: showWizardStep1 is missing onclick showWizardStep2('openrouter') button"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_2b_wizard_nvidia_button_exists(app_js_content):
|
|
"""P0-2b: showWizardStep1 must have onclick showWizardStep2('nvidia')."""
|
|
assert "showWizardStep2('nvidia')" in app_js_content, (
|
|
"P0-2b: showWizardStep1 is missing onclick showWizardStep2('nvidia') button"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_2b_wizard_openrouter_step2_has_slot_and_base_url(app_js_content):
|
|
"""P0-2b: showWizardStep2('openrouter') must render slot picker + base URL input."""
|
|
# The openrouter branch must use wiz-redirect-slot and wiz-base-url-input
|
|
step2_match = re.search(
|
|
r"function showWizardStep2\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\nasync function|\n$)",
|
|
app_js_content,
|
|
re.DOTALL
|
|
)
|
|
assert step2_match, "showWizardStep2 function not found"
|
|
step2_body = step2_match.group(1)
|
|
# Find the openrouter branch
|
|
openrouter_branch = re.search(
|
|
r"providerId\s*===\s*['\"]openrouter['\"]",
|
|
step2_body
|
|
)
|
|
assert openrouter_branch, "showWizardStep2 has no openrouter branch"
|
|
# The openrouter branch must have slot selector and base URL input
|
|
openrouter_section = step2_body[openrouter_branch.start():]
|
|
# Find the next provider branch or end
|
|
next_branch = re.search(
|
|
r"if\s*\(\s*providerId\s*===\s*['\"]",
|
|
openrouter_section[20:]
|
|
)
|
|
openrouter_chunk = openrouter_section[:20 + next_branch.start()] if next_branch else openrouter_section[20:]
|
|
assert "wiz-redirect-slot" in openrouter_chunk, (
|
|
"P0-2b: openrouter branch must use wiz-redirect-slot for slot selection"
|
|
)
|
|
assert "wiz-base-url-input" in openrouter_chunk, (
|
|
"P0-2b: openrouter branch must have wiz-base-url-input for optional base URL"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_2b_wizard_nvidia_step2_has_slot_and_base_url(app_js_content):
|
|
"""P0-2b: showWizardStep2('nvidia') must render slot picker + base URL input."""
|
|
step2_match = re.search(
|
|
r"function showWizardStep2\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\nasync function|\n$)",
|
|
app_js_content,
|
|
re.DOTALL
|
|
)
|
|
assert step2_match, "showWizardStep2 function not found"
|
|
step2_body = step2_match.group(1)
|
|
# nvidia may be in a combined OR branch with openrouter, or standalone
|
|
nvidia_branch = re.search(
|
|
r"providerId\s*===\s*['\"]nvidia['\"]",
|
|
step2_body
|
|
)
|
|
assert nvidia_branch, "showWizardStep2 has no nvidia branch"
|
|
nvidia_section = step2_body[nvidia_branch.start():]
|
|
next_branch = re.search(
|
|
r"if\s*\(\s*providerId\s*===\s*['\"]",
|
|
nvidia_section[20:]
|
|
)
|
|
nvidia_chunk = nvidia_section[:20 + next_branch.start()] if next_branch else nvidia_section[20:]
|
|
assert "wiz-redirect-slot" in nvidia_chunk, (
|
|
"P0-2b: nvidia branch must use wiz-redirect-slot for slot selection"
|
|
)
|
|
assert "wiz-base-url-input" in nvidia_chunk, (
|
|
"P0-2b: nvidia branch must have wiz-base-url-input for optional base URL"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_2b_proceedtostep3_sets_slot_for_openrouter_and_nvidia(app_js_content):
|
|
"""P0-2b: proceedToWizardStep3 must set window._wiz_device_profile for openrouter and nvidia."""
|
|
proceed_match = re.search(
|
|
r"function proceedToWizardStep3\([^)]*\)\s*\{(.*?)(?=\nfunction|\nasync function|\n$)",
|
|
app_js_content,
|
|
re.DOTALL
|
|
)
|
|
assert proceed_match, "proceedToWizardStep3 function not found"
|
|
proceed_body = proceed_match.group(1)
|
|
# openrouter and nvidia must be listed alongside antigravity/claude redirect-auth flow
|
|
# so that window._wiz_device_profile is set before showWizardStep3
|
|
assert "openrouter" in proceed_body, (
|
|
"P0-2b: proceedToWizardStep3 must handle openrouter to persist slot selection"
|
|
)
|
|
assert "nvidia" in proceed_body, (
|
|
"P0-2b: proceedToWizardStep3 must handle nvidia to persist slot selection"
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# TDD: P0-3 Local LLM server discovery button in wizard step 2
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_3_discover_local_models_action_referenced(app_js_content):
|
|
"""P0-3: discover_local_models action must be referenced in app.js."""
|
|
assert "discover_local_models" in app_js_content, (
|
|
"P0-3: discover_local_models action must be referenced in app.js"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_3_discover_button_in_local_wizard_step2(app_js_content):
|
|
"""P0-3: showWizardStep2 local branch must have 'Найти на этом компьютере' button
|
|
that calls discoverLocalServers or executeAction('discover_local_models')."""
|
|
step2_match = re.search(
|
|
r"function showWizardStep2\([^)]*\)\s*\{(.*?)(?=\nfunction|\nconst|\nlet|\nvar|\nasync function|\n$)",
|
|
app_js_content,
|
|
re.DOTALL
|
|
)
|
|
assert step2_match, "showWizardStep2 function not found"
|
|
step2_body = step2_match.group(1)
|
|
# Find the local provider branch
|
|
local_branch = re.search(
|
|
r"providerId\s*===\s*['\"]local['\"]",
|
|
step2_body
|
|
)
|
|
assert local_branch, "showWizardStep2 has no local provider branch"
|
|
local_section = step2_body[local_branch.start():]
|
|
# Find the extent of this branch (until next major provider branch or end)
|
|
next_branch = re.search(
|
|
r"if\s*\(\s*providerId\s*===\s*['\"]",
|
|
local_section[20:]
|
|
)
|
|
local_chunk = local_section[:20 + next_branch.start()] if next_branch else local_section[20:]
|
|
# Must contain the discover button label
|
|
assert "Найти на этом компьютере" in local_chunk, (
|
|
"P0-3: local wizard step 2 must have 'Найти на этом компьютере' button"
|
|
)
|
|
# Must call discover_local_models
|
|
assert "discover_local_models" in local_chunk, (
|
|
"P0-3: local wizard step 2 must call discover_local_models action"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_p0_3_discover_fills_base_url_input(app_js_content):
|
|
"""P0-3: discovered servers list must fill wiz-base-url-input on selection."""
|
|
# There must be a handler that sets wiz-base-url-input value from discovered server
|
|
assert "wiz-base-url-input" in app_js_content, (
|
|
"P0-3: wiz-base-url-input must be present for filling discovered server URL"
|
|
)
|
|
# The handler for discovered servers must reference both servers result and wiz-base-url-input
|
|
# Look for a function that handles discover_local_models response
|
|
discover_match = re.search(
|
|
r"function\s+(\w*discover\w*)\s*\(",
|
|
app_js_content,
|
|
re.IGNORECASE
|
|
)
|
|
assert discover_match, "P0-3: no function handling discover_local_models response found"
|
|
handler_name = discover_match.group(1)
|
|
handler_match = re.search(
|
|
rf"function\s+{re.escape(handler_name)}\s*\([^)]*\)\s*\{{(.*?)\n\}}",
|
|
app_js_content,
|
|
re.DOTALL
|
|
)
|
|
assert handler_match, f"handler {handler_name} body not found"
|
|
handler_body = handler_match.group(1)
|
|
assert "wiz-base-url-input" in handler_body, (
|
|
"P0-3: discover handler must fill wiz-base-url-input with selected server URL"
|
|
)
|