v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
|
|
|
"""
|
|
|
|
|
Sprint 33 Tests: Shared app dialogs replace native confirm/prompt usage.
|
|
|
|
|
|
|
|
|
|
These tests verify the static assets expose the reusable confirm/input modal
|
|
|
|
|
and that browser-native confirm/prompt calls are no longer used in the Web UI.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import pathlib
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
REPO = pathlib.Path(__file__).parent.parent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read(path):
|
|
|
|
|
return (REPO / path).read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_index_has_shared_app_dialog_markup():
|
|
|
|
|
html = read("static/index.html")
|
|
|
|
|
assert 'id="appDialogOverlay"' in html
|
|
|
|
|
assert 'id="appDialog"' in html
|
|
|
|
|
assert 'id="appDialogTitle"' in html
|
|
|
|
|
assert 'id="appDialogDesc"' in html
|
|
|
|
|
assert 'id="appDialogInput"' in html
|
|
|
|
|
assert 'id="appDialogCancel"' in html
|
|
|
|
|
assert 'id="appDialogConfirm"' in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_app_dialog_css_rules_exist():
|
|
|
|
|
css = read("static/style.css")
|
|
|
|
|
for selector in (
|
|
|
|
|
".app-dialog-overlay",
|
|
|
|
|
".app-dialog",
|
|
|
|
|
".app-dialog-input",
|
|
|
|
|
".app-dialog-actions",
|
|
|
|
|
".app-dialog-btn.confirm",
|
|
|
|
|
".app-dialog-btn.confirm.danger",
|
|
|
|
|
):
|
|
|
|
|
assert selector in css, f"missing CSS selector: {selector}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ui_js_exposes_shared_dialog_helpers():
|
|
|
|
|
src = read("static/ui.js")
|
|
|
|
|
assert "function showConfirmDialog(opts={})" in src
|
|
|
|
|
assert "function showPromptDialog(opts={})" in src
|
|
|
|
|
assert "document.addEventListener('keydown'" in src
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 02:26:20 +00:00
|
|
|
def test_prompt_dialog_honors_custom_label_and_danger_state():
|
|
|
|
|
src = read("static/ui.js")
|
|
|
|
|
assert "confirmBtn.textContent=opts.confirmLabel||t('create')" in src
|
|
|
|
|
assert "confirmBtn.classList.toggle('danger',!!opts.danger)" in src
|
|
|
|
|
assert "opts.danger?'alertdialog':'dialog'" in src
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disable_auth_prompt_uses_destructive_label_not_create():
|
|
|
|
|
src = read("static/panels.js")
|
|
|
|
|
match = re.search(r"async function disableAuth\(\)\{(.*?)\n\}", src, re.DOTALL)
|
|
|
|
|
assert match, "disableAuth() not found"
|
|
|
|
|
body = match.group(1)
|
|
|
|
|
assert "const confirmText='DISABLE AUTH'" in body
|
|
|
|
|
assert "currentPwField=$('settingsCurrentPassword')" in body
|
|
|
|
|
assert "showToast(t('current_password_required'))" in body
|
|
|
|
|
assert body.index("showToast(t('current_password_required'))") < body.index("showPromptDialog")
|
|
|
|
|
assert "showPromptDialog" in body
|
|
|
|
|
assert "confirmLabel:t('disable_auth')" in body
|
|
|
|
|
assert "danger:true" in body
|
|
|
|
|
assert "t('create')" not in body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_auth_disabled_warning_uses_status_payload_without_extra_settings_fetch():
|
|
|
|
|
src = read("static/panels.js")
|
|
|
|
|
match = re.search(r"function _updateAuthDisabledWarning\(authStatus\)\{(.*?)\n\}", src, re.DOTALL)
|
|
|
|
|
assert match, "_updateAuthDisabledWarning(authStatus) not found"
|
|
|
|
|
body = match.group(1)
|
|
|
|
|
assert "authStatus&&authStatus.auth_disabled_acknowledged" in body
|
|
|
|
|
assert "api('/api/settings')" not in body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_acknowledgement_save_failure_uses_i18n_toast():
|
|
|
|
|
src = read("static/panels.js")
|
|
|
|
|
match = re.search(r"async function _setAuthDisabledAck\(checked\)\{(.*?)\n\}", src, re.DOTALL)
|
|
|
|
|
assert match, "_setAuthDisabledAck(checked) not found"
|
|
|
|
|
body = match.group(1)
|
|
|
|
|
assert "showToast(t('auth_ack_save_failed')+e.message)" in body
|
|
|
|
|
assert "Failed to update acknowledgement" not in body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_settings_password_change_preflights_current_password_before_api():
|
|
|
|
|
src = read("static/panels.js")
|
|
|
|
|
match = re.search(r"async function saveSettings\([^)]*\)\{(.*?)\n\}", src, re.DOTALL)
|
|
|
|
|
assert match, "saveSettings() not found"
|
|
|
|
|
body = match.group(1)
|
|
|
|
|
assert "currentPwField=$('settingsCurrentPassword')" in body
|
|
|
|
|
assert "showToast(t('current_password_required'))" in body
|
|
|
|
|
assert body.index("showToast(t('current_password_required'))") < body.index("api('/api/settings'")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disable_auth_typed_confirm_locales_show_literal_phrase():
|
|
|
|
|
src = read("static/i18n.js")
|
|
|
|
|
values = re.findall(r"disable_auth_typed_confirm:\s*'([^']*)'", src)
|
|
|
|
|
assert values, "disable_auth_typed_confirm keys missing"
|
|
|
|
|
assert len(values) == len(_i18n_locale_blocks(src)), (
|
|
|
|
|
"Every locale must define disable_auth_typed_confirm exactly once"
|
|
|
|
|
)
|
|
|
|
|
missing_literal = [value for value in values if "DISABLE AUTH" not in value]
|
|
|
|
|
assert not missing_literal, (
|
|
|
|
|
"Disable-auth prompt must display the exact phrase accepted by "
|
|
|
|
|
f"disableAuth(), but these translations do not: {missing_literal}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
AUTH_SAFETY_LOCALE_KEYS = (
|
|
|
|
|
"current_password_label",
|
|
|
|
|
"current_password_placeholder",
|
|
|
|
|
"current_password_required",
|
|
|
|
|
"current_password_incorrect",
|
|
|
|
|
"disable_auth_typed_confirm",
|
|
|
|
|
"auth_status_password",
|
|
|
|
|
"auth_status_passkey_only",
|
|
|
|
|
"auth_status_unauthenticated",
|
|
|
|
|
"auth_warning_badge",
|
|
|
|
|
"auth_disabled_warning_message",
|
|
|
|
|
"auth_acknowledged_label",
|
|
|
|
|
"auth_ack_save_failed",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _i18n_locale_blocks(src):
|
|
|
|
|
heads = list(re.finditer(r"^ (?:(?:'([^']+)')|([A-Za-z][A-Za-z0-9_]*)):\s*\{", src, re.M))
|
|
|
|
|
blocks = {}
|
|
|
|
|
for i, head in enumerate(heads):
|
|
|
|
|
locale = head.group(1) or head.group(2)
|
|
|
|
|
end = heads[i + 1].start() if i + 1 < len(heads) else src.find("\n};", head.end())
|
|
|
|
|
assert end != -1, f"could not find end of locale block {locale}"
|
|
|
|
|
blocks[locale] = src[head.end():end]
|
|
|
|
|
return blocks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_auth_safety_keys_exist_once_per_locale():
|
|
|
|
|
src = read("static/i18n.js")
|
|
|
|
|
blocks = _i18n_locale_blocks(src)
|
|
|
|
|
assert "pt" in blocks
|
|
|
|
|
assert "zh-Hant" in blocks
|
|
|
|
|
for locale, block in blocks.items():
|
|
|
|
|
missing = [key for key in AUTH_SAFETY_LOCALE_KEYS if f"{key}:" not in block]
|
|
|
|
|
duplicated = [
|
|
|
|
|
key for key in AUTH_SAFETY_LOCALE_KEYS
|
|
|
|
|
if len(re.findall(rf"\b{re.escape(key)}\s*:", block)) != 1
|
|
|
|
|
]
|
|
|
|
|
assert not missing, f"{locale} missing auth-safety locale keys: {missing}"
|
|
|
|
|
assert not duplicated, f"{locale} has duplicated auth-safety locale keys: {duplicated}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_auth_safety_pt_and_zh_hant_strings_stay_in_correct_locale_blocks():
|
|
|
|
|
src = read("static/i18n.js")
|
|
|
|
|
blocks = _i18n_locale_blocks(src)
|
|
|
|
|
zh_hant = blocks["zh-Hant"]
|
|
|
|
|
pt = blocks["pt"]
|
|
|
|
|
assert "目前密碼" in zh_hant
|
|
|
|
|
assert "輸入 DISABLE AUTH" in zh_hant
|
|
|
|
|
assert "Senha atual" not in zh_hant
|
|
|
|
|
assert "Digite DISABLE AUTH" not in zh_hant
|
|
|
|
|
assert "Senha atual" in pt
|
|
|
|
|
assert "Digite DISABLE AUTH" in pt
|
|
|
|
|
|
|
|
|
|
|
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
|
|
|
def test_no_native_confirm_calls_remain_in_static_js():
|
|
|
|
|
for path in (REPO / "static").glob("*.js"):
|
|
|
|
|
src = path.read_text(encoding="utf-8")
|
|
|
|
|
assert not re.search(r"\bconfirm\s*\(", src), f"native confirm() remains in {path.name}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_native_prompt_calls_remain_in_static_js():
|
|
|
|
|
for path in (REPO / "static").glob("*.js"):
|
|
|
|
|
src = path.read_text(encoding="utf-8")
|
|
|
|
|
assert not re.search(r"\bprompt\s*\(", src), f"native prompt() remains in {path.name}"
|