Merge remote-tracking branch 'origin/antigravity/web-client' into review/web
BIN
artifacts/a16-screenshots/01_web_accounts_view.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
artifacts/a16-screenshots/02_web_overview_view.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
artifacts/a16-screenshots/03_web_routing_view.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
artifacts/a16-screenshots/04_web_providers_view.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
artifacts/a16-screenshots/05_web_team_view.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
artifacts/a16-screenshots/06_web_grok_wizard.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
artifacts/a16-screenshots/07_web_antigravity_wizard.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
artifacts/a16-screenshots/08_web_account_details_modal.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
114
scripts/capture_live_a16_screenshots.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""
|
||||
Live screenshot capture for Hermes Hub Web Client (A16).
|
||||
Captures 8 scenarios via Headless Chrome CLI at 1440x920.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
STATIC_DIR = REPO_ROOT / "src" / "antigravity_provider" / "router" / "web" / "static"
|
||||
ARTIFACTS_DIR = REPO_ROOT / "artifacts" / "a16-screenshots"
|
||||
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CHROME_PATHS = [
|
||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
||||
]
|
||||
|
||||
|
||||
class StaticServer(threading.Thread):
|
||||
def __init__(self, port: int):
|
||||
super().__init__(daemon=True)
|
||||
self.port = port
|
||||
self.httpd = None
|
||||
|
||||
def run(self):
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(STATIC_DIR), **kwargs)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
self.httpd = http.server.HTTPServer(("127.0.0.1", self.port), Handler)
|
||||
self.httpd.serve_forever()
|
||||
|
||||
def stop(self):
|
||||
if self.httpd:
|
||||
self.httpd.shutdown()
|
||||
|
||||
|
||||
def get_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def main():
|
||||
port = get_free_port()
|
||||
server = StaticServer(port)
|
||||
server.start()
|
||||
print(f"Static HTTP Server running on http://127.0.0.1:{port}")
|
||||
time.sleep(0.3)
|
||||
|
||||
chrome_exe = None
|
||||
for p in CHROME_PATHS:
|
||||
if os.path.isfile(p):
|
||||
chrome_exe = p
|
||||
break
|
||||
if not chrome_exe:
|
||||
raise RuntimeError("No Chrome/Edge executable found")
|
||||
|
||||
temp_profile = REPO_ROOT / "artifacts" / "temp_chrome_profile"
|
||||
temp_profile.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
scenarios = [
|
||||
("01_web_accounts_view.png", f"http://127.0.0.1:{port}/index.html?view=accounts"),
|
||||
("02_web_overview_view.png", f"http://127.0.0.1:{port}/index.html?view=overview"),
|
||||
("03_web_routing_view.png", f"http://127.0.0.1:{port}/index.html?view=routing"),
|
||||
("04_web_providers_view.png", f"http://127.0.0.1:{port}/index.html?view=providers"),
|
||||
("05_web_team_view.png", f"http://127.0.0.1:{port}/index.html?view=team"),
|
||||
("06_web_grok_wizard.png", f"http://127.0.0.1:{port}/index.html?modal=grok_wizard"),
|
||||
("07_web_antigravity_wizard.png", f"http://127.0.0.1:{port}/index.html?modal=antigravity_wizard"),
|
||||
("08_web_account_details_modal.png", f"http://127.0.0.1:{port}/index.html?modal=account_details&profile=ag-spare-1"),
|
||||
]
|
||||
|
||||
captured_count = 0
|
||||
for filename, url in scenarios:
|
||||
out_file = ARTIFACTS_DIR / filename
|
||||
cmd = [
|
||||
chrome_exe,
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--hide-scrollbars",
|
||||
"--virtual-time-budget=2000",
|
||||
f"--user-data-dir={temp_profile}",
|
||||
"--window-size=1440,920",
|
||||
f"--screenshot={out_file}",
|
||||
url,
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, timeout=20)
|
||||
if res.returncode == 0 and out_file.exists():
|
||||
size = out_file.stat().st_size
|
||||
print(f"Captured: {filename} ({size} bytes)")
|
||||
captured_count += 1
|
||||
else:
|
||||
print(f"FAILED to capture {filename}: returncode {res.returncode}")
|
||||
|
||||
server.stop()
|
||||
print(f"\nTotal screenshots captured: {captured_count}/{len(scenarios)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1102
src/antigravity_provider/router/web/static/app.js
Normal file
283
src/antigravity_provider/router/web/static/index.html
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hermes Hub — Панель управления</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app-layout">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">⚡</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-title">HERMES HUB</span>
|
||||
<span class="brand-subtitle">Multi-Account Router</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
<button class="nav-item active" data-view="accounts">
|
||||
<span class="nav-icon">👥</span>
|
||||
<span class="nav-label">Аккаунты</span>
|
||||
<span class="nav-badge" id="nav-accounts-count">0</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="overview">
|
||||
<span class="nav-icon">📊</span>
|
||||
<span class="nav-label">Обзор</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="routing">
|
||||
<span class="nav-icon">🔀</span>
|
||||
<span class="nav-label">Маршрутизация</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="providers">
|
||||
<span class="nav-icon">🧩</span>
|
||||
<span class="nav-label">Модели и провайдеры</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="team">
|
||||
<span class="nav-icon">👑</span>
|
||||
<span class="nav-label">Команда агентов</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="logs">
|
||||
<span class="nav-icon">📜</span>
|
||||
<span class="nav-label">Журнал событий</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="settings">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span class="nav-label">Настройки</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="source-indicator" id="source-indicator">
|
||||
<span class="status-dot healthy"></span>
|
||||
<span class="source-text" id="source-text">Загрузка данных...</span>
|
||||
</div>
|
||||
<div class="version-tag" id="version-tag">Hermes Hub Web v0.1.1</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Container -->
|
||||
<main class="main-wrapper">
|
||||
<!-- Top Header -->
|
||||
<header class="top-header">
|
||||
<div class="header-left">
|
||||
<h1 class="header-title" id="page-title">Аккаунты и квоты</h1>
|
||||
<span class="header-readiness-badge" id="header-readiness-badge">
|
||||
<span class="status-dot"></span>
|
||||
<span id="header-readiness-text">Инициализация...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" id="btn-refresh-all" title="Обновить квоты всех профилей">
|
||||
<span class="btn-icon">↻</span>
|
||||
<span>Обновить всё</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" id="btn-add-account">
|
||||
<span class="btn-icon">+</span>
|
||||
<span>Добавить аккаунт</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Dynamic Views -->
|
||||
<div class="content-scroll">
|
||||
<!-- 1. ACCOUNTS VIEW -->
|
||||
<section id="view-accounts" class="view-pane active">
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input type="text" id="accounts-search" placeholder="Поиск по аккаунту, почте, роли или модели...">
|
||||
</div>
|
||||
<div class="filters-row">
|
||||
<select id="filter-provider" class="select-filter">
|
||||
<option value="all">Все провайдеры</option>
|
||||
<option value="antigravity">Google Antigravity</option>
|
||||
<option value="openai-codex">OpenAI Codex</option>
|
||||
<option value="opencode-go">OpenCode Go</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="grok">Grok (xAI)</option>
|
||||
</select>
|
||||
<select id="filter-health" class="select-filter">
|
||||
<option value="all">Все состояния</option>
|
||||
<option value="healthy">Работает</option>
|
||||
<option value="warning">Предупреждение</option>
|
||||
<option value="quota_exhausted">Квота исчерпана</option>
|
||||
<option value="auth_required">Требуется вход</option>
|
||||
<option value="disabled">Отключён / Резерв</option>
|
||||
</select>
|
||||
<div class="toolbar-stats" id="accounts-stats-summary">
|
||||
Показано: <strong>0</strong> из <strong>0</strong> аккаунтов
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="accounts-container" class="accounts-groups-container">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. OVERVIEW VIEW -->
|
||||
<section id="view-overview" class="view-pane">
|
||||
<div class="overview-grid">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Состояние системы</div>
|
||||
<div class="kpi-value text-healthy" id="kpi-system-readiness">—</div>
|
||||
<div class="kpi-sub" id="kpi-readiness-summary">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Подключено аккаунтов</div>
|
||||
<div class="kpi-value text-accent" id="kpi-total-accounts">—</div>
|
||||
<div class="kpi-sub" id="kpi-accounts-sub">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Готовых ролей</div>
|
||||
<div class="kpi-value text-healthy" id="kpi-ready-roles">—</div>
|
||||
<div class="kpi-sub" id="kpi-roles-sub">—</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Провайдеры ИИ</div>
|
||||
<div class="kpi-value text-info" id="kpi-providers-count">—</div>
|
||||
<div class="kpi-sub" id="kpi-providers-sub">5 поддерживаемых систем</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title">Схема маршрутизации запросов</div>
|
||||
<div class="section-card-subtitle">Распределение агентов и цепочки отказоустойчивости</div>
|
||||
</div>
|
||||
<div class="route-diagram-container" id="overview-route-diagram">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title">Провайдеры и доступность runtime</div>
|
||||
<div class="section-card-subtitle">Статус локальных адаптеров и обнаруженные модели</div>
|
||||
</div>
|
||||
<div class="providers-grid" id="overview-providers-summary">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. ROUTING VIEW -->
|
||||
<section id="view-routing" class="view-pane">
|
||||
<div class="view-header-note">
|
||||
Цепочки маршрутизации: <strong>Основной → Резерв 1 → Резерв 2 → Резерв 3</strong>. Переключения выполняются автоматически при исчерпании квоты или ошибке провайдера.
|
||||
</div>
|
||||
<div class="routing-pipelines-list" id="routing-pipelines-container">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. PROVIDERS & MODELS VIEW -->
|
||||
<section id="view-providers" class="view-pane">
|
||||
<div class="view-header-note">
|
||||
Реально обнаруженные модели провайдеров и локальные адаптеры.
|
||||
</div>
|
||||
<div class="providers-full-list" id="providers-full-container">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. TEAM VIEW -->
|
||||
<section id="view-team" class="view-pane">
|
||||
<div class="view-header-note">
|
||||
Команда агентов Hermes Hub: роли, привязанные профили и оперативные квоты.
|
||||
</div>
|
||||
<div class="team-cards-grid" id="team-cards-container">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6. LOGS VIEW -->
|
||||
<section id="view-logs" class="view-pane">
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title">Журнал телеметрии и событий роутера</div>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-clear-logs">Очистить вид</button>
|
||||
</div>
|
||||
<div class="logs-container" id="logs-container">
|
||||
<div class="empty-text">Журнал событий пуст или сбор ещё не выполнен.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7. SETTINGS VIEW -->
|
||||
<section id="view-settings" class="view-pane">
|
||||
<div class="settings-card">
|
||||
<h2 class="settings-group-title">Параметры веб-клиента</h2>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Источник данных</div>
|
||||
<div class="setting-desc">Автоматическое переключение между живым сервером и фикстурой</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="setting-source-mode" class="select-filter">
|
||||
<option value="auto">Авто (Live API с fallback на фикстуру)</option>
|
||||
<option value="live">Строго Live API (/api/snapshot)</option>
|
||||
<option value="fixture">Строго фикстура (snapshot.example.json)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Интервал авто-опроса</div>
|
||||
<div class="setting-desc">Частота обновления снимка состояния с сервера</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="setting-poll-interval" class="select-filter">
|
||||
<option value="3000">3 секунды</option>
|
||||
<option value="5000" selected>5 секунд (стандарт)</option>
|
||||
<option value="10000">10 секунд</option>
|
||||
<option value="0">Отключить авто-опрос</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Токен безопасности (X-Hub-Token)</div>
|
||||
<div class="setting-desc">Обязателен при запуске сервера на нелокальном IP адресе</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="password" id="setting-auth-token" class="input-text" placeholder="Введите токен сервера...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="btn btn-primary" id="btn-save-client-settings">Сохранить параметры</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modal Layer -->
|
||||
<div id="modal-backdrop" class="modal-backdrop hidden">
|
||||
<div id="modal-container" class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title" class="modal-title">Модальное окно</h2>
|
||||
<button id="modal-close-btn" class="modal-close" title="Закрыть">✕</button>
|
||||
</div>
|
||||
<div id="modal-body" class="modal-body">
|
||||
<!-- Injected dynamically -->
|
||||
</div>
|
||||
<div id="modal-footer" class="modal-footer">
|
||||
<!-- Injected dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification Container -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
|
||||
<!-- Application Logic -->
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
5526
src/antigravity_provider/router/web/static/snapshot.example.json
Normal file
1044
src/antigravity_provider/router/web/static/style.css
Normal file
105
tests/test_web_client_contract.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""
|
||||
Hermes Hub — Web Client Invariants & Contract Verification Suite
|
||||
Tests adherence to docs/web-api/CONTRACT.md and A16 requirements.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
STATIC_DIR = REPO_ROOT / "src" / "antigravity_provider" / "router" / "web" / "static"
|
||||
SNAPSHOT_EXAMPLE = REPO_ROOT / "docs" / "web-api" / "snapshot.example.json"
|
||||
|
||||
|
||||
def test_static_assets_exist_and_no_build_dependencies():
|
||||
"""Verify that index.html, style.css, app.js exist and have zero build / npm dependencies."""
|
||||
index_html = STATIC_DIR / "index.html"
|
||||
style_css = STATIC_DIR / "style.css"
|
||||
app_js = STATIC_DIR / "app.js"
|
||||
|
||||
assert index_html.is_file(), f"Missing {index_html}"
|
||||
assert style_css.is_file(), f"Missing {style_css}"
|
||||
assert app_js.is_file(), f"Missing {app_js}"
|
||||
|
||||
html_content = index_html.read_text(encoding="utf-8")
|
||||
# No React, Webpack, Vite, npm or external bundle references
|
||||
assert "react" not in html_content.lower()
|
||||
assert "webpack" not in html_content.lower()
|
||||
assert "vite" not in html_content.lower()
|
||||
assert "<script src=\"app.js\"></script>" in html_content
|
||||
assert "<link rel=\"stylesheet\" href=\"style.css\">" in html_content
|
||||
|
||||
|
||||
def test_snapshot_fixture_validity_and_completeness():
|
||||
"""Verify snapshot.example.json conforms to HubSnapshot contract."""
|
||||
assert SNAPSHOT_EXAMPLE.is_file(), f"Missing {SNAPSHOT_EXAMPLE}"
|
||||
with open(SNAPSHOT_EXAMPLE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Top level keys
|
||||
required_keys = [
|
||||
"generation", "seq", "timestamp", "profiles_by_provider",
|
||||
"all_profiles", "readiness", "agents", "providers",
|
||||
"routing", "quotas", "metrics", "is_stale"
|
||||
]
|
||||
for key in required_keys:
|
||||
assert key in data, f"Missing required top-level key: {key}"
|
||||
|
||||
# Verify monotonic seq structure
|
||||
assert isinstance(data["seq"], int)
|
||||
assert data["seq"] >= 1
|
||||
|
||||
# Verify profiles count
|
||||
assert len(data["all_profiles"]) >= 16, "Must contain real profiles fixture"
|
||||
|
||||
# Zero leaked tokens / secrets in snapshot
|
||||
raw_text = json.dumps(data)
|
||||
forbidden_tokens = ["access_token", "refresh_token", "api_key", "client_secret"]
|
||||
for tok in forbidden_tokens:
|
||||
# Key shouldn't exist as actual secret payload
|
||||
assert f'"{tok}": "sk-' not in raw_text
|
||||
assert f'"{tok}": "gho_' not in raw_text
|
||||
|
||||
|
||||
def test_monotonic_seq_logic_in_app_js():
|
||||
"""Verify app.js contains strict monotonic seq checking to prevent stale response overwrites."""
|
||||
app_js_content = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
||||
assert "lastAppliedSeq" in app_js_content
|
||||
assert "snapshot.seq < lastAppliedSeq" in app_js_content
|
||||
assert "Stale snapshot" in app_js_content
|
||||
|
||||
|
||||
def test_account_card_compact_height_and_quota_rendering():
|
||||
"""Verify CSS has 164px compact fixed height and app.js renders multi-pool quota cells."""
|
||||
style_css = (STATIC_DIR / "style.css").read_text(encoding="utf-8")
|
||||
assert "164px" in style_css
|
||||
assert ".account-card" in style_css
|
||||
assert "overflow: hidden" in style_css
|
||||
|
||||
app_js = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
||||
assert "renderQuotaCell" in app_js
|
||||
assert "remaining_percent" in app_js
|
||||
assert "unavailable_reason" in app_js
|
||||
assert "Н/Д" in app_js
|
||||
|
||||
|
||||
def test_headless_server_auth_matrix():
|
||||
"""Verify headless server honesty in Add Account Wizard (Grok/Codex device-code vs Antigravity/Claude redirect warning)."""
|
||||
app_js = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
||||
assert "Device Code OAuth" in app_js
|
||||
assert "https://x.ai/device" in app_js
|
||||
assert "https://auth.openai.com/device" in app_js
|
||||
assert "Headless Сервер" in app_js
|
||||
assert "ssh -L 8085:localhost:8085" in app_js
|
||||
|
||||
|
||||
def test_actions_contract_handling():
|
||||
"""Verify POST /api/action handles ok: false as valid 200 business response and displays feedback in-place."""
|
||||
app_js = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
||||
assert "POST" in app_js
|
||||
assert "/api/action" in app_js
|
||||
assert "executeAction" in app_js
|
||||
assert "modal-feedback-area" in app_js
|
||||