v0.50.225: cron attention, image lightbox, pytest isolation (#1137)

* feat: attention state for broken cron jobs + Korean i18n (#1133, @franksong2702)

* fix: pytest state isolation for direct session saves (#1136, @franksong2702)

* fix(#1095): image thumbnails in composer + lightbox in chat (#1135)

* fix(css): restore cron attention + detail-alert rules overwritten by style.css merge (absorb)

* docs: v0.50.225 release notes and version bump

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-26 21:04:38 -07:00
committed by GitHub
parent 69bf2878bc
commit 5192ca5de5
15 changed files with 534 additions and 76 deletions
+17
View File
@@ -3,6 +3,12 @@
## [Unreleased]
### Fixed
- **Recurring cron jobs with no next run need attention** — the Tasks panel now
distinguishes anomalous recurring jobs (`enabled=false`, `state=completed`,
`next_run_at=null`) from ordinary off jobs, shows a warning with recovery
actions, and lets users copy diagnostics for scheduler/runtime failures.
(`static/panels.js`, `static/style.css`, `static/i18n.js`,
`tests/test_cron_needs_attention.py`)
- **Legacy `@provider:model` session models** — persisted sessions with an
old explicit provider hint (for example `@copilot:gpt-5.5`) now pass through
the same stale-model compatibility recovery as slash-prefixed session models,
@@ -14,6 +20,17 @@
provider without a manual container-side install. (`docker_init.bash`,
`tests/test_issue926_hindsight_docker_dependency.py`) Closes #926.
## v0.50.225 — 2026-04-27
### Added
- **Cron job attention state** — recurring jobs that land in a broken state (`enabled=false`, `state=completed`, `next_run_at=null`) now show an amber "needs attention" badge instead of the misleading "off" badge. Detail panel shows a warning banner with Resume & recalculate, Run once, and Copy diagnostics actions. Korean locale translated. (`static/panels.js`, `static/style.css`, `static/i18n.js`) [#1133 @franksong2702]
### Fixed
- **Image attachments: composer tray thumbnails** — pasted/dragged images now show as 56×56 thumbnail chips in the composer instead of paperclip pills. Blob URL revoked on remove. (`static/ui.js`, `static/style.css`) [#1135]
- **Image attachments: chat history inline** — uploaded images in sent messages now load correctly via `api/file/raw?session_id=SID&path=FILENAME` instead of the broken `api/media?path=FILENAME` path. Click any image to open a lightbox overlay (dark backdrop, 90vw/90vh, × or Escape to close). (`static/ui.js`, `static/style.css`) [#1135] Closes #1095
- **pytest state isolation** — `conftest.py` now uses direct assignment for `HERMES_WEBUI_STATE_DIR` / `HERMES_HOME` / `HERMES_WEBUI_DEFAULT_WORKSPACE` so tests importing `api.config` in the pytest process cannot inherit the real `~/.hermes/webui` state tree. (`tests/conftest.py`) [#1136 @franksong2702]
## v0.50.223 — 2026-04-26
### Added
+1 -1
View File
@@ -3,7 +3,7 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.50.221 (April 26, 2026) — 2511 tests collected
> Last updated: v0.50.225 (April 26, 2026) — 2591 tests collected
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
> Source: <repo>/
+1 -1
View File
@@ -8,7 +8,7 @@
> 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 coverage: 2511 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Automated coverage: 2591 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Run: `pytest tests/ -v --timeout=60`
>
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
+42
View File
@@ -504,6 +504,13 @@ const LOCALES = {
cron_status_error: 'error',
cron_status_active: 'active',
cron_status_running: 'running\u2026',
cron_status_needs_attention: 'needs attention',
cron_attention_desc: 'This recurring job has no next run time. The scheduler may have failed to compute its next run.',
cron_attention_croniter_hint: 'The Gateway runtime may be missing the croniter package. Restart the Gateway with cron support, then resume this job.',
cron_attention_resume: 'Resume and recalculate',
cron_attention_run_once: 'Run once now',
cron_attention_copy_diagnostics: 'Copy diagnostics',
cron_diagnostics_copied: 'Cron diagnostics copied',
cron_next: 'Next',
cron_last: 'Last',
cron_run_now: 'Run now',
@@ -1028,6 +1035,13 @@ const LOCALES = {
cron_status_error: 'ошибка',
cron_status_active: 'активно',
cron_status_running: 'выполняется\u2026',
cron_status_needs_attention: 'требует внимания',
cron_attention_desc: 'У этого повторяющегося задания нет времени следующего запуска. Планировщик мог не суметь вычислить следующий запуск.',
cron_attention_croniter_hint: 'В окружении Gateway может отсутствовать пакет croniter. Перезапустите Gateway с поддержкой cron, затем возобновите это задание.',
cron_attention_resume: 'Возобновить и пересчитать',
cron_attention_run_once: 'Запустить один раз сейчас',
cron_attention_copy_diagnostics: 'Скопировать диагностику',
cron_diagnostics_copied: 'Диагностика cron скопирована',
cron_next: 'Следующий',
cron_last: 'Последний',
cron_run_now: 'Запустить сейчас',
@@ -1631,6 +1645,13 @@ const LOCALES = {
cron_status_error: 'error',
cron_status_active: 'active',
cron_status_running: 'running\u2026',
cron_status_needs_attention: 'needs attention',
cron_attention_desc: 'This recurring job has no next run time. The scheduler may have failed to compute its next run.',
cron_attention_croniter_hint: 'The Gateway runtime may be missing the croniter package. Restart the Gateway with cron support, then resume this job.',
cron_attention_resume: 'Resume and recalculate',
cron_attention_run_once: 'Run once now',
cron_attention_copy_diagnostics: 'Copy diagnostics',
cron_diagnostics_copied: 'Cron diagnostics copied',
cron_next: 'Next',
cron_last: 'Last',
cron_run_now: 'Run now',
@@ -2582,6 +2603,13 @@ const LOCALES = {
cron_status_error: '错误',
cron_status_active: '运行中',
cron_status_running: '执行中\u2026',
cron_status_needs_attention: '需要处理',
cron_attention_desc: '这个重复定时任务没有下次运行时间。调度器可能没能计算出下一次运行。',
cron_attention_croniter_hint: 'Gateway 运行环境可能缺少 croniter 包。请用支持 cron 的环境重启 Gateway,然后恢复这个任务。',
cron_attention_resume: '恢复并重新计算',
cron_attention_run_once: '立即运行一次',
cron_attention_copy_diagnostics: '复制诊断信息',
cron_diagnostics_copied: '定时任务诊断信息已复制',
cron_next: '下次',
cron_last: '上次',
cron_run_now: '立即运行',
@@ -3368,6 +3396,13 @@ const LOCALES = {
cron_status_error: '\u932f\u8aa4',
cron_status_off: '\u672a\u555f\u7528',
cron_status_paused: '\u5df2\u66ab\u505c',
cron_status_needs_attention: '\u9700\u8981\u8655\u7406',
cron_attention_desc: '\u9019\u500b\u91cd\u8907\u6392\u7a0b\u4efb\u52d9\u6c92\u6709\u4e0b\u6b21\u57f7\u884c\u6642\u9593\u3002\u6392\u7a0b\u5668\u53ef\u80fd\u7121\u6cd5\u8a08\u7b97\u4e0b\u4e00\u6b21\u57f7\u884c\u3002',
cron_attention_croniter_hint: 'Gateway \u57f7\u884c\u74b0\u5883\u53ef\u80fd\u7f3a\u5c11 croniter \u5957\u4ef6\u3002\u8acb\u7528\u652f\u63f4 cron \u7684\u74b0\u5883\u91cd\u555f Gateway\uff0c\u7136\u5f8c\u6062\u5fa9\u9019\u500b\u4efb\u52d9\u3002',
cron_attention_resume: '\u6062\u5fa9\u4e26\u91cd\u65b0\u8a08\u7b97',
cron_attention_run_once: '\u7acb\u5373\u57f7\u884c\u4e00\u6b21',
cron_attention_copy_diagnostics: '\u8907\u88fd\u8a3a\u65b7\u8cc7\u8a0a',
cron_diagnostics_copied: '\u6392\u7a0b\u4efb\u52d9\u8a3a\u65b7\u8cc7\u8a0a\u5df2\u8907\u88fd',
providers_empty: '\u627e\u4e0d\u5230\u53ef\u8a2d\u5b9a\u7684\u63d0\u4f9b\u8005\u3002',
providers_enter_key: '\u8acb\u8f38\u5165 API \u91d1\u9470',
providers_key_placeholder_new: 'sk-...',
@@ -3903,6 +3938,13 @@ const LOCALES = {
cron_status_error: 'error',
cron_status_active: 'active',
cron_status_running: 'running\u2026',
cron_status_needs_attention: '주의 필요',
cron_attention_desc: '이 반복 작업에 다음 실행 시간이 없습니다. 스케줄러가 다음 실행을 계산하지 못했을 수 있습니다.',
cron_attention_croniter_hint: 'Gateway 런타임에 croniter 패키지가 없을 수 있습니다. cron 지원이 포함된 환경으로 Gateway를 재시작한 후 이 작업을 재개하세요.',
cron_attention_resume: '재개 및 재계산',
cron_attention_run_once: '지금 한 번 실행',
cron_attention_copy_diagnostics: '진단 정보 복사',
cron_diagnostics_copied: '크론 진단 정보가 복사되었습니다',
cron_next: 'Next',
cron_last: 'Last',
cron_run_now: 'Run now',
+114 -7
View File
@@ -177,6 +177,87 @@ async function switchPanel(name, opts = {}) {
}
// ── Cron panel ──
function _isRecurringCronJob(job) {
const kind = job && job.schedule && job.schedule.kind;
return kind === 'cron' || kind === 'interval';
}
function _hasUnlimitedRepeat(job) {
return !!(job && job.repeat && job.repeat.times == null);
}
function _isCronNeedsAttention(job) {
return _isRecurringCronJob(job) &&
_hasUnlimitedRepeat(job) &&
job.enabled === false &&
job.state === 'completed' &&
!job.next_run_at;
}
function _isCronScheduleError(job) {
return _isRecurringCronJob(job) &&
!job.next_run_at &&
(job.state === 'error' || job.last_status === 'error');
}
function _cronStatusMeta(job) {
if (_isCronNeedsAttention(job)) return {
state: 'needs_attention',
listClass: 'attention',
detailClass: 'warn',
label: t('cron_status_needs_attention'),
};
if (_isCronScheduleError(job)) return {
state: 'schedule_error',
listClass: 'attention',
detailClass: 'warn',
label: t('cron_status_needs_attention'),
};
if (job.state === 'paused') return {
state: 'paused',
listClass: 'paused',
detailClass: 'warn',
label: t('cron_status_paused'),
};
if (job.enabled === false) return {
state: 'off',
listClass: 'disabled',
detailClass: 'warn',
label: t('cron_status_off'),
};
if (job.last_status === 'error') return {
state: 'error',
listClass: 'error',
detailClass: 'err',
label: t('cron_status_error'),
};
return {
state: 'active',
listClass: 'active',
detailClass: 'ok',
label: t('cron_status_active'),
};
}
function _cronDiagnostics(job) {
const fields = {
id: job.id,
name: job.name || null,
schedule: job.schedule || null,
schedule_display: job.schedule_display || null,
enabled: job.enabled,
state: job.state,
next_run_at: job.next_run_at || null,
last_run_at: job.last_run_at || null,
last_status: job.last_status || null,
last_error: job.last_error || null,
last_delivery_error: job.last_delivery_error || null,
repeat: job.repeat || null,
deliver: job.deliver || null,
};
return JSON.stringify(fields, null, 2);
}
async function loadCrons(animate) {
const box = $('cronList');
const refreshBtn = $('cronRefreshBtn');
@@ -197,12 +278,11 @@ async function loadCrons(animate) {
const item = document.createElement('div');
item.className = 'cron-item';
item.id = 'cron-' + job.id;
const statusClass = job.enabled === false ? 'disabled' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active';
const statusLabel = job.enabled === false ? t('cron_status_off') : job.state === 'paused' ? t('cron_status_paused') : job.last_status === 'error' ? t('cron_status_error') : t('cron_status_active');
const status = _cronStatusMeta(job);
item.innerHTML = `
<div class="cron-header">
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
<span class="cron-status ${statusClass}">${esc(statusLabel)}</span>
<span class="cron-status ${status.listClass}">${esc(status.label)}</span>
</div>`;
item.onclick = () => openCronDetail(job.id, item);
if (_currentCronDetail && _currentCronDetail.id === job.id) item.classList.add('active');
@@ -230,19 +310,34 @@ function _renderCronDetail(job){
const empty = $('taskDetailEmpty');
if (!title || !body) return;
title.textContent = job.name || job.schedule_display || '(unnamed)';
const statusClass = job.enabled === false ? 'warn' : job.state === 'paused' ? 'warn' : job.last_status === 'error' ? 'err' : 'ok';
const statusLabel = job.enabled === false ? t('cron_status_off') : job.state === 'paused' ? t('cron_status_paused') : job.last_status === 'error' ? t('cron_status_error') : t('cron_status_active');
const status = _cronStatusMeta(job);
const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : t('not_available');
const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : t('never');
const schedule = job.schedule_display || (job.schedule && job.schedule.expression) || '';
const skills = Array.isArray(job.skills) && job.skills.length ? job.skills.join(', ') : '—';
const deliver = job.deliver || 'local';
const lastError = job.last_error ? `<div class="detail-row"><div class="detail-row-label">${esc(t('error_prefix').replace(/:\s*$/,''))}</div><div class="detail-row-value" style="color:var(--accent-text)">${esc(job.last_error)}</div></div>` : '';
const attention = status.state === 'needs_attention' || status.state === 'schedule_error';
const croniterHint = job.last_error && /croniter/i.test(job.last_error)
? `<p>${esc(t('cron_attention_croniter_hint'))}</p>`
: '';
const attentionBanner = attention ? `
<div class="detail-alert cron-attention-panel">
<div class="detail-alert-title">${esc(t('cron_status_needs_attention'))}</div>
<p>${esc(t('cron_attention_desc'))}</p>
${croniterHint}
<div class="detail-alert-actions">
<button type="button" class="cron-btn run" onclick="resumeCurrentCron()">${esc(t('cron_attention_resume'))}</button>
<button type="button" class="cron-btn" onclick="runCurrentCron()">${esc(t('cron_attention_run_once'))}</button>
<button type="button" class="cron-btn" onclick="copyCurrentCronDiagnostics()">${esc(t('cron_attention_copy_diagnostics'))}</button>
</div>
</div>` : '';
body.innerHTML = `
<div class="main-view-content">
${attentionBanner}
<div class="detail-card">
<div class="detail-card-title">${esc(t('cron_status_active').replace(/./,c=>c.toUpperCase()))}</div>
<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value"><span class="detail-badge ${statusClass}">${esc(statusLabel)}</span></div></div>
<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value"><span class="detail-badge ${status.detailClass}">${esc(status.label)}</span></div></div>
<div class="detail-row"><div class="detail-row-label">Schedule</div><div class="detail-row-value"><code>${esc(schedule)}</code></div></div>
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_next'))}</div><div class="detail-row-value">${esc(nextRun)}</div></div>
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_last'))}</div><div class="detail-row-value">${esc(lastRun)}</div></div>
@@ -279,7 +374,12 @@ function _setCronHeaderButtons(mode, job) {
const show = b => b && (b.style.display = '');
if (mode === 'read') {
show(runBtn);
if (job && job.state === 'paused') { hide(pauseBtn); show(resumeBtn); }
const status = job ? _cronStatusMeta(job) : null;
const resumable = job && (
job.state === 'paused' ||
(status && (status.state === 'needs_attention' || status.state === 'schedule_error'))
);
if (resumable) { hide(pauseBtn); show(resumeBtn); }
else { show(pauseBtn); hide(resumeBtn); }
show(editBtn); show(delBtn); hide(cancelBtn); hide(saveBtn);
} else if (mode === 'create' || mode === 'edit') {
@@ -339,6 +439,13 @@ function _clearCronDetail(){
async function runCurrentCron(){ if (_currentCronDetail) await cronRun(_currentCronDetail.id); }
async function pauseCurrentCron(){ if (_currentCronDetail) await cronPause(_currentCronDetail.id); }
async function resumeCurrentCron(){ if (_currentCronDetail) await cronResume(_currentCronDetail.id); }
async function copyCurrentCronDiagnostics(){
if (!_currentCronDetail) return;
try {
await _copyText(_cronDiagnostics(_currentCronDetail));
showToast(t('cron_diagnostics_copied'));
} catch(e) { showToast(t('copy_failed'), 4000); }
}
function editCurrentCron(){
if (!_currentCronDetail) return;
openCronEdit(_currentCronDetail);
+17 -2
View File
@@ -200,6 +200,7 @@
:root:not(.dark) .token.italic{font-style:italic;}
:root:not(.dark) .nav-tab:hover::after{background:var(--surface);border-color:var(--accent-bg-strong);color:var(--accent-text);}
:root:not(.dark) .cron-status.disabled{background:rgba(0,0,0,.05);}
:root:not(.dark) .cron-status.attention{background:rgba(217,119,6,.14);color:#b45309;}
:root:not(.dark) .cron-btn{background:rgba(0,0,0,.04);}
:root:not(.dark) .cron-btn:hover{background:rgba(0,0,0,.08);}
/* ── Smooth dark mode transitions ── */
@@ -517,6 +518,7 @@
.cron-status.paused{background:var(--accent-bg-strong);color:var(--accent-text);}
.cron-status.disabled{background:rgba(255,255,255,.07);color:var(--muted);}
.cron-status.error{background:rgba(239,83,80,.12);color:var(--error);}
.cron-status.attention{background:rgba(245,158,11,.16);color:rgba(245,158,11,.95);}
.cron-body{display:none;padding:0 12px 10px;border-top:1px solid var(--border);overflow:hidden;}
.cron-body.open{display:block;}
.cron-schedule{font-size:11px;color:var(--muted);margin:8px 0 6px;}
@@ -615,8 +617,13 @@
.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:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:6px;padding:4px 9px;font-size:12px;color:var(--accent-text);}
/* MEDIA: inline image rendering (feat #450) */
.msg-media-img{display:block;max-width:min(480px,100%);max-height:400px;border-radius:8px;margin:6px 0;cursor:zoom-in;object-fit:contain;border:1px solid var(--border);}
.msg-media-img--full{max-width:100%;max-height:none;cursor:zoom-out;}
.msg-media-img{display:inline-block;width:120px;height:90px;border-radius:6px;margin:3px 4px 3px 0;cursor:zoom-in;object-fit:cover;border:1px solid var(--border);vertical-align:bottom;transition:opacity .15s;}
.msg-media-img:hover{opacity:.85;}
.img-lightbox{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.82);display:flex;align-items:center;justify-content:center;cursor:zoom-out;animation:lb-in .15s ease;}
@keyframes lb-in{from{opacity:0}to{opacity:1}}
.img-lightbox img{max-width:90vw;max-height:90vh;object-fit:contain;border-radius:8px;box-shadow:0 8px 48px rgba(0,0,0,.6);cursor:default;}
.img-lightbox-close{position:absolute;top:16px;right:20px;width:36px;height:36px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s;}
.img-lightbox-close:hover{background:rgba(255,255,255,.22);}
.msg-media-link{display:inline-flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:6px;padding:4px 10px;font-size:13px;color:var(--accent-text);text-decoration:none;}
.msg-media-link:hover{background:var(--accent-bg-strong);}
.thinking{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:13px;padding-left:30px;}
@@ -642,6 +649,9 @@
.attach-chip{display:flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:500;color:var(--accent-text);}
.attach-chip button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;line-height:1;padding:0 0 0 3px;}
.attach-chip button:hover{color:var(--accent);}
/* Image attachment chips show a thumbnail preview instead of a paperclip chip */
.attach-chip--image{background:transparent;border-color:var(--border);padding:3px;border-radius:6px;}
.attach-thumb{width:56px;height:56px;object-fit:cover;border-radius:4px;display:block;cursor:default;}
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;}
textarea#msg::placeholder{color:var(--muted);}
.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px 10px;position:relative;}
@@ -2241,6 +2251,11 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.detail-badge.running{background:rgba(59,130,246,.12);color:rgba(96,165,250,.95);border-color:rgba(96,165,250,.3);}
.detail-badge.running::before{content:'';display:inline-block;width:10px;height:10px;border:2px solid rgba(96,165,250,.4);border-top-color:rgba(96,165,250,.95);border-radius:50%;margin-right:6px;vertical-align:middle;animation:cron-spinner .6s linear infinite;}
@keyframes cron-spinner{to{transform:rotate(360deg);}}
.detail-alert{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.1);border-radius:8px;padding:12px 14px;margin-bottom:16px;color:var(--text);}
.detail-alert-title{font-size:12px;font-weight:700;color:rgba(245,158,11,.98);text-transform:uppercase;letter-spacing:0;margin-bottom:6px;}
.detail-alert p{margin:0 0 8px;font-size:13px;line-height:1.45;color:var(--text);}
.detail-alert p:last-child{margin-bottom:0;}
.detail-alert-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;}
.detail-prompt{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:12px;white-space:pre-wrap;line-height:1.55;color:var(--text);font-family:'SF Mono',ui-monospace,monospace;max-height:240px;overflow-y:auto;}
.detail-run-item{border-top:1px solid var(--border);padding:8px 0;}
.detail-run-item:first-child{border-top:none;}
+54 -9
View File
@@ -50,6 +50,37 @@ function _setCompressionSessionLock(sid){
window._compressionLockSid=sid||null;
}
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
/* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */
function _openImgLightbox(src, alt) {
const lb = document.createElement('div');
lb.className = 'img-lightbox';
lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-label', alt || 'Image');
const img = document.createElement('img');
img.src = src;
img.alt = alt || '';
img.onclick = e => e.stopPropagation();
const cls = document.createElement('button');
cls.className = 'img-lightbox-close';
cls.setAttribute('aria-label', 'Close');
cls.textContent = '×';
cls.onclick = () => _closeImgLightbox(lb);
lb.appendChild(img);
lb.appendChild(cls);
lb.onclick = () => _closeImgLightbox(lb);
document.body.appendChild(lb);
// Close on Escape
lb._escHandler = e => { if(e.key==='Escape') _closeImgLightbox(lb); };
document.addEventListener('keydown', lb._escHandler);
}
function _closeImgLightbox(lb) {
if(!lb || !lb.parentNode) return;
document.removeEventListener('keydown', lb._escHandler);
lb.style.animation = 'lb-in .12s ease reverse';
setTimeout(() => lb.parentNode && lb.parentNode.removeChild(lb), 120);
}
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
@@ -815,7 +846,7 @@ function renderMd(raw){
// backticks stays protected as a \x00C token and is never rendered as <img>.
// Must run before _code_stash restore and before _link_stash so the image
// is not consumed by the [label](url) link regex.
t=t.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`);
t=t.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy" onclick="_openImgLightbox(this.src,this.alt)">`);
// Stash rendered <img> tags so autolink never matches URLs inside src=
const _img_stash=[];
t=t.replace(/(<img\b[^>]*>)/g,m=>{_img_stash.push(m);return `\x00G${_img_stash.length-1}\x00`;});
@@ -894,7 +925,7 @@ function renderMd(raw){
// #487: Outer image pass — handles ![alt](url) in plain paragraphs (outside tables/lists).
// Runs AFTER the table pass (images in table cells are handled by inlineMd() above).
// Runs BEFORE the outer [label](url) link pass so the image is not consumed as a plain link.
s=s.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`);
s=s.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy" onclick="_openImgLightbox(this.src,this.alt)">`);
// Outer link pass for labeled links in plain paragraphs (outside table cells).
// Runs AFTER the table pass so table cells are processed by inlineMd() only.
// Stash existing <a> tags first to avoid re-linking already-linked URLs.
@@ -958,14 +989,14 @@ function renderMd(raw){
// Render all https:// URLs as <img> — extension check would miss extensionless
// CDN paths like fal.media content-addressed URLs (closes #853).
if(_IMAGE_EXTS.test(src.split('?')[0]) || /^https?:\/\//i.test(src)){
return `<img class="msg-media-img" src="${esc(src)}" alt="image" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
return `<img class="msg-media-img" src="${esc(src)}" alt="image" loading="lazy" onclick="_openImgLightbox(this.src,this.alt)">`;
}
return `<a href="${esc(src)}" target="_blank" rel="noopener">${esc(src)}</a>`;
}
// Local file path
const apiUrl='api/media?path='+encodeURIComponent(ref);
if(_IMAGE_EXTS.test(ref)){
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy" onclick="_openImgLightbox(this.src,this.alt)">`;
}
// Non-image local file — show download link with filename
const fname=esc(ref.split('/').pop()||ref);
@@ -2255,11 +2286,14 @@ function renderMessages(){
const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
let filesHtml='';
if(m.attachments&&m.attachments.length){
const _attachSid=(S.session&&S.session.session_id)||'';
filesHtml=`<div class="msg-files">${m.attachments.map(f=>{
const fname=f.split('/').pop()||f;
if(_IMAGE_EXTS.test(fname)){
const imgUrl='api/media?path='+encodeURIComponent(f);
return `<img class="msg-media-img" src="${esc(imgUrl)}" alt="${esc(fname)}" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
// Use api/file/raw which resolves filename relative to the session workspace.
// api/media expects a full absolute path which we don't store on the client side.
const imgUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname);
return `<img class="msg-media-img" src="${esc(imgUrl)}" alt="${esc(fname)}" loading="lazy" onclick="_openImgLightbox(this.src,this.alt)">`;
}
return `<div class="msg-file-badge">${li('paperclip',12)} ${esc(fname)}</div>`;
}).join('')}</div>`;
@@ -3235,13 +3269,24 @@ function renderTray(){
updateSendBtn();
S.pendingFiles.forEach((f,i)=>{
const chip=document.createElement('div');chip.className='attach-chip';
chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} <button title="${t('remove_title')}">${li('x',12)}</button>`;
chip.querySelector('button').onclick=()=>{S.pendingFiles.splice(i,1);renderTray();};
// Image files get a thumbnail preview; other files keep the paperclip chip
if(_IMAGE_EXTS.test(f.name)){
const blobUrl=URL.createObjectURL(f);
chip.className='attach-chip attach-chip--image';
chip.dataset.blobUrl=blobUrl;
chip.innerHTML=`<img class="attach-thumb" src="${esc(blobUrl)}" alt="${esc(f.name)}" title="${esc(f.name)}"><button title="${t('remove_title')}">${li('x',12)}</button>`;
} else {
chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} <button title="${t('remove_title')}">${li('x',12)}</button>`;
}
chip.querySelector('button').onclick=()=>{
// Revoke blob URL to avoid memory leak before removing
if(chip.dataset.blobUrl) URL.revokeObjectURL(chip.dataset.blobUrl);
S.pendingFiles.splice(i,1);renderTray();
};
tray.appendChild(chip);
});
}
function addFiles(files){for(const f of files){if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f);}renderTray();}
async function uploadPendingFiles(){
if(!S.pendingFiles.length||!S.session)return[];
const names=[];let failures=0;
+12 -4
View File
@@ -57,10 +57,18 @@ TEST_STATE_DIR = pathlib.Path(os.getenv(
))
TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace'
# Publish at module level so _pytest_port.py (imported at collection time)
# and any test file using os.environ sees the right values immediately.
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
os.environ.setdefault('HERMES_WEBUI_TEST_STATE_DIR', str(TEST_STATE_DIR))
# Publish at module level so api.config, _pytest_port.py, and any test module
# importing stateful API code during collection see the isolated test paths.
#
# Direct assignment is intentional for production-risk paths: tests that import
# api.config/api.models in the pytest process must never inherit the real
# ~/.hermes state tree before the server subprocess fixture starts.
os.environ['HERMES_WEBUI_TEST_PORT'] = str(TEST_PORT)
os.environ['HERMES_WEBUI_TEST_STATE_DIR'] = str(TEST_STATE_DIR)
os.environ['HERMES_WEBUI_STATE_DIR'] = str(TEST_STATE_DIR)
os.environ['HERMES_WEBUI_DEFAULT_WORKSPACE'] = str(TEST_WORKSPACE)
os.environ['HERMES_HOME'] = str(TEST_STATE_DIR)
os.environ['HERMES_BASE_HOME'] = str(TEST_STATE_DIR)
# ── Server script: always relative to repo root ───────────────────────────
SERVER_SCRIPT = REPO_ROOT / 'server.py'
+97
View File
@@ -0,0 +1,97 @@
"""Regression coverage for anomalous recurring cron UI state."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
PANELS_JS = ROOT / "static" / "panels.js"
STYLE_CSS = ROOT / "static" / "style.css"
I18N_JS = ROOT / "static" / "i18n.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def _cron_helper_source() -> str:
src = PANELS_JS.read_text(encoding="utf-8")
start = src.index("function _isRecurringCronJob")
end = src.index("async function loadCrons", start)
return src[start:end]
def _run_node(script: str) -> str:
proc = subprocess.run(
[NODE, "-e", script],
check=True,
capture_output=True,
text=True,
)
return proc.stdout.strip()
def test_legacy_broken_recurring_cron_is_needs_attention_not_off():
script = _cron_helper_source() + r"""
function t(key){ return key; }
const legacyBroken = {
id: 'legacy-broken',
schedule: {kind: 'cron', expr: '0 7,15,23 * * *'},
repeat: {times: null, completed: 17},
enabled: false,
state: 'completed',
next_run_at: null,
last_status: 'ok',
};
const oneShotCompleted = {
id: 'oneshot-done',
schedule: {kind: 'once', run_at: '2026-04-01T00:00:00+00:00'},
repeat: {times: 1, completed: 1},
enabled: false,
state: 'completed',
next_run_at: null,
last_status: 'ok',
};
const scheduleError = {
id: 'schedule-error',
schedule: {kind: 'cron', expr: '0 7 * * *'},
repeat: {times: null, completed: 1},
enabled: true,
state: 'error',
next_run_at: null,
last_status: 'error',
};
console.log(JSON.stringify({
legacyBroken: _cronStatusMeta(legacyBroken),
oneShotCompleted: _cronStatusMeta(oneShotCompleted),
scheduleError: _cronStatusMeta(scheduleError),
}));
"""
states = json.loads(_run_node(script))
assert states["legacyBroken"]["state"] == "needs_attention"
assert states["legacyBroken"]["listClass"] == "attention"
assert states["legacyBroken"]["label"] == "cron_status_needs_attention"
assert states["oneShotCompleted"]["state"] == "off"
assert states["oneShotCompleted"]["listClass"] == "disabled"
assert states["scheduleError"]["state"] == "schedule_error"
assert states["scheduleError"]["listClass"] == "attention"
def test_cron_attention_ui_has_recovery_and_diagnostics_actions():
panels = PANELS_JS.read_text(encoding="utf-8")
style = STYLE_CSS.read_text(encoding="utf-8")
i18n = I18N_JS.read_text(encoding="utf-8")
assert "cron_status_needs_attention" in panels
assert "resumeCurrentCron()" in panels
assert "runCurrentCron()" in panels
assert "copyCurrentCronDiagnostics()" in panels
assert "_cronDiagnostics(_currentCronDetail)" in panels
assert ".cron-status.attention" in style
assert ".detail-alert" in style
assert "cron_attention_resume: 'Resume and recalculate'" in i18n
assert "cron_attention_copy_diagnostics: 'Copy diagnostics'" in i18n
+3 -1
View File
@@ -11,6 +11,7 @@ def test_resolve_default_workspace_falls_back_to_existing_home_work(monkeypatch,
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
resolved = config.resolve_default_workspace("/definitely/not/usable")
@@ -28,6 +29,7 @@ def test_save_settings_rewrites_bad_default_workspace_to_fallback(monkeypatch, t
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", preferred)
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
saved = config.save_settings({"default_workspace": "/definitely/not/usable"})
on_disk = json.loads(settings_file.read_text(encoding="utf-8"))
@@ -41,6 +43,7 @@ def test_resolve_default_workspace_creates_home_workspace_when_missing(monkeypat
state_dir = tmp_path / "state"
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
# Neither ~/work nor ~/workspace exists yet
resolved = config.resolve_default_workspace(None)
assert resolved == (tmp_path / "workspace").resolve()
@@ -145,4 +148,3 @@ def test_env_var_wins_over_settings_json_on_startup(monkeypatch, tmp_path):
f"Expected {env_ws.resolve()}, got {current_ws}. "
"settings.json must not override HERMES_WEBUI_DEFAULT_WORKSPACE."
)
+131 -35
View File
@@ -1,4 +1,8 @@
"""Tests for #1095 — pasted images render as inline previews, not paperclip badges."""
"""Tests for #1095 — full fix covering both bugs:
Bug 1: Composer tray shows paperclip chip for images instead of thumbnail preview.
Bug 2: Chat history renders uploaded images as broken <img> (wrong endpoint / dead URL).
"""
import os
import re
import pytest
@@ -9,44 +13,136 @@ def _read_js(name):
return f.read()
class TestAttachmentImageRendering:
"""User message attachments with image extensions should render as <img>, not paperclip badges."""
def _read_css():
with open(os.path.join('static', 'style.css')) as f:
return f.read()
def test_attachments_block_uses_image_check(self):
ui = _read_js('ui.js')
# Find the attachments rendering block
assert 'm.attachments' in ui
# Must check file extension before rendering
assert '_IMAGE_EXTS.test(' in ui, '_IMAGE_EXTS not used in attachment rendering'
def test_image_attachments_use_img_tag(self):
"""Image attachments should produce <img> with api/media?path=, not paperclip badge."""
ui = _read_js('ui.js')
# Find the attachments section
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
assert m, 'attachments rendering block not found'
body = ui[m.start():m.start() + 1000]
# Should have img tag with api/media
assert 'msg-media-img' in body, 'attachments must render images with msg-media-img class'
assert 'api/media?path=' in body, 'image attachments must use api/media endpoint'
# ── Bug 1: Composer tray thumbnail previews ────────────────────────────────
def test_non_image_attachments_keep_paperclip(self):
"""Non-image attachments must still show paperclip badge."""
ui = _read_js('ui.js')
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
body = ui[m.start():m.start() + 1000]
assert "msg-file-badge" in body, 'non-image attachments must still use paperclip badge'
class TestComposerTrayThumbnails:
"""renderTray() must show thumbnail previews for image files, not paperclip chips."""
def test_image_click_to_full(self):
"""Inline image attachments should support click-to-fullscreen (toggle class)."""
def test_rendertray_checks_image_extension(self):
"""renderTray must branch on _IMAGE_EXTS for the file object in S.pendingFiles."""
ui = _read_js('ui.js')
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
body = ui[m.start():m.start() + 1000]
assert "msg-media-img--full" in body, 'image attachments should toggle full-screen on click'
# Find renderTray function body
idx = ui.find('function renderTray()')
assert idx >= 0, 'renderTray() not found in ui.js'
body = ui[idx:idx + 800]
assert '_IMAGE_EXTS.test(' in body, 'renderTray must check _IMAGE_EXTS for thumbnail vs chip'
def test_uses_filename_not_full_path(self):
"""Non-image badge should display filename, not full path."""
def test_rendertray_uses_createobjecturl_for_images(self):
"""Image files must use URL.createObjectURL(f) to generate a blob URL for the thumbnail."""
ui = _read_js('ui.js')
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
body = ui[m.start():m.start() + 1000]
assert ".split('/').pop()" in body, 'should extract filename from path for display'
idx = ui.find('function renderTray()')
body = ui[idx:idx + 800]
assert 'URL.createObjectURL(' in body, 'renderTray must use URL.createObjectURL for image thumbnails'
def test_rendertray_revokes_blob_url_on_remove(self):
"""Blob URLs must be revoked when a file is removed to prevent memory leaks."""
ui = _read_js('ui.js')
idx = ui.find('function renderTray()')
body = ui[idx:idx + 1200]
assert 'URL.revokeObjectURL(' in body, 'renderTray must revoke blob URL when chip is removed'
def test_rendertray_uses_attach_thumb_class(self):
"""Image chips must use attach-thumb class for the thumbnail <img> element."""
ui = _read_js('ui.js')
idx = ui.find('function renderTray()')
body = ui[idx:idx + 800]
assert 'attach-thumb' in body, 'renderTray image chip must use attach-thumb class'
def test_rendertray_non_image_still_uses_paperclip(self):
"""Non-image files must still get the paperclip chip (not thumbnail)."""
ui = _read_js('ui.js')
idx = ui.find('function renderTray()')
body = ui[idx:idx + 800]
assert 'paperclip' in body, 'non-image files must still use paperclip chip in renderTray'
def test_attach_thumb_css_present(self):
"""CSS must define .attach-thumb with width/height/object-fit for the thumbnail."""
css = _read_css()
assert '.attach-thumb' in css, '.attach-thumb CSS class must be defined'
# Find the rule
idx = css.find('.attach-thumb')
rule = css[idx:idx + 200]
assert 'object-fit' in rule, '.attach-thumb must set object-fit to crop image to square'
def test_attach_chip_image_variant_css(self):
"""CSS must define .attach-chip--image for the image chip variant."""
css = _read_css()
assert '.attach-chip--image' in css, '.attach-chip--image CSS variant must be defined'
def test_adfiles_function_still_present(self):
"""addFiles() must still exist after renderTray refactor."""
ui = _read_js('ui.js')
assert 'function addFiles(' in ui, 'addFiles() must not be removed from ui.js'
# ── Bug 2: Chat history image rendering ───────────────────────────────────
class TestChatHistoryImageRendering:
"""Uploaded images in chat history must render via a working HTTP endpoint, not a dead path."""
def test_attachment_render_uses_file_raw_not_media(self):
"""Image attachments in chat history must use api/file/raw, not api/media.
api/media expects a full absolute filesystem path (e.g. /home/hermes/.hermes/...).
We only store the filename in m.attachments feeding just a filename to api/media
results in a broken image (path not in allowed roots 404).
api/file/raw resolves the filename relative to the session's workspace, which is
exactly where the upload endpoint stores the file.
"""
ui = _read_js('ui.js')
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
assert m, 'attachments rendering block not found in ui.js'
body = ui[m.start():m.start() + 1200]
assert 'api/file/raw' in body, (
'Image attachments in chat history must use api/file/raw endpoint '
'(resolves filename relative to session workspace). '
'api/media requires a full absolute path which is not stored on the client.'
)
assert 'api/media?path=' not in body, (
'api/media?path= must not be used for user-uploaded image attachments — '
'it expects a full absolute path, but only filenames are stored in m.attachments.'
)
def test_attachment_render_includes_session_id(self):
"""api/file/raw URL must include session_id parameter for workspace resolution."""
ui = _read_js('ui.js')
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
body = ui[m.start():m.start() + 1200]
assert 'session_id' in body, (
'api/file/raw URL in attachment rendering must include session_id '
'so the server can resolve the filename against the correct workspace.'
)
def test_attachment_render_image_uses_msg_media_img(self):
"""Image attachments must still render with msg-media-img class for consistent styling."""
ui = _read_js('ui.js')
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
body = ui[m.start():m.start() + 1200]
assert 'msg-media-img' in body, 'Image attachment <img> must use msg-media-img class'
def test_attachment_render_click_to_fullscreen(self):
"""Click-to-fullscreen must still work on chat history images."""
ui = _read_js('ui.js')
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
body = ui[m.start():m.start() + 1200]
assert '_openImgLightbox' in body, 'Chat history images must open lightbox on click'
def test_attachment_render_non_image_keeps_paperclip(self):
"""Non-image attachments in chat history must still show paperclip badge."""
ui = _read_js('ui.js')
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
body = ui[m.start():m.start() + 1200]
assert 'msg-file-badge' in body, 'Non-image attachments must still use msg-file-badge in chat history'
def test_attachment_render_extracts_filename(self):
"""Filename extraction (.split('/').pop()) must still be present for display."""
ui = _read_js('ui.js')
m = re.search(r'm\.attachments&&m\.attachments\.length', ui)
body = ui[m.start():m.start() + 1200]
assert ".split('/').pop()" in body, 'Must extract filename from path for display'
+2 -1
View File
@@ -36,7 +36,8 @@ class TestMediaUrlRendersInline:
js = _read("static/ui.js")
# The img tag is constructed with the existing class + onclick toggle
assert "msg-media-img" in js
assert "msg-media-img--full" in js
# PR #1135: CSS class toggle replaced by lightbox. Class removed; _openImgLightbox handles zoom.
assert "_openImgLightbox" in js, "Image click must open lightbox overlay"
# ── #857: thinking-preamble stripping in auto-title ──────────────────────────
+8 -5
View File
@@ -76,8 +76,9 @@ class TestMediaRenderMdStash(unittest.TestCase):
)
def test_zoom_toggle_on_click(self):
self.assertIn("msg-media-img--full", UI_JS,
"Clicking the image must toggle msg-media-img--full class for zoom")
# PR #1135: CSS class toggle replaced by proper lightbox overlay
self.assertIn("_openImgLightbox", UI_JS,
"Clicking the image must open lightbox overlay (_openImgLightbox)")
# ── Static analysis: CSS ──────────────────────────────────────────────────────
@@ -90,14 +91,16 @@ class TestMediaCSS(unittest.TestCase):
self.assertIn(".msg-media-img", self.CSS)
def test_msg_media_img_max_width(self):
# Should have a max-width to prevent huge images breaking layout
# PR #1135: resting thumbnail is 120x90px (fixed size); no max-width needed.
# Lightbox shows full-size. Check width is set instead.
idx = self.CSS.find(".msg-media-img{")
self.assertGreater(idx, 0)
rule = self.CSS[idx:idx+200]
self.assertIn("max-width", rule)
self.assertIn("width:120px", rule, "Thumbnail must have fixed 120px width")
def test_msg_media_img_full_class_defined(self):
self.assertIn(".msg-media-img--full", self.CSS,
# PR #1135: .msg-media-img--full removed; lightbox replaces inline zoom.
self.assertIn(".img-lightbox", self.CSS,
"Full-size toggle class must exist for zoom-on-click")
def test_msg_media_link_class_defined(self):
+13 -10
View File
@@ -293,6 +293,15 @@ def _server_reachable() -> bool:
return False
def _flush_server_config_cache() -> None:
# GET /api/personalities always calls reload_config(), giving us a cheap
# way to flush cached provider state without restarting the test server.
try:
_http_get("/api/personalities")
except Exception:
pass
# No collection-time skip guard — conftest.py starts the server via its
# autouse session fixture BEFORE tests run. A collection-time check always
# sees no server and turns every test into a skip. Server reachability is
@@ -313,19 +322,13 @@ class TestOnboardingGateIntegration:
hermes_home = _server_hermes_home()
for rel in ("config.yaml", ".env"):
(hermes_home / rel).unlink(missing_ok=True)
_http_post("/api/settings", {"onboarding_completed": False})
_flush_server_config_cache()
yield
for rel in ("config.yaml", ".env"):
(hermes_home / rel).unlink(missing_ok=True)
# Force the server to reload its in-memory config after file deletion.
# apply_onboarding_setup() calls reload_config() which caches provider
# state in the server process. Deleting files on disk does not clear
# that cache — the next test would see provider_configured=True.
# GET /api/personalities always calls reload_config(), giving us a
# cheap way to flush the cache without a server restart.
try:
_http_get("/api/personalities")
except Exception:
pass
_http_post("/api/settings", {"onboarding_completed": False})
_flush_server_config_cache()
def test_no_config_wizard_fires(self):
"""No config.yaml → completed=False."""
+22
View File
@@ -0,0 +1,22 @@
"""
Regression tests for pytest-process state isolation.
Some tests import api.config/api.models during collection and directly write
sessions from the pytest process. conftest must publish the test state env vars
before those imports, not only for the server subprocess.
"""
from pathlib import Path
def test_api_config_uses_pytest_state_dir():
import api.config as config
from tests.conftest import TEST_STATE_DIR
test_state_dir = TEST_STATE_DIR.resolve()
production_state_dir = (Path.home() / ".hermes" / "webui").resolve()
assert config.STATE_DIR == test_state_dir
assert config.SESSION_DIR == test_state_dir / "sessions"
assert config.STATE_DIR != production_state_dir
assert production_state_dir not in config.SESSION_DIR.resolve().parents