Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09bbbfc657 | ||
|
|
88dc8bbe26 | ||
|
|
1fee123ac8 | ||
|
|
a683553699 | ||
|
|
63fb22b7ee | ||
|
|
05f09012a5 | ||
|
|
3c771c4d2c | ||
|
|
2398ec51fe | ||
|
|
4eaf4e0743 | ||
|
|
1c0d13c6d9 | ||
|
|
4c78d8a56b | ||
|
|
229680ae1e | ||
|
|
e0e642a239 | ||
|
|
e684fdd731 | ||
|
|
2a3324c201 | ||
|
|
39d42be396 | ||
|
|
2fc19a8326 |
37
CHANGELOG.md
37
CHANGELOG.md
@@ -5,6 +5,43 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.50.12] Profile .env isolation — prevent API key leakage on profile switch (fixes #351)
|
||||
|
||||
- **API keys no longer leak between profiles on switch** (`api/profiles.py`): `_reload_dotenv()` now tracks which env vars were loaded from the active profile's `.env` and clears them before loading the next profile. Previously, switching from a profile with `OPENAI_API_KEY=X` to a profile without that key left `X` in `os.environ` for the duration of the process — effectively leaking credentials across the profile boundary. A module-level `_loaded_profile_env_keys: set[str]` tracks loaded keys; it is cleared and repopulated on every `_reload_dotenv()` call.
|
||||
- **`apply_onboarding_setup()` ordering fixed** (`api/onboarding.py`): the belt-and-braces `os.environ[key] = api_key` direct assignment is now placed **after** `_reload_dotenv()`. Previously the key was wiped by the isolation cleanup when `_reload_dotenv()` ran immediately after the direct set.
|
||||
- 2 new tests in `tests/test_profile_env_isolation.py`; 815 tests total (up from 813)
|
||||
|
||||
## [v0.50.11] Chat table styles + plain URL auto-linking (fixes #341, #342)
|
||||
|
||||
- **Tables in chat messages now render with visible borders** (`static/style.css`): The `.msg-body` area had no table CSS, so markdown tables sent by the assistant were unstyled and unreadable. Four new rules mirror the existing `.preview-md` table styles: `border-collapse:collapse`, per-cell padding and borders via `var(--border2)`, and an alternating-row tint. Two `:root[data-theme="light"]` overrides ensure the borders and header background adapt correctly in light mode. (fixes #341)
|
||||
- **Plain URLs in chat messages are now clickable** (`static/ui.js`): Bare URLs like `https://example.com` were rendered as plain text. A new autolink pass in `renderMd()` converts `https?://...` URLs to `<a>` tags automatically. Runs after the SAFE_TAGS escape pass (protecting code blocks), before paragraph wrapping. Also applied inside `inlineMd()` so URLs in list items, blockquotes, and table cells are linked too. Trailing punctuation stripped; `esc()` applied to both href and link text. (fixes #342)
|
||||
- 11 new tests (4 in `tests/test_issue341.py`, 7 in `tests/test_issue342.py`); 813 tests total (up from 802)
|
||||
- **Test infrastructure fix** (`tests/test_sprint34.py` #349): two static-file opens used bare relative paths that failed when pytest ran from outside the repo root; replaced with `pathlib.Path(__file__).parent.parent` consistent with the rest of the suite. 813/813 now pass from any working directory.
|
||||
|
||||
## [v0.50.10] Title auto-generation fix + mobile close button (PR #333)
|
||||
|
||||
- **Session title now auto-generates for all default title values** (`'Untitled'`, `'New Chat'`, empty string): The condition in `api/streaming.py` that triggers `title_from()` previously only matched `'Untitled'`. It now also covers `'New Chat'` (used by some external clients/forks) and any empty/falsy title, so sessions started from those states get a proper auto-generated title after the first message.
|
||||
- **Redundant workspace panel close button hidden on mobile** (`static/style.css`): On viewports ≤900px wide, both the desktop collapse button (`#btnCollapseWorkspacePanel`) and the mobile-specific X button (`.mobile-close-btn`) were rendered simultaneously. The desktop button is now hidden on mobile and `.mobile-close-btn` is hidden by default (desktop) and shown only on mobile — eliminating the duplicate control.
|
||||
- 11 new tests in `tests/test_sprint41.py`; 802 tests total (up from 791)
|
||||
|
||||
## [v0.50.9] Onboarding works from Docker bridge networks (PR #335, fixes #334)
|
||||
|
||||
- **Docker users can now complete onboarding without enabling auth first** (closes #334): The onboarding setup endpoint previously only accepted requests from `127.0.0.1`. Docker containers connect via bridge network IPs (`172.17.x.x`, etc.), so the endpoint returned a 403 mid-wizard with no clear explanation. The check now accepts any loopback or RFC-1918 private address (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) using Python's `ipaddress.is_loopback` and `is_private`. Public IPs are still blocked unless auth is enabled.
|
||||
|
||||
## [v0.50.8] Model dropdown deduplication — hyphen vs dot separator fix (PR #332)
|
||||
|
||||
- **Model dropdown no longer shows duplicates for hyphen-format configs** (e.g. `claude-sonnet-4-6` from hermes-agent config): The server-side normalization in `api/config.py` now unifies hyphens and dots when checking whether the default model is already in the dropdown. Previously, `claude-sonnet-4-6` (hermes-agent format) and `claude-sonnet-4.6` (WebUI list format) were treated as different models, causing the same model to appear twice — once as a raw unlabelled entry and once with the correct display name. The raw entry is now suppressed and the labelled one is selected as default.
|
||||
- **README updated**: test count corrected to 791 / 51 files; all module line counts updated to current values; `onboarding.py`, `state_sync.py`, `updates.py` added to the architecture listing.
|
||||
|
||||
## [v0.50.7] OAuth provider onboarding path — Codex/Copilot no longer blocks setup (PR #331, fixes #329 bug 2)
|
||||
|
||||
- **OAuth providers now have a proper onboarding path** (closes bug 2): Users with `openai-codex`, `copilot`, `qwen-oauth`, or any other OAuth-authenticated provider now see a clear confirmation card instead of an unusable API key input form.
|
||||
- If already authenticated (`chat_ready: true`): blue "Provider already authenticated" card with a direct Continue button — no key entry required.
|
||||
- If not yet authenticated: amber card explaining how to run `hermes auth` or `hermes model` in a terminal to complete setup.
|
||||
- Either state includes a collapsible "switch provider" section for users who want to move to an API-key provider instead.
|
||||
- `_build_setup_catalog` now includes `current_is_oauth` boolean; fixed a latent `KeyError` crash when looking up `default_model` for OAuth providers.
|
||||
- 5 new i18n keys in English and Spanish (`onboarding_oauth_*`).
|
||||
- 15 new tests in `tests/test_sprint40.py`; 791 tests total (up from 776)
|
||||
|
||||
## [v0.50.6] Skip-onboarding env var + synchronous API key reload (PR #330, fixes #329 bugs 1+3)
|
||||
|
||||
|
||||
44
README.md
44
README.md
@@ -339,8 +339,8 @@ Or using the agent venv explicitly:
|
||||
```
|
||||
|
||||
Tests run against an isolated server on port 8788 with a separate state directory.
|
||||
Production data and real cron jobs are never touched. Current count: **433 tests**
|
||||
across 23 test files.
|
||||
Production data and real cron jobs are never touched. Current count: **802 tests**
|
||||
across 51 test files.
|
||||
|
||||
---
|
||||
|
||||
@@ -462,31 +462,33 @@ across 23 test files.
|
||||
## Architecture
|
||||
|
||||
```
|
||||
server.py HTTP routing shell + auth middleware (~83 lines)
|
||||
server.py HTTP routing shell + auth middleware (~154 lines)
|
||||
api/
|
||||
auth.py Optional password authentication, signed cookies (~149 lines)
|
||||
config.py Discovery, globals, model detection, reloadable config (~726 lines)
|
||||
helpers.py HTTP helpers, security headers (~71 lines)
|
||||
models.py Session model + CRUD + CLI bridge (~338 lines)
|
||||
profiles.py Profile state management, hermes_cli wrapper (~366 lines)
|
||||
routes.py All GET + POST route handlers (~1314 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~332 lines)
|
||||
upload.py Multipart parser, file upload handler (~78 lines)
|
||||
auth.py Optional password authentication, signed cookies (~201 lines)
|
||||
config.py Discovery, globals, model detection, reloadable config (~1110 lines)
|
||||
helpers.py HTTP helpers, security headers (~175 lines)
|
||||
models.py Session model + CRUD + CLI bridge (~377 lines)
|
||||
onboarding.py First-run onboarding wizard, OAuth provider support (~507 lines)
|
||||
profiles.py Profile state management, hermes_cli wrapper (~411 lines)
|
||||
routes.py All GET + POST route handlers (~1996 lines)
|
||||
state_sync.py /insights sync — message_count to state.db (~113 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~545 lines)
|
||||
updates.py Self-update check and release notes (~257 lines)
|
||||
upload.py Multipart parser, file upload handler (~82 lines)
|
||||
workspace.py File ops, workspace helpers, git detection (~288 lines)
|
||||
static/
|
||||
index.html HTML template (~600 lines)
|
||||
style.css All CSS incl. mobile responsive, themes (~855 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, context ring (~1090 lines)
|
||||
workspace.js File preview, file ops, git badge (~247 lines)
|
||||
sessions.js Session CRUD, ⋯ dropdown, collapsible groups, search (~600 lines)
|
||||
messages.js send(), SSE handlers, rAF throttle (~352 lines)
|
||||
panels.js Cron, skills, memory, profiles, control center (~1200 lines)
|
||||
commands.js Slash command autocomplete (~170 lines)
|
||||
boot.js Mobile nav, workspace state machine, composer chips, boot IIFE (~420 lines)
|
||||
style.css All CSS incl. mobile responsive, themes (~1050 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, context indicator (~1496 lines)
|
||||
workspace.js File preview, file ops, git badge (~286 lines)
|
||||
sessions.js Session CRUD, collapsible groups, search (~752 lines)
|
||||
messages.js send(), SSE handlers, rAF throttle (~487 lines)
|
||||
panels.js Cron, skills, memory, profiles, settings (~1438 lines)
|
||||
commands.js Slash command autocomplete (~267 lines)
|
||||
boot.js Mobile nav, voice input, boot IIFE (~524 lines)
|
||||
tests/
|
||||
conftest.py Isolated test server (port 8788)
|
||||
test_sprint{1-36}.py 36 test files, 742 test functions
|
||||
test_regressions.py Permanent regression gate
|
||||
51 test files 802 test functions
|
||||
Dockerfile python:3.12-slim container image
|
||||
docker-compose.yml Compose with named volume and optional auth
|
||||
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
> Each section is written as a step-by-step test procedure with expected outcomes.
|
||||
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
|
||||
>
|
||||
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 700 total (700 passing, 0 skipped, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), and the `/api/onboarding/*` backend.
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
@@ -913,10 +913,11 @@ def get_available_models() -> dict:
|
||||
# Ensure the user's configured default_model always appears in the dropdown.
|
||||
# It may be missing if the model isn't in any hardcoded list (e.g. openrouter/free,
|
||||
# a custom local model, or any model.default not in _FALLBACK_MODELS).
|
||||
# Normalize before comparing: strip provider prefix so 'anthropic/claude-opus-4.6'
|
||||
# matches 'claude-opus-4.6' already in the list and avoids a duplicate entry.
|
||||
# Normalize before comparing: strip provider prefix and unify separators so
|
||||
# 'anthropic/claude-opus-4.6' matches 'claude-opus-4.6' and 'claude-sonnet-4-6'
|
||||
# matches 'claude-sonnet-4.6' (hermes-agent uses hyphens, webui uses dots).
|
||||
if default_model:
|
||||
_norm = lambda mid: mid.split("/", 1)[-1] if "/" in mid else mid
|
||||
_norm = lambda mid: (mid.split("/", 1)[-1] if "/" in mid else mid).replace("-", ".")
|
||||
all_ids_norm = {_norm(m["id"]) for g in groups for m in g.get("models", [])}
|
||||
if _norm(default_model) not in all_ids_norm:
|
||||
# Determine which group to inject into. Compare against the
|
||||
|
||||
@@ -362,13 +362,23 @@ def _build_setup_catalog(cfg: dict) -> dict:
|
||||
}
|
||||
)
|
||||
|
||||
# Flag whether the currently-configured provider is OAuth-based (not in the
|
||||
# API-key flow). The frontend uses this to show a confirmation card instead
|
||||
# of a key input when the user has already authenticated via 'hermes auth'.
|
||||
current_is_oauth = current_provider not in _SUPPORTED_PROVIDER_SETUPS and bool(
|
||||
current_provider
|
||||
)
|
||||
|
||||
return {
|
||||
"providers": providers,
|
||||
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
|
||||
"current_is_oauth": current_is_oauth,
|
||||
"current": {
|
||||
"provider": current_provider,
|
||||
"model": current_model
|
||||
or _SUPPORTED_PROVIDER_SETUPS[current_provider]["default_model"],
|
||||
or _SUPPORTED_PROVIDER_SETUPS.get(current_provider, {}).get(
|
||||
"default_model", ""
|
||||
),
|
||||
"base_url": current_base_url,
|
||||
},
|
||||
}
|
||||
@@ -469,9 +479,6 @@ def apply_onboarding_setup(body: dict) -> dict:
|
||||
|
||||
if api_key:
|
||||
_write_env_file(env_path, {provider_meta["env_var"]: api_key})
|
||||
# Belt-and-braces: set directly on os.environ so the value is visible to
|
||||
# any code in the same process that reads it before the next request cycle.
|
||||
os.environ[provider_meta["env_var"]] = api_key
|
||||
|
||||
# Reload the hermes_cli provider/config cache so the next streaming call
|
||||
# picks up the new key without requiring a server restart.
|
||||
@@ -481,6 +488,12 @@ def apply_onboarding_setup(body: dict) -> dict:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Belt-and-braces: set directly on os.environ AFTER _reload_dotenv so the
|
||||
# value survives even if _reload_dotenv cleared it (e.g. when _write_env_file
|
||||
# wrote to disk but the profile isolation tracking hasn't seen it yet).
|
||||
if api_key:
|
||||
os.environ[provider_meta["env_var"]] = api_key
|
||||
|
||||
try:
|
||||
# hermes_cli may cache config at import time; ask it to reload if possible.
|
||||
from hermes_cli.config import reload as _cli_reload
|
||||
|
||||
@@ -26,6 +26,7 @@ _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md']
|
||||
# ── Module state ────────────────────────────────────────────────────────────
|
||||
_active_profile = 'default'
|
||||
_profile_lock = threading.Lock()
|
||||
_loaded_profile_env_keys: set[str] = set()
|
||||
|
||||
def _resolve_base_hermes_home() -> Path:
|
||||
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
|
||||
@@ -120,11 +121,24 @@ def _set_hermes_home(home: Path):
|
||||
|
||||
|
||||
def _reload_dotenv(home: Path):
|
||||
"""Load .env from the profile dir into os.environ (additive)."""
|
||||
"""Load .env from the profile dir into os.environ with profile isolation.
|
||||
|
||||
Clears env vars that were loaded from the previously active profile before
|
||||
applying the current profile's .env. This prevents API keys and other
|
||||
profile-scoped secrets from leaking across profile switches.
|
||||
"""
|
||||
global _loaded_profile_env_keys
|
||||
|
||||
# Remove keys loaded from the previous profile first.
|
||||
for key in list(_loaded_profile_env_keys):
|
||||
os.environ.pop(key, None)
|
||||
_loaded_profile_env_keys = set()
|
||||
|
||||
env_path = home / '.env'
|
||||
if not env_path.exists():
|
||||
return
|
||||
try:
|
||||
loaded_keys: set[str] = set()
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
@@ -133,8 +147,10 @@ def _reload_dotenv(home: Path):
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k and v:
|
||||
os.environ[k] = v
|
||||
loaded_keys.add(k)
|
||||
_loaded_profile_env_keys = loaded_keys
|
||||
except Exception:
|
||||
pass
|
||||
_loaded_profile_env_keys = set()
|
||||
|
||||
|
||||
def init_profile_state() -> None:
|
||||
|
||||
@@ -827,10 +827,19 @@ def handle_post(handler, parsed) -> bool:
|
||||
return j(handler, saved)
|
||||
|
||||
if parsed.path == "/api/onboarding/setup":
|
||||
# Writing API keys to disk - restrict to loopback unless auth is active
|
||||
# Writing API keys to disk - restrict to local/private networks unless auth is active.
|
||||
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,
|
||||
# even when the user accesses via localhost:8787 on the host.
|
||||
from api.auth import is_auth_enabled
|
||||
if not is_auth_enabled() and handler.client_address[0] != "127.0.0.1":
|
||||
return bad(handler, "Onboarding setup is only available from localhost when auth is not enabled.", 403)
|
||||
if not is_auth_enabled():
|
||||
import ipaddress
|
||||
try:
|
||||
addr = ipaddress.ip_address(handler.client_address[0])
|
||||
is_local = addr.is_loopback or addr.is_private
|
||||
except ValueError:
|
||||
is_local = False
|
||||
if not is_local:
|
||||
return bad(handler, "Onboarding setup is only available from local networks when auth is not enabled.", 403)
|
||||
try:
|
||||
return j(handler, apply_onboarding_setup(body))
|
||||
except ValueError as e:
|
||||
|
||||
@@ -335,7 +335,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'):
|
||||
_m['timestamp'] = int(_now)
|
||||
# Only auto-generate title when still default; preserves user renames
|
||||
if s.title == 'Untitled':
|
||||
if s.title == 'Untitled' or s.title == 'New Chat' or not s.title:
|
||||
s.title = title_from(s.messages, s.title)
|
||||
# Read token/cost usage from the agent object (if available)
|
||||
input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0
|
||||
|
||||
107
static/i18n.js
107
static/i18n.js
@@ -237,6 +237,11 @@ const LOCALES = {
|
||||
onboarding_missing_imports: 'Missing imports:',
|
||||
onboarding_notice_setup_required: 'Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.',
|
||||
onboarding_notice_setup_already_ready: 'A working Hermes provider setup is already detected. You can keep it or replace it here.',
|
||||
onboarding_oauth_provider_ready_title: 'Provider already authenticated',
|
||||
onboarding_oauth_provider_ready_body: 'This instance is configured to use an OAuth provider (<strong>{provider}</strong>) that was set up via the Hermes CLI. No API key is needed here — click Continue to finish setup.',
|
||||
onboarding_oauth_provider_not_ready_title: 'OAuth provider not yet authenticated',
|
||||
onboarding_oauth_provider_not_ready_body: 'This instance is configured to use <strong>{provider}</strong>, which uses OAuth rather than an API key. Run <code>hermes auth</code> or <code>hermes model</code> in a terminal to authenticate, then reload the Web UI.',
|
||||
onboarding_oauth_switch_hint: 'Or choose a different provider below to switch to an API-key setup:',
|
||||
onboarding_notice_workspace: 'These values reuse the same settings APIs as the normal app.',
|
||||
onboarding_workspace_label: 'Workspace',
|
||||
onboarding_workspace_or_path: 'Or enter a workspace path',
|
||||
@@ -497,6 +502,11 @@ const LOCALES = {
|
||||
onboarding_missing_imports: 'Importaciones faltantes:',
|
||||
onboarding_notice_setup_required: 'Elige aquí una ruta simple de proveedor. Los flujos OAuth avanzados siguen siendo del CLI de Hermes por ahora.',
|
||||
onboarding_notice_setup_already_ready: 'Ya se detectó una configuración funcional del proveedor de Hermes. Puedes conservarla o reemplazarla aquí.',
|
||||
onboarding_oauth_provider_ready_title: 'Proveedor ya autenticado',
|
||||
onboarding_oauth_provider_ready_body: 'Esta instancia está configurada para usar un proveedor OAuth (<strong>{provider}</strong>) configurado mediante la CLI de Hermes. No se necesita clave API aquí — haz clic en Continuar para finalizar la configuración.',
|
||||
onboarding_oauth_provider_not_ready_title: 'Proveedor OAuth no autenticado aún',
|
||||
onboarding_oauth_provider_not_ready_body: 'Esta instancia está configurada para usar <strong>{provider}</strong>, que utiliza OAuth en lugar de una clave API. Ejecuta <code>hermes auth</code> o <code>hermes model</code> en una terminal para autenticarte y recarga la interfaz web.',
|
||||
onboarding_oauth_switch_hint: 'O elige un proveedor diferente a continuación para cambiar a la configuración con clave API:',
|
||||
onboarding_notice_workspace: 'Estos valores reutilizan las mismas APIs de configuración que la app normal.',
|
||||
onboarding_workspace_label: 'Espacio de trabajo',
|
||||
onboarding_workspace_or_path: 'O introduce la ruta de un espacio de trabajo',
|
||||
@@ -874,57 +884,48 @@ const LOCALES = {
|
||||
login_btn: '\u767b\u5f55',
|
||||
login_invalid_pw: '\u5bc6\u7801\u9519\u8bef',
|
||||
login_conn_failed: '\u8fde\u63a5\u5931\u8d25',
|
||||
dialog_confirm_title: '确认操作',
|
||||
dialog_prompt_title: '输入内容',
|
||||
dialog_confirm_btn: '确认',
|
||||
discard: '放弃',
|
||||
clear: '清空',
|
||||
create: '创建',
|
||||
remove: '移除',
|
||||
project_name_prompt: '项目名称:',
|
||||
// missing keys from English
|
||||
tab_chat: '\u804a\u5929',
|
||||
tab_memory: '\u8a18\u61b6',
|
||||
tab_skills: '\u6280\u80fd',
|
||||
tab_tasks: '\u4efb\u52d9',
|
||||
tab_todos: '\u5f85\u8e29',
|
||||
tab_workspaces: '\u5de5\u4f5c\u5340',
|
||||
new_conversation: '\u65b0\u5b58\u5c0d\u8a71',
|
||||
filter_conversations: '\u7b5c\u9078\u5b58\u5c0d\u8a71',
|
||||
scheduled_jobs: '\u5b58\u5287\u4efb\u52d9',
|
||||
new_job: '\u65b0\u4efb\u52d9',
|
||||
search_skills: '\u641c\u5c0b\u6280\u80fd',
|
||||
new_skill: '\u65b0\u6280\u80fd',
|
||||
save_skill: '\u5132\u5b58\u6280\u80fd',
|
||||
personal_memory: '\u500b\u4eba\u8a18\u61b6',
|
||||
current_task_list: '\u76ee\u524d\u4efb\u52d9\u6e05\u55ae',
|
||||
new_profile: '\u65b0\u914d\u7f6e\u6a94',
|
||||
transcript: '\u8a18\u9304',
|
||||
download_transcript: '\u4e0b\u8f09\u8a18\u9304',
|
||||
import: '\u5c0e\u5165',
|
||||
editing: '\u7de8\u8f2f\u4e2d',
|
||||
empty_title: '\u7a7a\u767c\u5b58\u7a7a\u9593',
|
||||
empty_subtitle: '\u9ede\u64ca\u4e0a\u65b9\u6309\u9215\u958b\u59cb\u5c0d\u8a71',
|
||||
cancel: '\u53d6\u6d88',
|
||||
loading: '\u52a0\u8f09\u4e2d',
|
||||
create_job: '\u5efa\u7acb\u4efb\u52d9',
|
||||
suggest_plan: '\u5efa\u8b70\u8a08\u5287',
|
||||
suggest_schedule: '\u5efa\u8b70\u6642\u7a0b',
|
||||
suggest_files: '\u5efa\u8b70\u6a94\u6848',
|
||||
sign_out: '\u767b\u51fa',
|
||||
password_placeholder: '\u5bc6\u7801',
|
||||
disable_auth: '\u505c\u7528\u9a57\u8b49',
|
||||
settings_label_sound: '\u901a\u77e5\u8072\u97f3',
|
||||
settings_label_notifications: '\u700f\u89bd\u901a\u77e5',
|
||||
settings_desc_sound: '\u52a9\u624b\u5b8c\u6210\u56de\u7b54\u6642\u64a9\u653e\u8072\u97f3\u3002',
|
||||
settings_desc_notifications: '\u7576\u5206\u9801\u5728\u5f8c\u53f0\u6642\uff0c\u6709\u56de\u7b54\u5b8c\u6210\u6e05\u55ae\u6703\u986f\u793a\u7cfb\u7d71\u901a\u77e5\u3002',
|
||||
settings_desc_token_usage: '\u5728\u52a9\u624b\u6bcf\u6b21\u56de\u7b54\u4e0b\u65b9\u986f\u793a Input/Output token \u6578\u91cf\u3002\u4e5f\u53ef\u4ee5\u7528 /usage \u5207\u63db\u3002',
|
||||
settings_desc_cli_sessions: '\u5c07 Hermes CLI (\u7684 state.db) \u4e2d\u7684\u4f1a\u8a71\u6dfb\u52a0\u5230\u4f1a\u8a71\u6e05\u55ae\u3002\u9ede\u64ca\u4e00\u500b CLI \u4f1a\u8a71\u5c07\u5c0e\u5165\u5b83\u7a0b\u5f0f\u4e26\u7e7c\u7e8c\u5b58\u5c0d\u8a71\u3002',
|
||||
settings_desc_sync_insights: '\u5c07 WebUI token \u4f7f\u7528\u60c5\u6cc1\u540c\u6b65\u5230 state.db\uff0c\u8a93 hermes /insights \u5305\u542b\u700f\u89bd\u5668\u4f1a\u8a71\u6578\u64da\u3002\u9810\u8a2d\u70b8\u555f\u7528\u3002',
|
||||
settings_desc_check_updates: '\u7576\u6709\u66f4\u65b0\u7684 WebUI \u6216\u52a9\u624b\u7248\u672c\u6642\u986f\u793a\u6a19\u8a18\u3002\u5c07\u5728\u5f8c\u81ea\u6b63\u5e38\u57f7\u884c Git-Fetch\u3002',
|
||||
settings_desc_bot_name: '\u52a9\u624b\u5728 UI \u4e2d\u7684\u986f\u793a\u540d\u7a31\u3002\u9810\u8a2d\u70b8\u7528\u6539\u3002',
|
||||
settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u7801\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002',
|
||||
settings_label_sound: '\u901a\u77e5\u8072\u97f3',
|
||||
// sidebar & navigation
|
||||
tab_chat: '聊天',
|
||||
tab_memory: '记忆',
|
||||
tab_skills: '技能',
|
||||
tab_tasks: '任务',
|
||||
tab_todos: '待办',
|
||||
tab_workspaces: '工作区',
|
||||
new_conversation: '新建对话',
|
||||
filter_conversations: '筛选对话…',
|
||||
scheduled_jobs: '定时任务',
|
||||
new_job: '新任务',
|
||||
search_skills: '搜索技能…',
|
||||
new_skill: '新技能',
|
||||
save_skill: '保存技能',
|
||||
personal_memory: '个人记忆',
|
||||
current_task_list: '当前任务列表',
|
||||
new_profile: '新配置',
|
||||
transcript: '记录',
|
||||
download_transcript: '下载为 Markdown',
|
||||
import: '导入',
|
||||
editing: '编辑中',
|
||||
empty_title: '有什么可以帮您?',
|
||||
empty_subtitle: '随时提问、运行命令、浏览文件或管理定时任务。',
|
||||
cancel: '取消',
|
||||
loading: '加载中…',
|
||||
create_job: '创建任务',
|
||||
suggest_plan: '帮我规划一个小项目。',
|
||||
suggest_schedule: '今天有什么安排?',
|
||||
suggest_files: '这个工作区有哪些文件?',
|
||||
sign_out: '退出登录',
|
||||
password_placeholder: '输入新密码…',
|
||||
disable_auth: '停用认证',
|
||||
settings_label_sound: '通知声音',
|
||||
settings_label_notifications: '浏览器通知',
|
||||
settings_desc_sound: '助手完成回复时播放提示音。',
|
||||
settings_desc_notifications: '当标签页在后台时,回复完成后显示系统通知。',
|
||||
settings_desc_token_usage: '在助手每次回复下方显示输入/输出 token 数量。也可以用 /usage 切换。',
|
||||
settings_desc_cli_sessions: '将 Hermes CLI(state.db)中的会话合并到会话列表。点击某个 CLI 会话可导入并继续对话。',
|
||||
settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。',
|
||||
settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。',
|
||||
settings_desc_bot_name: '助手在 UI 中的显示名称。默认为 Hermes。',
|
||||
settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。',
|
||||
},
|
||||
|
||||
// Traditional Chinese (zh-Hant)
|
||||
@@ -963,8 +964,8 @@ const LOCALES = {
|
||||
approval_btn_once_title: '\u5141\u8a31\u57f7\u884c\u6b64\u547d\u4ee4\u4e00\u6b21\uff08Enter\uff09',
|
||||
approval_btn_session: '\u672c\u6b21\u5141\u8a31',
|
||||
approval_btn_session_title: '\u672c\u6b21\u6703\u8a71\u671f\u9593\u5141\u8a31',
|
||||
approval_btn_always: '\u59c4\u59b9\u5141\u8a31',
|
||||
approval_btn_always_title: '\u59c4\u59b9\u5141\u8a31\u6b64\u547d\u4ee4\u6a21\u5f0f',
|
||||
approval_btn_always: '始終允許',
|
||||
approval_btn_always_title: '始終允許此命令模式',
|
||||
approval_btn_deny: '\u62d2\u7edd',
|
||||
approval_btn_deny_title: '\u62d2\u7edd — \u4e0d\u57f7\u884c\u6b64\u547d\u4ee4',
|
||||
approval_responding: '\u8655\u7406\u4e2d\u2026',
|
||||
|
||||
@@ -526,7 +526,7 @@
|
||||
<div class="settings-section-title">System</div>
|
||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||
</div>
|
||||
<span class="settings-version-badge">v0.50.6</span>
|
||||
<span class="settings-version-badge">v0.50.12</span>
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
|
||||
@@ -112,6 +112,61 @@ function _renderOnboardingBody(){
|
||||
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
|
||||
const showBaseUrl=provider&&provider.requires_base_url;
|
||||
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
|
||||
|
||||
// OAuth provider path: configured via CLI, no API key input needed.
|
||||
const currentIsOauth=!!(ONBOARDING.status.setup||{}).current_is_oauth;
|
||||
const currentProviderName=((ONBOARDING.status.setup||{}).current||{}).provider||'';
|
||||
if(currentIsOauth){
|
||||
const isReady=!!(ONBOARDING.status.system||{}).chat_ready;
|
||||
const providerLabel=esc(currentProviderName);
|
||||
if(isReady){
|
||||
_setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success');
|
||||
body.innerHTML=`
|
||||
<div class="onboarding-oauth-card onboarding-oauth-ready">
|
||||
<div class="onboarding-oauth-icon">✓</div>
|
||||
<div>
|
||||
<strong>${t('onboarding_oauth_provider_ready_title')}</strong>
|
||||
<p>${t('onboarding_oauth_provider_ready_body').replace('{provider}',providerLabel)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||
</label>
|
||||
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||
</label>
|
||||
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||
<p class="onboarding-copy">${keyHelp}</p>`;
|
||||
} else {
|
||||
_setOnboardingNotice(t('onboarding_notice_setup_required'),'warn');
|
||||
body.innerHTML=`
|
||||
<div class="onboarding-oauth-card onboarding-oauth-pending">
|
||||
<div class="onboarding-oauth-icon">⚠</div>
|
||||
<div>
|
||||
<strong>${t('onboarding_oauth_provider_not_ready_title')}</strong>
|
||||
<p>${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||
</label>
|
||||
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||
</label>
|
||||
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||
<p class="onboarding-copy">${keyHelp}</p>`;
|
||||
}
|
||||
const providerSel=$('onboardingProviderSelect');
|
||||
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||
return;
|
||||
}
|
||||
|
||||
_setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info');
|
||||
body.innerHTML=`
|
||||
<label class="onboarding-field">
|
||||
|
||||
@@ -66,7 +66,9 @@
|
||||
:root[data-theme="light"] .panel-icon-btn:hover{background:rgba(0,0,0,.06);}
|
||||
:root[data-theme="light"] .file-item:hover{background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .preview-md th{background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .msg-body th{background:rgba(0,0,0,.04);}
|
||||
:root[data-theme="light"] .preview-md td{border-color:rgba(0,0,0,.08);}
|
||||
:root[data-theme="light"] .msg-body td{border-color:rgba(0,0,0,.08);}
|
||||
:root[data-theme="light"] .preview-badge.code{background:rgba(0,0,0,.05);}
|
||||
:root[data-theme="light"] .ctx-ring-center{background:var(--bg);color:#5a544a;}
|
||||
:root[data-theme="light"] .ctx-ring-track{stroke:rgba(0,0,0,.12);}
|
||||
@@ -214,6 +216,15 @@
|
||||
.onboarding-summary div{padding:14px;border-radius:14px;background:rgba(255,255,255,.03);border:1px solid var(--border);display:flex;flex-direction:column;gap:5px;}
|
||||
.onboarding-summary strong{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);}
|
||||
.onboarding-summary span{font-size:13px;color:var(--text);word-break:break-word;}
|
||||
.onboarding-oauth-card{display:flex;align-items:flex-start;gap:14px;padding:16px 18px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.03);margin-bottom:4px;}
|
||||
.onboarding-oauth-card p{margin:6px 0 0;font-size:13px;color:var(--muted);line-height:1.5;}
|
||||
.onboarding-oauth-card strong{font-size:13px;color:var(--text);}
|
||||
.onboarding-oauth-card code{font-size:12px;background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;}
|
||||
.onboarding-oauth-icon{font-size:18px;flex-shrink:0;margin-top:1px;}
|
||||
.onboarding-oauth-ready{border-color:rgba(124,185,255,.28);background:rgba(124,185,255,.08);}
|
||||
.onboarding-oauth-ready .onboarding-oauth-icon{color:#7cb9ff;}
|
||||
.onboarding-oauth-pending{border-color:rgba(201,168,76,.25);background:rgba(201,168,76,.08);}
|
||||
.onboarding-oauth-pending .onboarding-oauth-icon{color:#c9a84c;}
|
||||
.onboarding-actions{display:flex;justify-content:space-between;gap:10px;margin-top:auto;}
|
||||
.onboarding-actions .sm-btn{padding:10px 16px;}
|
||||
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
||||
@@ -360,6 +371,10 @@
|
||||
.msg-body blockquote{border-left:3px solid var(--blue);padding-left:14px;color:var(--muted);font-style:italic;margin:10px 0;}
|
||||
.msg-body a{color:var(--blue);text-decoration:underline;}
|
||||
.msg-body hr{border:none;border-top:1px solid var(--border);margin:14px 0;}
|
||||
.msg-body table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px;}
|
||||
.msg-body th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
|
||||
.msg-body td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);}
|
||||
.msg-body tr:nth-child(even){background:rgba(255,255,255,.03);}
|
||||
.msg-files{display:flex;flex-wrap:wrap;gap:6px;padding-left:30px;margin-bottom:10px;}
|
||||
.msg-file-badge{display:flex;align-items:center;gap:5px;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.25);border-radius:6px;padding:4px 9px;font-size:12px;color:var(--blue);}
|
||||
.thinking{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:13px;padding-left:30px;}
|
||||
@@ -457,6 +472,7 @@
|
||||
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
|
||||
.git-badge.dirty{color:var(--gold);background:rgba(201,168,76,.1);}
|
||||
.panel-actions{display:flex;gap:4px;}
|
||||
.mobile-close-btn{display:none;}
|
||||
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
|
||||
.panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||
.panel-icon-btn:disabled{opacity:.35;cursor:not-allowed;}
|
||||
@@ -524,7 +540,12 @@
|
||||
.layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
|
||||
}
|
||||
|
||||
@media(max-width:900px){.rightpanel{display:none}.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}}
|
||||
@media(max-width:900px){
|
||||
.rightpanel{display:none}
|
||||
.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}
|
||||
.mobile-close-btn{display:flex;}
|
||||
#btnCollapseWorkspacePanel{display:none;}
|
||||
}
|
||||
|
||||
@media(max-width:640px){
|
||||
/* ── Sidebar: slide-in overlay instead of hidden ── */
|
||||
|
||||
@@ -331,6 +331,7 @@ function renderMd(raw){
|
||||
t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`<em>${esc(x)}</em>`);
|
||||
t=t.replace(/`([^`\n]+)`/g,(_,x)=>`<code>${esc(x)}</code>`);
|
||||
t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>`<a href="${esc(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);
|
||||
t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `<a href="${esc(clean)}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;});
|
||||
// Escape any plain text that isn't already wrapped in a tag we produced
|
||||
// by escaping bare < > that aren't part of our own tags
|
||||
const SAFE_INLINE=/^<\/?(strong|em|code|a)([\s>]|$)/i;
|
||||
@@ -383,6 +384,13 @@ function renderMd(raw){
|
||||
// <div class="..."> (mermaid/pre-header). Everything else is untrusted input.
|
||||
const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div)([\s>]|$)/i;
|
||||
s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));
|
||||
// Autolink: convert plain URLs to clickable links (not inside existing <a> tags, not in code)
|
||||
s=s.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{
|
||||
// Strip trailing punctuation that was likely not part of the URL
|
||||
const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';
|
||||
const clean=trail?url.slice(0,-1):url;
|
||||
return `<a href="${esc(clean)}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;
|
||||
});
|
||||
const parts=s.split(/\n{2,}/);
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
return s;
|
||||
|
||||
34
tests/test_issue341.py
Normal file
34
tests/test_issue341.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Tests for GitHub issue #341: .msg-body table CSS styles."""
|
||||
import os
|
||||
|
||||
CSS_PATH = os.path.join(os.path.dirname(__file__), "..", "static", "style.css")
|
||||
|
||||
|
||||
def _read_css():
|
||||
with open(CSS_PATH, "r") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_msg_body_table_css_present():
|
||||
css = _read_css()
|
||||
assert ".msg-body table" in css, ".msg-body table rule missing from style.css"
|
||||
assert "border-collapse:collapse" in css, "border-collapse:collapse missing from style.css"
|
||||
|
||||
|
||||
def test_msg_body_table_th_td_present():
|
||||
css = _read_css()
|
||||
assert ".msg-body th" in css, ".msg-body th rule missing from style.css"
|
||||
assert ".msg-body td" in css, ".msg-body td rule missing from style.css"
|
||||
|
||||
|
||||
def test_msg_body_table_tr_stripe_present():
|
||||
css = _read_css()
|
||||
assert ".msg-body tr:nth-child(even)" in css, ".msg-body tr:nth-child(even) rule missing from style.css"
|
||||
|
||||
|
||||
def test_msg_body_light_theme_overrides():
|
||||
css = _read_css()
|
||||
assert ':root[data-theme="light"] .msg-body th' in css, \
|
||||
'Light-theme override for .msg-body th missing from style.css'
|
||||
assert ':root[data-theme="light"] .msg-body td' in css, \
|
||||
'Light-theme override for .msg-body td missing from style.css'
|
||||
115
tests/test_issue342.py
Normal file
115
tests/test_issue342.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Tests for GitHub issue #342: auto-link plain URLs in chat messages.
|
||||
|
||||
These are structural tests that verify the fix is present in static/ui.js
|
||||
without requiring a running server or JavaScript engine.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
|
||||
|
||||
|
||||
def read_ui_js():
|
||||
with open(UI_JS, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_autolink_comment_present():
|
||||
"""The Autolink comment should be present in renderMd() to document the feature."""
|
||||
content = read_ui_js()
|
||||
assert 'Autolink: convert plain URLs' in content, (
|
||||
"Expected 'Autolink: convert plain URLs' comment not found in static/ui.js. "
|
||||
"Did the autolink pass get added?"
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_regex_in_rendermd():
|
||||
"""The autolink regex pattern (https?://) should appear in renderMd()."""
|
||||
content = read_ui_js()
|
||||
# Locate the renderMd function body
|
||||
rendermd_start = content.find('function renderMd(raw){')
|
||||
assert rendermd_start != -1, "renderMd function not found in ui.js"
|
||||
# Find the closing brace after renderMd (look for the autolink pattern within it)
|
||||
rendermd_body = content[rendermd_start:rendermd_start + 5000]
|
||||
assert 'https?:\\/\\/' in rendermd_body, (
|
||||
"Autolink regex (https?:\\/\\/) not found inside renderMd() body."
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_uses_esc_for_xss_safety():
|
||||
"""The autolink code must use esc() to escape URLs, preventing XSS."""
|
||||
content = read_ui_js()
|
||||
# Find the autolink section (between the SAFE_TAGS pass and paragraph wrap)
|
||||
autolink_idx = content.find('// Autolink: convert plain URLs')
|
||||
assert autolink_idx != -1, "Autolink comment not found in ui.js"
|
||||
# Extract the autolink block (next ~300 chars after the comment)
|
||||
autolink_block = content[autolink_idx:autolink_idx + 400]
|
||||
assert 'esc(clean)' in autolink_block, (
|
||||
"Autolink block should use esc(clean) for XSS-safe URL escaping, but it was not found."
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_in_inline_md():
|
||||
"""The autolink pass should also be present inside the inlineMd() helper."""
|
||||
content = read_ui_js()
|
||||
# Find inlineMd function
|
||||
inline_start = content.find('function inlineMd(t){')
|
||||
assert inline_start != -1, "inlineMd function not found in ui.js"
|
||||
# Find closing brace of inlineMd by looking for 'return t;' followed by '}'
|
||||
inline_end = content.find('return t;\n }', inline_start)
|
||||
assert inline_end != -1, "Could not locate end of inlineMd function"
|
||||
inline_body = content[inline_start:inline_end + 20]
|
||||
assert 'https?:\\/\\/' in inline_body, (
|
||||
"Autolink regex not found inside inlineMd() — plain URLs in list items "
|
||||
"and blockquotes won't be autolinked."
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_after_safe_tags_pass():
|
||||
"""The autolink pass must come AFTER the SAFE_TAGS escape pass (ordering matters)."""
|
||||
content = read_ui_js()
|
||||
safe_tags_idx = content.find('s=s.replace(/<\\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));')
|
||||
autolink_idx = content.find('// Autolink: convert plain URLs')
|
||||
parts_idx = content.find('const parts=s.split(/\\n{2,}/);')
|
||||
assert safe_tags_idx != -1, "SAFE_TAGS pass not found"
|
||||
assert autolink_idx != -1, "Autolink pass not found"
|
||||
assert parts_idx != -1, "Paragraph-wrap parts line not found"
|
||||
assert safe_tags_idx < autolink_idx < parts_idx, (
|
||||
f"Ordering wrong: SAFE_TAGS at {safe_tags_idx}, autolink at {autolink_idx}, "
|
||||
f"parts (paragraph wrap) at {parts_idx}. "
|
||||
"Autolink must come between SAFE_TAGS pass and paragraph wrap."
|
||||
)
|
||||
|
||||
|
||||
def test_autolink_target_blank_and_rel():
|
||||
"""Autolinked URLs should open in a new tab with rel=noopener for security."""
|
||||
content = read_ui_js()
|
||||
autolink_idx = content.find('// Autolink: convert plain URLs')
|
||||
assert autolink_idx != -1, "Autolink comment not found"
|
||||
autolink_block = content[autolink_idx:autolink_idx + 400]
|
||||
assert 'target="_blank"' in autolink_block, (
|
||||
"Autolinked URLs should have target=\"_blank\""
|
||||
)
|
||||
assert 'rel="noopener"' in autolink_block, (
|
||||
"Autolinked URLs should have rel=\"noopener\" for security"
|
||||
)
|
||||
|
||||
|
||||
def test_safe_tags_includes_anchor():
|
||||
"""SAFE_TAGS regex must include 'a' so <a> tags from autolink are not escaped."""
|
||||
content = read_ui_js()
|
||||
# Find the SAFE_TAGS definition line — the pattern contains slashes so we
|
||||
# search for the line directly rather than extracting the regex literal.
|
||||
safe_tags_line = None
|
||||
for line in content.splitlines():
|
||||
if 'const SAFE_TAGS=' in line:
|
||||
safe_tags_line = line
|
||||
break
|
||||
assert safe_tags_line is not None, "SAFE_TAGS const definition not found in ui.js"
|
||||
# The pattern should include 'a' as a tag alternative (e.g. |a|)
|
||||
assert '|a|' in safe_tags_line or '|a)' in safe_tags_line, (
|
||||
f"SAFE_TAGS line does not include 'a' tag — "
|
||||
"<a> tags emitted by autolink would be escaped!\n"
|
||||
f"Line: {safe_tags_line}"
|
||||
)
|
||||
67
tests/test_profile_env_isolation.py
Normal file
67
tests/test_profile_env_isolation.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_profile_switch_clears_previous_profile_env_vars(monkeypatch, tmp_path):
|
||||
base = tmp_path / ".hermes"
|
||||
(base / "profiles" / "p1").mkdir(parents=True)
|
||||
(base / "profiles" / "p2").mkdir(parents=True)
|
||||
(base / "profiles" / "p1" / ".env").write_text(
|
||||
"OPENAI_API_KEY=secret-from-p1\nCUSTOM_TOKEN=token-from-p1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_BASE_HOME", str(base))
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("CUSTOM_TOKEN", raising=False)
|
||||
|
||||
sys.modules.pop("api.profiles", None)
|
||||
profiles = importlib.import_module("api.profiles")
|
||||
profiles = importlib.reload(profiles)
|
||||
|
||||
profiles.init_profile_state()
|
||||
profiles.switch_profile("p1")
|
||||
assert os.environ.get("OPENAI_API_KEY") == "secret-from-p1"
|
||||
assert os.environ.get("CUSTOM_TOKEN") == "token-from-p1"
|
||||
|
||||
profiles.switch_profile("p2")
|
||||
assert os.environ.get("OPENAI_API_KEY") is None
|
||||
assert os.environ.get("CUSTOM_TOKEN") is None
|
||||
assert profiles.get_active_profile_name() == "p2"
|
||||
|
||||
|
||||
def test_profile_switch_replaces_overlapping_keys(monkeypatch, tmp_path):
|
||||
base = tmp_path / ".hermes"
|
||||
(base / "profiles" / "p1").mkdir(parents=True)
|
||||
(base / "profiles" / "p2").mkdir(parents=True)
|
||||
(base / "profiles" / "p1" / ".env").write_text(
|
||||
"OPENAI_API_KEY=secret-from-p1\nONLY_P1=one\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(base / "profiles" / "p2" / ".env").write_text(
|
||||
"OPENAI_API_KEY=secret-from-p2\nONLY_P2=two\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_BASE_HOME", str(base))
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ONLY_P1", raising=False)
|
||||
monkeypatch.delenv("ONLY_P2", raising=False)
|
||||
|
||||
sys.modules.pop("api.profiles", None)
|
||||
profiles = importlib.import_module("api.profiles")
|
||||
profiles = importlib.reload(profiles)
|
||||
|
||||
profiles.init_profile_state()
|
||||
profiles.switch_profile("p1")
|
||||
assert os.environ.get("OPENAI_API_KEY") == "secret-from-p1"
|
||||
assert os.environ.get("ONLY_P1") == "one"
|
||||
|
||||
profiles.switch_profile("p2")
|
||||
assert os.environ.get("OPENAI_API_KEY") == "secret-from-p2"
|
||||
assert os.environ.get("ONLY_P1") is None
|
||||
assert os.environ.get("ONLY_P2") == "two"
|
||||
@@ -232,7 +232,7 @@ class TestOnboardingStatusApiOAuth:
|
||||
|
||||
def test_control_center_resets_active_section_on_close():
|
||||
"""Closing the control center must reset _settingsSection to 'conversation'."""
|
||||
src = open('static/panels.js').read()
|
||||
src = open(pathlib.Path(__file__).parent.parent / 'static' / 'panels.js').read()
|
||||
assert '_settingsSection' in src, '_settingsSection state variable missing from panels.js'
|
||||
assert "_settingsSection = 'conversation'" in src or "_settingsSection='conversation'" in src, \
|
||||
'Control center does not reset section to conversation on close'
|
||||
@@ -240,7 +240,7 @@ def test_control_center_resets_active_section_on_close():
|
||||
|
||||
def test_control_center_tab_highlight_on_open():
|
||||
"""Opening the control center must use settings-tabs for section navigation."""
|
||||
css = open('static/style.css').read()
|
||||
css = open(pathlib.Path(__file__).parent.parent / 'static' / 'style.css').read()
|
||||
assert 'settings-tabs' in css, 'settings-tabs CSS class for control center tabs missing from style.css'
|
||||
|
||||
|
||||
|
||||
162
tests/test_sprint40.py
Normal file
162
tests/test_sprint40.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Sprint 40 Tests: OAuth provider onboarding path (PR B of issue #329).
|
||||
|
||||
Covers:
|
||||
- _build_setup_catalog sets current_is_oauth=True for OAuth providers
|
||||
- _build_setup_catalog sets current_is_oauth=False for API-key providers
|
||||
- _build_setup_catalog sets current_is_oauth=False when no provider configured
|
||||
- apply_onboarding_setup with unsupported provider marks onboarding complete directly
|
||||
- i18n.js contains all required OAuth onboarding keys in both English and Spanish
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import api.onboarding as mod
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text()
|
||||
ONBOARDING_JS = (REPO_ROOT / "static" / "onboarding.js").read_text()
|
||||
|
||||
|
||||
# ── Backend: _build_setup_catalog ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSetupCatalog(unittest.TestCase):
|
||||
|
||||
def _catalog(self, provider, model="gpt-4o", base_url=""):
|
||||
cfg = {}
|
||||
if provider:
|
||||
cfg = {"model": {"provider": provider, "default": model, "base_url": base_url}}
|
||||
with patch.object(mod, "get_config", return_value=cfg):
|
||||
return mod._build_setup_catalog(cfg)
|
||||
|
||||
def test_oauth_provider_sets_current_is_oauth_true(self):
|
||||
"""openai-codex is not in _SUPPORTED_PROVIDER_SETUPS → current_is_oauth=True."""
|
||||
catalog = self._catalog("openai-codex", "gpt-5.4")
|
||||
self.assertTrue(catalog["current_is_oauth"],
|
||||
"current_is_oauth must be True for openai-codex")
|
||||
|
||||
def test_copilot_provider_sets_current_is_oauth_true(self):
|
||||
"""copilot is also OAuth."""
|
||||
catalog = self._catalog("copilot")
|
||||
self.assertTrue(catalog["current_is_oauth"])
|
||||
|
||||
def test_openai_provider_sets_current_is_oauth_false(self):
|
||||
"""openai is in _SUPPORTED_PROVIDER_SETUPS → current_is_oauth=False."""
|
||||
catalog = self._catalog("openai", "gpt-4o")
|
||||
self.assertFalse(catalog["current_is_oauth"],
|
||||
"current_is_oauth must be False for API-key provider openai")
|
||||
|
||||
def test_anthropic_provider_sets_current_is_oauth_false(self):
|
||||
catalog = self._catalog("anthropic", "claude-sonnet-4.6")
|
||||
self.assertFalse(catalog["current_is_oauth"])
|
||||
|
||||
def test_no_provider_sets_current_is_oauth_false(self):
|
||||
"""Empty config → current_is_oauth=False."""
|
||||
catalog = self._catalog("")
|
||||
self.assertFalse(catalog["current_is_oauth"])
|
||||
|
||||
def test_catalog_includes_current_is_oauth_key(self):
|
||||
"""current_is_oauth must always be present in the catalog dict."""
|
||||
catalog = self._catalog("openrouter")
|
||||
self.assertIn("current_is_oauth", catalog)
|
||||
|
||||
|
||||
# ── Backend: apply_onboarding_setup for OAuth providers ────────────────────
|
||||
|
||||
|
||||
class TestApplyOnboardingOAuthPath(unittest.TestCase):
|
||||
|
||||
def test_unsupported_provider_skips_to_complete(self):
|
||||
"""apply_onboarding_setup with an OAuth provider just marks onboarding done."""
|
||||
saved = {}
|
||||
|
||||
def _save(d):
|
||||
saved.update(d)
|
||||
|
||||
mock_status = {"completed": True, "system": {"chat_ready": True}}
|
||||
|
||||
with patch.object(mod, "save_settings", side_effect=_save), \
|
||||
patch.object(mod, "get_onboarding_status", return_value=mock_status):
|
||||
result = mod.apply_onboarding_setup({"provider": "openai-codex", "model": "gpt-5.4"})
|
||||
|
||||
self.assertTrue(saved.get("onboarding_completed"),
|
||||
"save_settings must set onboarding_completed=True for OAuth provider")
|
||||
self.assertEqual(result, mock_status)
|
||||
|
||||
def test_unsupported_provider_does_not_write_config_yaml(self):
|
||||
"""OAuth path must not call _save_yaml_config — no config mutation."""
|
||||
with patch.object(mod, "save_settings"), \
|
||||
patch.object(mod, "get_onboarding_status", return_value={}), \
|
||||
patch.object(mod, "_save_yaml_config") as mock_save_yaml:
|
||||
mod.apply_onboarding_setup({"provider": "copilot", "model": "gpt-4o"})
|
||||
|
||||
mock_save_yaml.assert_not_called()
|
||||
|
||||
|
||||
# ── Frontend: i18n keys ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_REQUIRED_OAUTH_KEYS = [
|
||||
"onboarding_oauth_provider_ready_title",
|
||||
"onboarding_oauth_provider_ready_body",
|
||||
"onboarding_oauth_provider_not_ready_title",
|
||||
"onboarding_oauth_provider_not_ready_body",
|
||||
"onboarding_oauth_switch_hint",
|
||||
]
|
||||
|
||||
|
||||
class TestOAuthI18nKeys(unittest.TestCase):
|
||||
|
||||
def test_english_locale_has_all_oauth_keys(self):
|
||||
"""All OAuth onboarding i18n keys must be present in the English locale."""
|
||||
missing = [k for k in _REQUIRED_OAUTH_KEYS if k not in I18N_JS]
|
||||
self.assertFalse(missing,
|
||||
f"English locale missing OAuth keys: {missing}")
|
||||
|
||||
def test_spanish_locale_has_all_oauth_keys(self):
|
||||
"""All OAuth onboarding i18n keys must be present in the Spanish locale."""
|
||||
# Spanish locale is the second occurrence of each key
|
||||
counts = {k: I18N_JS.count(k) for k in _REQUIRED_OAUTH_KEYS}
|
||||
under = [k for k, c in counts.items() if c < 2]
|
||||
self.assertFalse(under,
|
||||
f"Spanish locale missing OAuth keys (need 2 occurrences each): {under}")
|
||||
|
||||
def test_oauth_body_strings_contain_provider_placeholder(self):
|
||||
"""Body strings must contain {provider} so JS can substitute the provider name."""
|
||||
for key in ["onboarding_oauth_provider_ready_body",
|
||||
"onboarding_oauth_provider_not_ready_body"]:
|
||||
self.assertIn("{provider}", I18N_JS,
|
||||
f"{key} must contain {{provider}} placeholder")
|
||||
|
||||
|
||||
# ── Frontend: onboarding.js uses current_is_oauth ─────────────────────────
|
||||
|
||||
|
||||
class TestOAuthOnboardingJs(unittest.TestCase):
|
||||
|
||||
def test_onboarding_js_reads_current_is_oauth(self):
|
||||
"""onboarding.js must check current_is_oauth from the status payload."""
|
||||
self.assertIn("current_is_oauth", ONBOARDING_JS,
|
||||
"onboarding.js must read current_is_oauth from ONBOARDING.status.setup")
|
||||
|
||||
def test_onboarding_js_renders_oauth_ready_card(self):
|
||||
"""onboarding.js must render the oauth-ready card class."""
|
||||
self.assertIn("onboarding-oauth-ready", ONBOARDING_JS)
|
||||
|
||||
def test_onboarding_js_renders_oauth_pending_card(self):
|
||||
"""onboarding.js must render the oauth-pending card class."""
|
||||
self.assertIn("onboarding-oauth-pending", ONBOARDING_JS)
|
||||
|
||||
def test_style_css_has_oauth_card_rules(self):
|
||||
"""style.css must contain the .onboarding-oauth-card rules."""
|
||||
css = (REPO_ROOT / "static" / "style.css").read_text()
|
||||
self.assertIn("onboarding-oauth-card", css)
|
||||
self.assertIn("onboarding-oauth-ready", css)
|
||||
self.assertIn("onboarding-oauth-pending", css)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
129
tests/test_sprint41.py
Normal file
129
tests/test_sprint41.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Sprint 41 Tests: Title auto-generation fix + mobile close button CSS (PR #333).
|
||||
|
||||
Covers:
|
||||
- streaming.py: sessions titled 'New Chat' trigger auto-title generation
|
||||
- streaming.py: sessions with empty/falsy title trigger auto-title generation
|
||||
- streaming.py: sessions titled 'Untitled' (original guard) still trigger
|
||||
- streaming.py: sessions with a user-set title do NOT trigger auto-title
|
||||
- style.css: .mobile-close-btn is hidden by default (desktop rule present)
|
||||
- style.css: .mobile-close-btn shown in <=900px media query
|
||||
- style.css: #btnCollapseWorkspacePanel hidden in <=900px media query
|
||||
- index.html: both .mobile-close-btn and #btnCollapseWorkspacePanel buttons exist
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
import unittest
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
CSS = (REPO_ROOT / "static" / "style.css").read_text()
|
||||
HTML = (REPO_ROOT / "static" / "index.html").read_text()
|
||||
STREAMING_PY = (REPO_ROOT / "api" / "streaming.py").read_text()
|
||||
|
||||
|
||||
# ── streaming.py: title auto-generation condition ─────────────────────────
|
||||
|
||||
class TestTitleAutoGenerationCondition(unittest.TestCase):
|
||||
"""Verify the guarded condition in streaming.py covers all default title cases."""
|
||||
|
||||
def _titles_that_trigger(self):
|
||||
"""Extract the condition from the source so tests stay in sync with code."""
|
||||
# Find the if-condition that calls title_from
|
||||
m = re.search(
|
||||
r'if\s+(s\.title\s*==.*?):\s*\n\s*s\.title\s*=\s*title_from',
|
||||
STREAMING_PY,
|
||||
re.DOTALL,
|
||||
)
|
||||
self.assertIsNotNone(m, "Could not find title auto-generation condition in streaming.py")
|
||||
return m.group(1)
|
||||
|
||||
def test_untitled_in_condition(self):
|
||||
cond = self._titles_that_trigger()
|
||||
self.assertIn("'Untitled'", cond, "Original 'Untitled' guard must be present")
|
||||
|
||||
def test_new_chat_in_condition(self):
|
||||
cond = self._titles_that_trigger()
|
||||
self.assertIn("'New Chat'", cond, "'New Chat' guard must be present (PR #333)")
|
||||
|
||||
def test_empty_title_guard_in_condition(self):
|
||||
cond = self._titles_that_trigger()
|
||||
self.assertIn("not s.title", cond, "Empty/falsy title guard must be present (PR #333)")
|
||||
|
||||
def test_condition_logic_covers_all_defaults(self):
|
||||
"""The condition uses OR so any one default title triggers generation."""
|
||||
cond = self._titles_that_trigger()
|
||||
# All three guards must be joined by 'or'
|
||||
parts = re.split(r'\bor\b', cond)
|
||||
self.assertGreaterEqual(len(parts), 3,
|
||||
"Expected at least 3 OR-joined sub-conditions (Untitled, New Chat, not s.title)")
|
||||
|
||||
|
||||
# ── style.css: mobile close button visibility ─────────────────────────────
|
||||
|
||||
class TestMobileCloseButtonCSS(unittest.TestCase):
|
||||
"""Verify CSS rules that control the duplicate close button on mobile."""
|
||||
|
||||
def test_mobile_close_btn_hidden_by_default(self):
|
||||
"""Desktop default: .mobile-close-btn must be display:none outside any media query."""
|
||||
# Find the rule before the first @media block that contains mobile-close-btn
|
||||
# We look for the pattern in the desktop (non-media-query) section
|
||||
self.assertIn(
|
||||
".mobile-close-btn{display:none;}",
|
||||
CSS.replace(" ", ""),
|
||||
".mobile-close-btn should be hidden by default (desktop) — rule missing or wrong"
|
||||
)
|
||||
|
||||
def test_mobile_close_btn_shown_in_900px_query(self):
|
||||
"""Inside max-width:900px media query, .mobile-close-btn must be display:flex."""
|
||||
# Extract the 900px media block
|
||||
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
|
||||
CSS)
|
||||
self.assertIsNotNone(m, "@media(max-width:900px) block not found in style.css")
|
||||
block = m.group(1).replace(" ", "")
|
||||
self.assertIn(".mobile-close-btn{display:flex;}",
|
||||
block,
|
||||
".mobile-close-btn must be display:flex inside the 900px media query")
|
||||
|
||||
def test_desktop_collapse_btn_hidden_in_900px_query(self):
|
||||
"""Inside max-width:900px media query, #btnCollapseWorkspacePanel must be display:none."""
|
||||
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
|
||||
CSS)
|
||||
self.assertIsNotNone(m, "@media(max-width:900px) block not found in style.css")
|
||||
block = m.group(1).replace(" ", "")
|
||||
self.assertIn("#btnCollapseWorkspacePanel{display:none;}",
|
||||
block,
|
||||
"#btnCollapseWorkspacePanel must be display:none in 900px media query")
|
||||
|
||||
def test_900px_query_retains_existing_rules(self):
|
||||
"""Ensure the PR didn't accidentally drop existing rules from the 900px block."""
|
||||
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
|
||||
CSS)
|
||||
self.assertIsNotNone(m)
|
||||
block = m.group(1)
|
||||
self.assertIn("rightpanel", block, ".rightpanel rule missing from 900px block")
|
||||
self.assertIn("mobile-files-btn", block, ".mobile-files-btn rule missing from 900px block")
|
||||
|
||||
|
||||
# ── index.html: button presence ───────────────────────────────────────────
|
||||
|
||||
class TestWorkspacePanelButtons(unittest.TestCase):
|
||||
"""Verify both panel buttons are present in the HTML so CSS rules have targets."""
|
||||
|
||||
def test_desktop_collapse_button_exists(self):
|
||||
self.assertIn("btnCollapseWorkspacePanel", HTML,
|
||||
"#btnCollapseWorkspacePanel button must exist in index.html")
|
||||
|
||||
def test_mobile_close_button_exists(self):
|
||||
self.assertIn("mobile-close-btn", HTML,
|
||||
".mobile-close-btn button must exist in index.html")
|
||||
|
||||
def test_mobile_close_button_has_aria_label(self):
|
||||
"""Accessibility: mobile close button must have an aria-label."""
|
||||
m = re.search(r'class="[^"]*mobile-close-btn[^"]*"[^>]*>', HTML)
|
||||
self.assertIsNotNone(m, "Could not find mobile-close-btn element")
|
||||
self.assertIn("aria-label", m.group(0),
|
||||
"mobile-close-btn must have aria-label for accessibility")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user