From 5192ca5de52878a3b4eb628df18a2b22b9016419 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 26 Apr 2026 21:04:38 -0700 Subject: [PATCH] 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 --- CHANGELOG.md | 17 +++ ROADMAP.md | 2 +- TESTING.md | 2 +- static/i18n.js | 42 ++++++ static/panels.js | 121 ++++++++++++++++- static/style.css | 19 ++- static/ui.js | 63 +++++++-- tests/conftest.py | 16 ++- tests/test_cron_needs_attention.py | 97 +++++++++++++ tests/test_default_workspace_fallback.py | 4 +- tests/test_issue1095_pasted_images.py | 166 ++++++++++++++++++----- tests/test_issues_853_857.py | 3 +- tests/test_media_inline.py | 13 +- tests/test_onboarding_existing_config.py | 23 ++-- tests/test_pytest_state_isolation.py | 22 +++ 15 files changed, 534 insertions(+), 76 deletions(-) create mode 100644 tests/test_cron_needs_attention.py create mode 100644 tests/test_pytest_state_isolation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a31e74e..a74c1752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 540bfc0a..21d69dee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: / diff --git a/TESTING.md b/TESTING.md index fe30f4a0..86eabd5d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/static/i18n.js b/static/i18n.js index e0f2307e..90df7553 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/panels.js b/static/panels.js index 101a8ea7..2e526c10 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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 = `
${esc(job.name)} - ${esc(statusLabel)} + ${esc(status.label)}
`; 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 ? `
${esc(t('error_prefix').replace(/:\s*$/,''))}
${esc(job.last_error)}
` : ''; + const attention = status.state === 'needs_attention' || status.state === 'schedule_error'; + const croniterHint = job.last_error && /croniter/i.test(job.last_error) + ? `

${esc(t('cron_attention_croniter_hint'))}

` + : ''; + const attentionBanner = attention ? ` +
+
${esc(t('cron_status_needs_attention'))}
+

${esc(t('cron_attention_desc'))}

+ ${croniterHint} +
+ + + +
+
` : ''; body.innerHTML = `
+ ${attentionBanner}
${esc(t('cron_status_active').replace(/./,c=>c.toUpperCase()))}
-
Status
${esc(statusLabel)}
+
Status
${esc(status.label)}
Schedule
${esc(schedule)}
${esc(t('cron_next'))}
${esc(nextRun)}
${esc(t('cron_last'))}
${esc(lastRun)}
@@ -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); diff --git a/static/style.css b/static/style.css index a3fab38a..df494c2d 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/static/ui.js b/static/ui.js index 2e8f1b3d..e9dfd268 100644 --- a/static/ui.js +++ b/static/ui.js @@ -50,6 +50,37 @@ function _setCompressionSessionLock(sid){ window._compressionLockSid=sid||null; } const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 . // 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)=>`${esc(alt)}`); + t=t.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`${esc(alt)}`); // Stash rendered tags so autolink never matches URLs inside src= const _img_stash=[]; t=t.replace(/(]*>)/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)=>`${esc(alt)}`); + s=s.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`${esc(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 tags first to avoid re-linking already-linked URLs. @@ -958,14 +989,14 @@ function renderMd(raw){ // Render all https:// URLs as — 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 `image`; + return `image`; } return `${esc(src)}`; } // Local file path const apiUrl='api/media?path='+encodeURIComponent(ref); if(_IMAGE_EXTS.test(ref)){ - return `${esc(ref.split('/').pop())}`; + return `${esc(ref.split('/').pop())}`; } // 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=`
${m.attachments.map(f=>{ const fname=f.split('/').pop()||f; if(_IMAGE_EXTS.test(fname)){ - const imgUrl='api/media?path='+encodeURIComponent(f); - return `${esc(fname)}`; + // 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 `${esc(fname)}`; } return `
${li('paperclip',12)} ${esc(fname)}
`; }).join('')}
`; @@ -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)} `; - 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=`${esc(f.name)}`; + } else { + chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} `; + } + 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; diff --git a/tests/conftest.py b/tests/conftest.py index f80d66c3..ef707679 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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' diff --git a/tests/test_cron_needs_attention.py b/tests/test_cron_needs_attention.py new file mode 100644 index 00000000..21255924 --- /dev/null +++ b/tests/test_cron_needs_attention.py @@ -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 diff --git a/tests/test_default_workspace_fallback.py b/tests/test_default_workspace_fallback.py index 0934be0c..c22e40fc 100644 --- a/tests/test_default_workspace_fallback.py +++ b/tests/test_default_workspace_fallback.py @@ -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." ) - diff --git a/tests/test_issue1095_pasted_images.py b/tests/test_issue1095_pasted_images.py index 709d7b90..b1bf6d5b 100644 --- a/tests/test_issue1095_pasted_images.py +++ b/tests/test_issue1095_pasted_images.py @@ -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 (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 , 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 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 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 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' diff --git a/tests/test_issues_853_857.py b/tests/test_issues_853_857.py index e0c326cb..7e8b34a0 100644 --- a/tests/test_issues_853_857.py +++ b/tests/test_issues_853_857.py @@ -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 ────────────────────────── diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index 30e353f9..b70b3214 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -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): diff --git a/tests/test_onboarding_existing_config.py b/tests/test_onboarding_existing_config.py index 423df84e..8c7f56cf 100644 --- a/tests/test_onboarding_existing_config.py +++ b/tests/test_onboarding_existing_config.py @@ -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.""" diff --git a/tests/test_pytest_state_isolation.py b/tests/test_pytest_state_isolation.py new file mode 100644 index 00000000..56d26861 --- /dev/null +++ b/tests/test_pytest_state_isolation.py @@ -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