Merge pull request #2108 from nesquena/stage-340
Some checks failed
Release & Docker / release (push) Has been cancelled

Release V0.51.47 — stage-340 (4-PR contributor batch: Italian locale + cron toast toggle + stale-gateway fix + CI hygiene)
This commit is contained in:
nesquena-hermes
2026-05-11 16:44:07 -07:00
committed by GitHub
16 changed files with 1450 additions and 28 deletions

View File

@@ -24,7 +24,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pyyaml>=6.0 pytest pytest-timeout pytest-asyncio
pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio
# Install the `mcp` package so tests/test_mcp_server.py runs in CI.
# The package is an optional runtime dep of mcp_server.py — users
# who run the MCP integration install it themselves; CI installs

View File

@@ -2,6 +2,24 @@
## [Unreleased]
### Added
- **PR #2100** by @ai-ag2026 — Per-cron toast notification toggle. New `toast_notifications` boolean on cron job payloads (default-true for legacy preservation) wired through `_renderCronForm`, `_renderCronDetail`, `openCronCreate`, `openCronEdit`, `duplicateCurrentCron`, and `saveCronForm`. The polling loop in `startCronPolling()` gates `showToast(...)` on `c.toast_notifications !== false` so muted jobs still update the Tasks badge and new-run marker but skip the toast. Full i18n parity (8 locales: en/it/ja/ru/es/de/zh/pt/ko after PR #2067 lands) and 158-line regression suite in `tests/test_cron_toast_notifications.py`.
- **PR #2067** by @samuelgudi — Italian (`it`) locale. ~280 UI strings translated covering boot, messages, MCP, commands, goals, settings, sessions, kanban, panels, and the offline state. Inserted alphabetically (`en → it → ja`) in `static/i18n.js`'s `LOCALES` map and mirrored in the `LOGIN_LOCALES` server-rendered table in `api/routes.py`. Updated `TestComposerVoiceButtonI18n.LOCALES` to include `"it"`; sibling `TestVoiceModePreferenceGate` also gets the tuple so its newly-adaptive `len(self.LOCALES)` count assert resolves.
### Fixed
- **PR #2075** by @LumenYoung — Stale `gateway_state == "running"` runtime status is now reported as `alive: null` (unknown) instead of `alive: false` (refs #1879). In multi-container WebUI+gateway deployments the older gateway builds only refresh `gateway_state.json` on lifecycle changes, not every tick — so a stale `running` file means "WebUI cannot see the gateway" rather than "gateway is down". New `_runtime_status_is_stale_running()` helper sits in front of the existing `_runtime_status_is_stale_stopped()` branch in `build_agent_health_payload()` so the heartbeat banner no longer flips to a confirmed-outage state when the gateway is actually fine but PID-checking across containers is impossible. 52 LOC including the inversion of the matching assertion in `test_issue1879_cross_container_gateway_liveness.py`.
- **PR #2070** by @ai-ag2026 — CI and console-noise hygiene. (1) Quoted `"pyyaml>=6.0"` in `.github/workflows/tests.yml` install step so the shell stops parsing the unquoted `>` as stdout redirection. (2) Registered the `integration` pytest marker in a new `pytest.ini` to suppress collection-time warnings on tests that hit the live test server. (3) Lowered the live-model success diagnostic in `_fetchLiveModels()` from `console.log` to `console.debug` so model-fetch chatter no longer floods the default browser console. New `tests/test_ci_hygiene.py` (29 LOC) pins all three regressions.
### Stage-340 maintainer fixes
- **`tests/test_issue1488_composer_voice_buttons.py:TestVoiceModePreferenceGate`** — Defined `LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")` on the class. PR #2067 made `test_settings_pane_has_voice_mode_i18n_keys` count adaptive via `len(self.LOCALES)` but only defined `LOCALES` on the sibling `TestComposerVoiceButtonI18n`, so CI failed with `AttributeError`. Mirroring the tuple is the surgical fix; the alternative (back to a hard-coded `9`) would have rotted next time someone adds a locale. ~2 LOC.
## [v0.51.46] — 2026-05-11 — Release V (5-PR contributor batch — CSP report-only + logs panel polish + plugin slash commands + turn-journal crash-safe writer + lifecycle events)
## [v0.51.46] — 2026-05-11 — Release V (5-PR contributor batch — CSP report-only + logs panel polish + plugin slash commands + turn-journal crash-safe writer + lifecycle events)
### Added

View File

@@ -14,8 +14,10 @@ volume is shared, those checks always return ``None`` and the dashboard
incorrectly shows "Gateway not running". To stay accurate without forcing a
``pid: "service:hermes-agent"`` compose workaround, we accept a recent
``updated_at`` timestamp on ``gateway_state.json`` (combined with
``gateway_state == "running"``) as an equivalent live-process signal — the
gateway already writes that file on every tick.
``gateway_state == "running"``) as an equivalent live-process signal. Older
gateway builds do not refresh that file periodically, so a stale
``gateway_state == "running"`` record is treated as inconclusive rather than a
confirmed outage.
"""
from __future__ import annotations
@@ -126,6 +128,41 @@ def _runtime_status_is_stale_stopped(
return age_s > threshold_s
def _runtime_status_is_stale_running(
runtime_status: dict[str, Any] | None,
*,
now: datetime | None = None,
threshold_s: float = GATEWAY_FRESHNESS_THRESHOLD_S,
) -> bool:
"""Return ``True`` when the gateway last self-reported running, but stale.
WebUI often runs in a separate container from the gateway. In that shape PID
checks can be impossible, and older gateway versions only update
``gateway_state.json`` on lifecycle/platform changes. A stale ``running``
file therefore means "not enough information from WebUI" rather than
"gateway is down".
"""
if not isinstance(runtime_status, dict):
return False
if runtime_status.get("gateway_state") != "running":
return False
raw_updated_at = runtime_status.get("updated_at")
if not isinstance(raw_updated_at, str) or not raw_updated_at:
return False
try:
updated_at = datetime.fromisoformat(raw_updated_at)
except (TypeError, ValueError):
return False
if updated_at.tzinfo is None:
return False
reference = now if now is not None else datetime.now(timezone.utc)
age_s = (reference - updated_at).total_seconds()
return age_s > threshold_s
def _gateway_status_module():
"""Load gateway.status lazily so tests and WebUI-only installs stay isolated."""
return importlib.import_module("gateway.status")
@@ -309,6 +346,17 @@ def build_agent_health_payload() -> dict[str, Any]:
},
}
if _runtime_status_is_stale_running(runtime_status):
return {
"alive": None,
"checked_at": checked_at,
"details": {
"state": "unknown",
"reason": "gateway_stale_running_state",
**safe_details,
},
}
if isinstance(runtime_status, dict):
return {
"alive": False,

View File

@@ -460,14 +460,19 @@ def _cron_output_content_window(text: str, limit: int = _CRON_OUTPUT_CONTENT_LIM
def _cron_job_for_api(job: dict) -> dict:
"""Return a cron job payload with the #617 optional profile field present.
"""Return a cron job payload with optional UI settings normalized.
Legacy jobs intentionally persist without ``profile`` so they keep the
scheduler's server-default behavior. The API still returns ``profile: None``
so the UI can label that state explicitly instead of guessing.
``toast_notifications`` is a WebUI preference for completion toasts. Legacy
jobs default to enabled so existing behavior is preserved unless a job is
explicitly muted.
"""
payload = dict(job or {})
payload.setdefault("profile", None)
payload["toast_notifications"] = payload.get("toast_notifications") is not False
return payload
@@ -1939,6 +1944,15 @@ _LOGIN_LOCALE = {
# Strings mirror static/i18n.js login_* keys for the corresponding locale.
# See issue #1442. When adding a new locale to LOCALES in i18n.js, also add
# the matching entry here — tests/test_login_locale_parity.py enforces this.
"it": {
"lang": "it-IT",
"title": "Accedi",
"subtitle": "Inserisci la password per continuare",
"placeholder": "Password",
"btn": "Accedi",
"invalid_pw": "Password non valida",
"conn_failed": "Connessione fallita",
},
"ja": {
"lang": "ja-JP",
"title": "\u30b5\u30a4\u30f3\u30a4\u30f3",
@@ -6363,6 +6377,7 @@ def _handle_cron_recent(handler, parsed):
"name": job.get("name", "Unknown"),
"status": job.get("last_status", "unknown"),
"completed_at": ts,
"toast_notifications": job.get("toast_notifications") is not False,
}
)
return j(handler, {"completions": completions, "since": since})
@@ -7142,6 +7157,7 @@ def _handle_cron_create(handler, body):
from cron.jobs import create_job, update_job
profile = _normalize_cron_profile_value(body.get("profile"))
toast_notifications = body.get("toast_notifications") is not False
job = create_job(
prompt=body["prompt"],
schedule=body["schedule"],
@@ -7150,8 +7166,13 @@ def _handle_cron_create(handler, body):
skills=body.get("skills") or [],
model=body.get("model") or None,
)
post_create_updates = {}
if profile is not None:
job = update_job(job["id"], {"profile": profile}) or job
post_create_updates["profile"] = profile
if not toast_notifications:
post_create_updates["toast_notifications"] = False
if post_create_updates:
job = update_job(job["id"], post_create_updates) or job
return j(handler, {"ok": True, "job": _cron_job_for_api(job)})
except Exception as e:
return j(handler, {"error": str(e)}, status=400)

3
pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
markers =
integration: tests that hit the live test server or external integration surface

File diff suppressed because it is too large Load Diff

View File

@@ -487,6 +487,7 @@ function _renderCronDetail(job){
<button type="button" class="cron-btn" onclick="copyCurrentCronDiagnostics()">${esc(t('cron_attention_copy_diagnostics'))}</button>
</div>
</div>` : '';
const toastNotifications = job.toast_notifications !== false;
body.innerHTML = `
<div class="main-view-content">
${attentionBanner}
@@ -500,6 +501,7 @@ function _renderCronDetail(job){
<div class="detail-row"><div class="detail-row-label">Mode</div><div class="detail-row-value"><span class="detail-badge" id="cronJobMode">${esc(cronJobMode)}</span></div></div>
${isNoAgent ? `<div class="detail-row"><div class="detail-row-label">No-agent script</div><div class="detail-row-value"><code>${esc(script || '—')}</code></div></div>` : ''}
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_profile_label') || 'Profile')}</div><div class="detail-row-value"><span class="detail-badge active" title="${esc(profileTitle)}">${esc(profileLabel)}</span></div></div>
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_toast_notifications_label') || 'Completion toasts')}</div><div class="detail-row-value"><span class="detail-badge ${toastNotifications ? 'active' : ''}">${esc(toastNotifications ? (t('cron_toast_notifications_enabled') || 'Enabled') : (t('cron_toast_notifications_disabled') || 'Disabled'))}</span></div></div>
<div class="detail-row"><div class="detail-row-label">Skills</div><div class="detail-row-value">${esc(skills)}</div></div>
${lastError}
</div>
@@ -683,6 +685,7 @@ function duplicateCurrentCron(){
prompt: job.prompt || '',
deliver: job.deliver || 'local',
profile: job.profile || '',
toast_notifications: job.toast_notifications !== false,
isEdit: false,
});
if (!_cronSkillsCache) {
@@ -716,7 +719,7 @@ function openCronCreate(){
_cronMode = 'create';
_cronIsDuplicate = false;
_cronSelectedSkills = [];
_renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', profile:'', isEdit:false });
_renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', profile:'', toast_notifications:true, isEdit:false });
_cronSkillsCache = null;
api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{});
loadCronProfiles().then(()=>_refreshCronProfileSelect('')).catch(()=>{});
@@ -734,6 +737,7 @@ function openCronEdit(job){
prompt: job.prompt || '',
deliver: job.deliver || 'local',
profile: job.profile || '',
toast_notifications: job.toast_notifications !== false,
no_agent: !!job.no_agent,
script: job.script || '',
isEdit: true,
@@ -746,12 +750,13 @@ function openCronEdit(job){
loadCronProfiles().then(()=>_refreshCronProfileSelect(job.profile || '')).catch(()=>{});
}
function _renderCronForm({ name, schedule, prompt, deliver, profile, no_agent=false, script='', isEdit }){
function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notifications=true, no_agent=false, script='', isEdit }){
const title = $('taskDetailTitle');
const body = $('taskDetailBody');
const empty = $('taskDetailEmpty');
if (!body || !title) return;
const isNoAgent = !!no_agent;
const toastNotifications = toast_notifications !== false;
title.textContent = isEdit ? (t('edit') + ' · ' + (name || schedule || t('scheduled_jobs'))) : t('new_job');
const deliverOpt = (v,l) => `<option value="${v}"${deliver===v?' selected':''}>${esc(l)}</option>`;
body.innerHTML = `
@@ -788,6 +793,13 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, no_agent=fa
</select>
<div class="detail-form-hint">${esc(t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time')}</div>
</div>
<div class="detail-form-row">
<label for="cronFormToastNotifications">${esc(t('cron_toast_notifications_label') || 'Completion toasts')}</label>
<label class="detail-form-check" for="cronFormToastNotifications">
<input type="checkbox" id="cronFormToastNotifications" ${toastNotifications ? 'checked' : ''}>
<span>${esc(t('cron_toast_notifications_hint') || 'Show a toast when this cron finishes.')}</span>
</label>
</div>
<div class="detail-form-row">
<label for="cronFormSkillSearch">${esc(t('cron_skills_label') || 'Skills')}</label>
<div class="skill-picker-wrap">
@@ -879,6 +891,7 @@ async function saveCronForm(){
const promptEl=$('cronFormPrompt');
const delivEl=$('cronFormDeliver');
const profileEl=$('cronFormProfile');
const toastEl=$('cronFormToastNotifications');
const errEl=$('cronFormError');
if(!schEl||!promptEl||!errEl) return;
const name=(nameEl?nameEl.value:'').trim();
@@ -886,13 +899,14 @@ async function saveCronForm(){
const prompt=promptEl.value.trim();
const deliver=delivEl?delivEl.value:'local';
const profile=profileEl?profileEl.value:'';
const toastNotifications=toastEl?!!toastEl.checked:true;
const isNoAgent = !!(_cronPreFormDetail && _cronPreFormDetail.no_agent);
errEl.style.display='none';
if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;}
if(!isNoAgent && !prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;}
try{
if (_editingCronId) {
const updates = {job_id: _editingCronId, schedule, profile: profile};
const updates = {job_id: _editingCronId, schedule, profile: profile, toast_notifications: toastNotifications};
if (!isNoAgent) updates.prompt = prompt;
if (name) updates.name = name;
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
@@ -905,7 +919,7 @@ async function saveCronForm(){
if (job) openCronDetail(editedId);
return;
}
const body={schedule,prompt,deliver,profile: profile};
const body={schedule,prompt,deliver,profile: profile, toast_notifications: toastNotifications};
if(_cronIsDuplicate) body.enabled=false;
if(name)body.name=name;
if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills;
@@ -6011,7 +6025,9 @@ function startCronPolling(){
const data=await api(`/api/crons/recent?since=${_cronPollSince}`);
if(data.completions&&data.completions.length>0){
for(const c of data.completions){
showToast(t('cron_completion_status', c.name, c.status==='error' ? t('status_failed') : t('status_completed')),4000);
if(c.toast_notifications !== false){
showToast(t('cron_completion_status', c.name, c.status==='error' ? t('status_failed') : t('status_completed')),4000);
}
_cronPollSince=Math.max(_cronPollSince,c.completed_at);
if(c.job_id) _cronNewJobIds.add(String(c.job_id));
}

View File

@@ -906,7 +906,7 @@ async function _fetchLiveModels(provider, sel){
const added=_addLiveModelsToSelect(provider,data.models,sel);
if(added>0){
if(typeof syncModelChip==='function') syncModelChip();
console.log('[hermes] Live models loaded for',provider+':',added,'new models added');
console.debug('[hermes] Live models loaded for',provider+':',added,'new models added');
}
}catch(e){
console.debug('[hermes] Live model fetch failed for',provider,e.message);

View File

@@ -333,7 +333,7 @@ def test_panels_js_uses_locked_placeholder_i18n_key():
# (en/es/de/zh/zh-Hant/ru/ja/fr/pt). The repo currently ships 9 locales but
# substitutes 'ko' for 'fr' — we test what the repo actually has, not what the
# issue body lists, so a future addition of fr won't fail the suite either.
EXPECTED_LOCALES = ("en", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
EXPECTED_LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
def _locale_block(locale_key: str) -> str:

29
tests/test_ci_hygiene.py Normal file
View File

@@ -0,0 +1,29 @@
"""Small hygiene regression checks for CI and frontend console noise."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_github_actions_quotes_pyyaml_version_specifier():
"""Unquoted `pyyaml>=6.0` is parsed by the shell as stdout redirection."""
workflow = ROOT / ".github" / "workflows" / "tests.yml"
text = workflow.read_text(encoding="utf-8")
assert '"pyyaml>=6.0"' in text or "'pyyaml>=6.0'" in text
assert "pip install pyyaml>=6.0" not in text
def test_pytest_integration_marker_is_registered():
config = ROOT / "pytest.ini"
text = config.read_text(encoding="utf-8")
assert "markers" in text
assert "integration:" in text
def test_live_model_success_log_is_debug_not_default_console_log():
ui = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
assert "console.debug('[hermes] Live models loaded" in ui
assert "console.log('[hermes] Live models loaded" not in ui

View File

@@ -0,0 +1,158 @@
"""Coverage for per-cron completion toast notification settings."""
from __future__ import annotations
import io
import json
import sys
import types
from pathlib import Path
from types import SimpleNamespace
REPO = Path(__file__).resolve().parents[1]
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
class _JSONHandler:
def __init__(self):
self.status = None
self.headers = {}
self.response_headers = []
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
pass
def _payload(handler):
return json.loads(handler.wfile.getvalue().decode("utf-8"))
def _function_body(name: str) -> str:
marker = f"function {name}("
start = PANELS_JS.find(marker)
assert start != -1, f"{name} not found"
paren = PANELS_JS.find("(", start)
assert paren != -1, f"{name} params not found"
depth = 0
for idx in range(paren, len(PANELS_JS)):
ch = PANELS_JS[idx]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
brace = PANELS_JS.find("{", idx)
break
else:
raise AssertionError(f"{name} params did not terminate")
assert brace != -1, f"{name} body not found"
depth = 0
for idx in range(brace, len(PANELS_JS)):
ch = PANELS_JS[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return PANELS_JS[brace + 1 : idx]
raise AssertionError(f"{name} body did not terminate")
def test_cron_recent_marks_muted_jobs_without_requesting_toast(monkeypatch):
import api.routes as routes
cron_pkg = types.ModuleType("cron")
cron_pkg.__path__ = []
cron_jobs = types.ModuleType("cron.jobs")
cron_jobs.list_jobs = lambda include_disabled=True: [
{
"id": "loud",
"name": "Loud job",
"last_run_at": 20,
"last_status": "success",
},
{
"id": "muted",
"name": "Muted job",
"last_run_at": 30,
"last_status": "success",
"toast_notifications": False,
},
]
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
handler = _JSONHandler()
routes._handle_cron_recent(handler, SimpleNamespace(query="since=10"))
body = _payload(handler)
assert handler.status == 200
by_id = {item["job_id"]: item for item in body["completions"]}
assert by_id["loud"]["toast_notifications"] is True
assert by_id["muted"]["toast_notifications"] is False
def test_cron_create_persists_muted_toast_setting_after_create(monkeypatch):
import api.routes as routes
created = {"id": "job-toast", "name": "Muted", "prompt": "ping"}
calls = []
cron_pkg = types.ModuleType("cron")
cron_pkg.__path__ = []
cron_jobs = types.ModuleType("cron.jobs")
cron_jobs.create_job = lambda **kwargs: calls.append(("create", kwargs)) or dict(created)
cron_jobs.update_job = lambda job_id, updates: calls.append(("update", job_id, updates)) or {**created, **updates}
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
handler = _JSONHandler()
routes._handle_cron_create(
handler,
{
"prompt": "ping",
"schedule": "every 1h",
"toast_notifications": False,
},
)
assert handler.status == 200
assert calls[0][0] == "create"
assert calls[1] == ("update", "job-toast", {"toast_notifications": False})
assert _payload(handler)["job"]["toast_notifications"] is False
def test_cron_form_has_toast_toggle_and_saves_boolean_setting():
render_body = _function_body("_renderCronForm")
save_body = _function_body("saveCronForm")
edit_body = _function_body("openCronEdit")
detail_body = _function_body("_renderCronDetail")
assert "cronFormToastNotifications" in render_body
assert "cron_toast_notifications_label" in render_body
assert "toast_notifications" in edit_body
assert "toast_notifications" in detail_body
assert "const toastNotifications" in save_body
assert "toast_notifications: toastNotifications" in save_body
def test_cron_polling_suppresses_toasts_but_keeps_unread_badges():
body = _function_body("startCronPolling")
assert "c.toast_notifications !== false" in body
assert "showToast(t('cron_completion_status'" in body
assert "if(c.job_id) _cronNewJobIds.add(String(c.job_id));" in body
def test_cron_toast_i18n_keys_exist():
assert "cron_toast_notifications_label" in I18N_JS
assert "cron_toast_notifications_hint" in I18N_JS
assert "cron_toast_notifications_enabled" in I18N_JS
assert "cron_toast_notifications_disabled" in I18N_JS

View File

@@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n:
"voice_mode_toggle_active",
)
LOCALES = ("en", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
def test_legacy_voice_toggle_key_removed(self):
"""The old key whose string was 'Voice input' caused the duplicate-
@@ -171,6 +171,8 @@ class TestComposerVoiceButtonI18n:
class TestVoiceModePreferenceGate:
"""boot.js must hide btnVoiceMode by default, surface it via Preferences."""
LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
def test_voice_mode_pref_is_localstorage_backed(self):
"""The pref reads from localStorage key 'hermes-voice-mode-button'."""
src = _src("boot.js")
@@ -212,9 +214,9 @@ class TestVoiceModePreferenceGate:
src = _src("i18n.js")
for key in ("settings_label_voice_mode", "settings_desc_voice_mode"):
count = len(re.findall(rf'\b{re.escape(key)}\s*:', src))
assert count == 9, (
assert count == len(self.LOCALES), (
f"Preferences i18n key {key!r} appears {count} times — "
f"expected 9 (one per locale)."
f"expected {len(self.LOCALES)} (one per locale)."
)
def test_panels_js_wires_voice_mode_pref(self):

View File

@@ -104,10 +104,10 @@ def test_panels_js_hides_disable_auth_button_when_env_locked():
'Disable Auth button must be hidden in the env-locked code path'
# ── i18n: keys present in all 9 locales (static/i18n.js) ──────────────────
# ── i18n: keys present in all 10 locales (static/i18n.js) ──────────────────
LOCALES = ['en', 'ja', 'ru', 'es', 'de', 'zh', 'zh-Hant', 'pt', 'ko']
LOCALES = ['en', 'it', 'ja', 'ru', 'es', 'de', 'zh', 'zh-Hant', 'pt', 'ko']
def _split_locales(i18n_src):

View File

@@ -15,7 +15,7 @@ cross-container liveness signal.
These tests pin every behavior the fix promises:
* fresh + running gateway_state, no PID → alive (cross-container path)
* stale updated_at + running → down (no false positives)
* stale updated_at + running → unknown (old gateways may not tick)
* fresh updated_at + non-running state → down (crash-without-cleanup case)
* stale updated_at + stopped state → unknown (old root gateway was
intentionally stopped; do not nag profile-gateway users)
@@ -116,8 +116,8 @@ def test_cross_container_alive_path_does_not_leak_raw_process_fields(monkeypatch
# -- Stale / missing / malformed timestamps -----------------------------------
def test_stale_updated_at_reports_down_even_when_gateway_state_running(monkeypatch):
"""A long-dead gateway with a fossilised state file must surface as down."""
def test_stale_updated_at_with_running_state_reports_unknown(monkeypatch):
"""Older gateways may not refresh the file while still processing messages."""
from api import agent_health
stale_ts = _iso(datetime.now(timezone.utc) - timedelta(seconds=300))
@@ -130,9 +130,10 @@ def test_stale_updated_at_reports_down_even_when_gateway_state_running(monkeypat
payload = agent_health.build_agent_health_payload()
assert payload["alive"] is False
assert payload["details"]["state"] == "down"
assert payload["details"]["reason"] == "gateway_not_running"
assert payload["alive"] is None
assert payload["details"]["state"] == "unknown"
assert payload["details"]["reason"] == "gateway_stale_running_state"
assert payload["details"]["gateway_state"] == "running"
def test_fresh_updated_at_with_non_running_state_reports_down(monkeypatch):

View File

@@ -260,10 +260,10 @@ def test_every_i18n_locale_has_login_locale_entry():
def test_login_locale_count_matches_or_exceeds_floor():
"""_LOGIN_LOCALE must contain at least the 9 launch locales (en, es, de, ru, zh, zh-Hant, ja, pt, ko)."""
"""_LOGIN_LOCALE must contain at least the 10 launch locales (en, it, es, de, ru, zh, zh-Hant, ja, pt, ko)."""
login = _load_login_locale()
assert len(login) >= 9, f"_LOGIN_LOCALE shrank: only {len(login)} entries"
for k in ("en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"):
assert len(login) >= 10, f"_LOGIN_LOCALE shrank: only {len(login)} entries"
for k in ("en", "it", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"):
assert k in login, f"_LOGIN_LOCALE missing core locale {k!r}"

View File

@@ -43,8 +43,8 @@ def test_sf1_session_meta_children_present_in_all_locales():
f"session_meta_messages appears {msg_count} times but "
f"session_meta_children appears {child_count} — must be in every locale"
)
# Sanity: 9 known locales (en, ja, ru, es, de, zh, zh-Hant, plus the legacy zh-tw/zh-hk aliases)
assert child_count >= 9, f"expected >=9 locales with session_meta_children, got {child_count}"
# Sanity: 10 known locales (en, it, ja, ru, es, de, zh, zh-Hant, plus the legacy zh-tw/zh-hk aliases)
assert child_count >= 10, f"expected >=10 locales with session_meta_children, got {child_count}"
# --- SF-2 (#1462): duplicate carries per-session settings ---