fix: batch v0.50.228 — renderer, model race, tool card, empty session, .env (#1179)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
Merged as v0.50.228. 2644 tests passing. Browser QA 21/21 (desktop 1440×900 + mobile iPhone 14). All 5 fix invariants verified live in browser. **Fix verifications:** - #1172 (`renderMd` pre-stash): `rawPreStash` present in function, `<pre>` blocks pass through without content rewrite ✅ - #1174 (model race guard): `syncTopbar()` contains `liveStillPending` guard ✅ - #1175 (tool card): `.tool-card-result pre` max-height=360px, `.tool-card.open .tool-card-detail` overflow=auto, cap=600px ✅ - #1176 (empty session guard): double-click New Conversation on empty session → stays on same session, composer focused ✅ - #1178 (`.env` atomic write): `tempfile.mkstemp + os.replace` in `providers.py`, 9/9 env tests pass ✅ Thanks @bsgdigital (#1150) and @bergeouss (#1178)!
This commit is contained in:
26
CHANGELOG.md
26
CHANGELOG.md
@@ -357,6 +357,32 @@
|
||||
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
|
||||
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
|
||||
|
||||
## [v0.50.228] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
- **Raw `<pre>` blocks preserved in markdown renderer** — the inline `<code>` rewrite
|
||||
pass in `renderMd()` no longer processes content inside raw `<pre>` blocks, preventing
|
||||
multiline HTML code blocks from being degraded to backtick strings.
|
||||
(`static/ui.js`) (#1150, @bsgdigital)
|
||||
- **Live model race silently overwrites session model** — `syncTopbar()` now skips
|
||||
the destructive fallback-to-first-model path while a live model fetch is in flight
|
||||
for the active provider; `_addLiveModelsToSelect()` re-applies the session model
|
||||
once the fetch completes, so models only present in the live catalog (e.g. Kimi K2)
|
||||
are never silently replaced. (`static/ui.js`) (#1169)
|
||||
- **Tool card output truncated at 220 chars and unscrollable** — JS truncation threshold
|
||||
raised to 800 chars; CSS `overflow:auto` added to `.tool-card.open .tool-card-detail`
|
||||
so the inner `<pre>` scroll works correctly; `<pre>` max-height raised to 360 px.
|
||||
(`static/ui.js`, `static/style.css`) (#1170)
|
||||
- **New Conversation creates empty session when already on empty session** — clicking
|
||||
the New Conversation button or pressing Cmd/Ctrl+K when the current session has zero
|
||||
messages now focuses the composer instead of creating another empty Untitled session.
|
||||
(`static/boot.js`) (#1171)
|
||||
- **`.env` file corruption from concurrent WebUI and CLI/Telegram writes** — removes
|
||||
the unlocked duplicate `_write_env_file()` in `api/onboarding.py` that bypassed
|
||||
`_ENV_LOCK`; rewrites the shared version to preserve comments, blank lines, and
|
||||
original key order rather than rebuilding from a sorted dict.
|
||||
(`api/onboarding.py`, `api/providers.py`) (#1164, @bergeouss)
|
||||
|
||||
## [v0.50.227] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -23,6 +23,7 @@ from api.config import (
|
||||
save_settings,
|
||||
verify_hermes_imports,
|
||||
)
|
||||
from api.providers import _write_env_file # shared impl with _ENV_LOCK (#1164)
|
||||
from api.workspace import get_last_workspace, load_workspaces
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -167,26 +168,6 @@ def _load_env_file(env_path: Path) -> dict[str, str]:
|
||||
return values
|
||||
|
||||
|
||||
def _write_env_file(env_path: Path, updates: dict[str, str]) -> None:
|
||||
current = _load_env_file(env_path)
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
current.pop(key, None)
|
||||
os.environ.pop(key, None)
|
||||
continue
|
||||
clean = str(value).strip()
|
||||
if not clean:
|
||||
continue
|
||||
# Reject embedded newlines/carriage returns to prevent .env injection
|
||||
if "\n" in clean or "\r" in clean:
|
||||
raise ValueError("API key must not contain newline characters.")
|
||||
current[key] = clean
|
||||
os.environ[key] = clean
|
||||
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"{key}={current[key]}" for key in sorted(current)]
|
||||
env_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
||||
|
||||
|
||||
def _load_yaml_config(config_path: Path) -> dict:
|
||||
try:
|
||||
|
||||
@@ -88,6 +88,10 @@ def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
|
||||
"""Write key=value pairs to the .env file.
|
||||
|
||||
Values of ``None`` cause the key to be removed.
|
||||
|
||||
Preserves comments, blank lines, and original key order (#1164).
|
||||
New keys are appended at the end of the file with a blank-line separator.
|
||||
|
||||
Holds ``_ENV_LOCK`` from ``api.streaming`` for the entire load → modify →
|
||||
write cycle to prevent TOCTOU races between concurrent POST /api/providers
|
||||
calls (each reading the same file baseline and overwriting the other's key).
|
||||
@@ -97,11 +101,31 @@ def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
|
||||
import stat as _stat
|
||||
|
||||
with _ENV_LOCK:
|
||||
current = _load_env_file(env_path)
|
||||
# ── Read existing lines (preserving comments and blank lines) ──
|
||||
existing_lines: list[str] = []
|
||||
if env_path.exists():
|
||||
try:
|
||||
existing_lines = env_path.read_text(encoding="utf-8").splitlines()
|
||||
except Exception:
|
||||
existing_lines = []
|
||||
|
||||
# Map each existing key to its line index so we can update in-place.
|
||||
existing_key_indices: dict[str, int] = {}
|
||||
for _i, _raw in enumerate(existing_lines):
|
||||
_stripped = _raw.strip()
|
||||
if _stripped and not _stripped.startswith("#") and "=" in _stripped:
|
||||
_existing_key_indices_key = _stripped.split("=", 1)[0].strip()
|
||||
existing_key_indices[_existing_key_indices_key] = _i
|
||||
|
||||
output_lines = list(existing_lines)
|
||||
new_keys: list[str] = []
|
||||
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
current.pop(key, None)
|
||||
# Mark the line for removal (None sentinel) and clear env.
|
||||
os.environ.pop(key, None)
|
||||
if key in existing_key_indices:
|
||||
output_lines[existing_key_indices[key]] = None # type: ignore[assignment]
|
||||
continue
|
||||
clean = str(value).strip()
|
||||
if not clean:
|
||||
@@ -109,17 +133,49 @@ def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
|
||||
# Reject embedded newlines/carriage returns to prevent .env injection
|
||||
if "\n" in clean or "\r" in clean:
|
||||
raise ValueError("API key must not contain newline characters.")
|
||||
current[key] = clean
|
||||
os.environ[key] = clean
|
||||
|
||||
if key in existing_key_indices:
|
||||
output_lines[existing_key_indices[key]] = f"{key}={clean}"
|
||||
else:
|
||||
new_keys.append(f"{key}={clean}")
|
||||
|
||||
# Remove deleted lines (None sentinels)
|
||||
output_lines = [l for l in output_lines if l is not None]
|
||||
|
||||
# Append new keys after a blank-line separator
|
||||
if new_keys:
|
||||
if output_lines and output_lines[-1].strip() != "":
|
||||
output_lines.append("")
|
||||
output_lines.extend(new_keys)
|
||||
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"{key}={current[key]}" for key in sorted(current)]
|
||||
# Create at owner-only mode from the first byte (O_CREAT honours the mode
|
||||
# argument subject to umask). A trailing chmod guards pre-existing files.
|
||||
content = "\n".join(output_lines)
|
||||
if content:
|
||||
content += "\n"
|
||||
# Atomic write via tempfile + os.replace so cross-process readers
|
||||
# (Telegram bot, CLI) never see a half-truncated file. The shared
|
||||
# ``~/.hermes/.env`` is also written by ``hermes_cli.config.save_env_value``
|
||||
# using the same atomic pattern; matching it here closes the
|
||||
# cross-process leg of #1164 (within-process is covered by _ENV_LOCK).
|
||||
_mode = _stat.S_IRUSR | _stat.S_IWUSR # 0o600
|
||||
_fd = os.open(str(env_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _mode)
|
||||
with os.fdopen(_fd, "w", encoding="utf-8") as _f:
|
||||
_f.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
import tempfile as _tempfile
|
||||
_tmp_fd, _tmp_path = _tempfile.mkstemp(
|
||||
dir=str(env_path.parent), prefix=".env_", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(_tmp_fd, "w", encoding="utf-8") as _f:
|
||||
_f.write(content)
|
||||
_f.flush()
|
||||
os.fsync(_f.fileno())
|
||||
os.chmod(_tmp_path, _mode) # tighten before rename so readers see 0600
|
||||
os.replace(_tmp_path, env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(_tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
env_path.chmod(_mode)
|
||||
except OSError:
|
||||
|
||||
@@ -382,7 +382,12 @@ $('btnAttach').onclick=()=>$('fileInput').click();
|
||||
window._micActive=window._micActive||false;
|
||||
window._micPendingSend=window._micPendingSend||false;
|
||||
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
||||
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();};
|
||||
$('btnNewChat').onclick=async()=>{
|
||||
// If the current session has no messages, just focus the composer rather than
|
||||
// creating another empty session that will clutter the sidebar list (#1171).
|
||||
if(S.session&&(S.session.message_count||0)===0){$('msg').focus();closeMobileSidebar();return;}
|
||||
await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
|
||||
};
|
||||
$('btnDownload').onclick=()=>{
|
||||
if(!S.session)return;
|
||||
const blob=new Blob([transcript()],{type:'text/markdown'});
|
||||
@@ -516,6 +521,9 @@ document.addEventListener('keydown',async e=>{
|
||||
}
|
||||
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
|
||||
e.preventDefault();
|
||||
// If the current session has no messages, just focus the composer rather than
|
||||
// creating another empty session that will clutter the sidebar list (#1171).
|
||||
if(S.session&&(S.session.message_count||0)===0){$('msg').focus();return;}
|
||||
if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}
|
||||
}
|
||||
if(e.key==='Escape'){
|
||||
|
||||
@@ -1233,12 +1233,12 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.5;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
|
||||
.tool-card.open .tool-card-toggle{transform:rotate(90deg);}
|
||||
.tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;border-top:1px solid transparent;padding:0 12px;transition:max-height .22s ease,opacity .18s ease,padding .22s ease,border-top-color .22s ease;}
|
||||
.tool-card.open .tool-card-detail{max-height:520px;opacity:1;padding:8px 12px;border-top-color:rgba(255,255,255,.06);}
|
||||
.tool-card.open .tool-card-detail{max-height:600px;opacity:1;padding:8px 12px;border-top-color:rgba(255,255,255,.06);overflow:auto;}
|
||||
.tool-card-args{margin-bottom:6px;}
|
||||
.tool-card-args div{font-size:11px;line-height:1.6;}
|
||||
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;}
|
||||
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;word-break:break-all;}
|
||||
.tool-card-result pre{font-size:11px;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:180px;overflow-y:auto;margin:0;line-height:1.55;}
|
||||
.tool-card-result pre{font-size:11px;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:360px;overflow-y:auto;margin:0;line-height:1.55;}
|
||||
|
||||
/* ── Manual compression cards (transient transcript-local feedback) ── */
|
||||
.live-compression-cards{
|
||||
|
||||
64
static/ui.js
64
static/ui.js
@@ -165,6 +165,10 @@ async function populateModelDropdown(){
|
||||
|
||||
// Cache so we don't re-fetch on every page load
|
||||
const _liveModelCache={};
|
||||
// Tracks providers for which a live-model fetch is in flight.
|
||||
// Used by syncTopbar() to defer model corrections until the fetch completes,
|
||||
// preventing premature fallback to the first static model (#1169).
|
||||
const _liveModelFetchPending=new Set();
|
||||
|
||||
function _addLiveModelsToSelect(provider, models, sel){
|
||||
if(!provider||!models||!models.length||!sel) return 0;
|
||||
@@ -215,6 +219,14 @@ function _addLiveModelsToSelect(provider, models, sel){
|
||||
added++;
|
||||
}
|
||||
if(added>0 && currentVal) _applyModelToDropdown(currentVal, sel);
|
||||
// After live models are added, re-apply the session's model in case it was
|
||||
// absent from the static list and syncTopbar() fired before the live fetch
|
||||
// completed (#1169). This ensures the session model wins over any premature
|
||||
// fallback that may have set sel.value to the first available option.
|
||||
if(S.session && S.session.model && sel.id==='modelSelect'){
|
||||
const reapplied=_applyModelToDropdown(S.session.model, sel);
|
||||
if(reapplied && typeof syncModelChip==='function') syncModelChip();
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
@@ -226,6 +238,7 @@ async function _fetchLiveModels(provider, sel){
|
||||
if(added>0 && typeof syncModelChip==='function') syncModelChip();
|
||||
return;
|
||||
}
|
||||
_liveModelFetchPending.add(provider);
|
||||
try{
|
||||
const url=new URL('api/models/live',location.href);
|
||||
url.searchParams.set('provider',provider);
|
||||
@@ -241,6 +254,8 @@ async function _fetchLiveModels(provider, sel){
|
||||
}
|
||||
}catch(e){
|
||||
console.debug('[hermes] Live model fetch failed for',provider,e.message);
|
||||
}finally{
|
||||
_liveModelFetchPending.delete(provider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,12 +825,18 @@ function renderMd(raw){
|
||||
s=s.replace(/\\\\\((.+?)\\\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
s=s.replace(/\\\\\[(.+?)\\\\\]/gs,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
// Safe tag → markdown equivalent (these produce the same output as **text** etc.)
|
||||
// Stash raw <pre> blocks so the inline <code> rewrite below does not run
|
||||
// inside them. Running that rewrite in <pre> content can introduce stray
|
||||
// backticks for multiline code and break subsequent code-box rendering.
|
||||
const rawPreStash=[];
|
||||
s=s.replace(/(<pre\b[^>]*>[\s\S]*?<\/pre>)/gi,m=>{rawPreStash.push(m);return `\x00R${rawPreStash.length-1}\x00`;});
|
||||
s=s.replace(/<strong>([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
|
||||
s=s.replace(/<b>([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
|
||||
s=s.replace(/<em>([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*');
|
||||
s=s.replace(/<i>([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*');
|
||||
s=s.replace(/<code>([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`');
|
||||
s=s.replace(/<br\s*\/?>/gi,'\n');
|
||||
s=s.replace(/\x00R(\d+)\x00/g,(_,i)=>rawPreStash[+i]);
|
||||
// Restore stashed code blocks
|
||||
s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]);
|
||||
// Mermaid blocks: render as diagram containers (processed after DOM insertion)
|
||||
@@ -1832,21 +1853,30 @@ function syncTopbar(){
|
||||
// first available model so stale values don't pollute the picker (#829).
|
||||
if(!applied && currentModel){
|
||||
const deferModelCorrection=Boolean(S.session._modelResolutionDeferred);
|
||||
// Stale session model not in the current provider catalog — reset to the
|
||||
// first available model rather than injecting an "(unavailable)" option
|
||||
// that visually appears under the wrong provider group (#829).
|
||||
const modelSel=$('modelSelect');
|
||||
const first=modelSel&&modelSel.querySelector('optgroup > option, option');
|
||||
if(first){
|
||||
modelSel.value=first.value;
|
||||
if(!deferModelCorrection){
|
||||
S.session.model=first.value;
|
||||
// Persist the correction so the session doesn't re-inject on next load.
|
||||
fetch(new URL('api/session/update',location.href).href,{
|
||||
method:'POST',credentials:'include',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value})
|
||||
}).catch(()=>{});
|
||||
// Also defer if a live model fetch is still in flight — the model may be
|
||||
// in the list once the fetch completes. Persisting now would corrupt the
|
||||
// session with the wrong model before live models arrive (#1169).
|
||||
const liveStillPending=window._activeProvider&&_liveModelFetchPending.has(window._activeProvider);
|
||||
if(liveStillPending){
|
||||
// Live fetch in flight — don't touch sel.value or S.session.model yet.
|
||||
// _addLiveModelsToSelect() will re-apply S.session.model once done (#1169).
|
||||
} else {
|
||||
// Stale session model not in the current provider catalog — reset to the
|
||||
// first available model rather than injecting an "(unavailable)" option
|
||||
// that visually appears under the wrong provider group (#829).
|
||||
const modelSel=$('modelSelect');
|
||||
const first=modelSel&&modelSel.querySelector('optgroup > option, option');
|
||||
if(first){
|
||||
modelSel.value=first.value;
|
||||
if(!deferModelCorrection){
|
||||
S.session.model=first.value;
|
||||
// Persist the correction so the session doesn't re-inject on next load.
|
||||
fetch(new URL('api/session/update',location.href).href,{
|
||||
method:'POST',credentials:'include',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value})
|
||||
}).catch(()=>{});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2614,9 +2644,9 @@ function buildToolCard(tc){
|
||||
let displaySnippet='';
|
||||
if(tc.snippet){
|
||||
const s=tc.snippet;
|
||||
if(s.length<=220){displaySnippet=s;}
|
||||
if(s.length<=800){displaySnippet=s;}
|
||||
else{
|
||||
const cutoff=s.slice(0,220);
|
||||
const cutoff=s.slice(0,800);
|
||||
const lastBreak=Math.max(cutoff.lastIndexOf('. '),cutoff.lastIndexOf('\n'),cutoff.lastIndexOf('; '));
|
||||
displaySnippet=lastBreak>80?s.slice(0,lastBreak+1):cutoff;
|
||||
}
|
||||
|
||||
170
tests/test_issue1164_env_file_corruption.py
Normal file
170
tests/test_issue1164_env_file_corruption.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Regression tests for #1164 — .env file corruption by WebUI.
|
||||
|
||||
The WebUI's onboarding flow had a duplicate _write_env_file() without
|
||||
_ENV_LOCK protection. Concurrent writes (e.g. Telegram bot + WebUI) could
|
||||
corrupt the shared .env file. Additionally, both _write_env_file copies
|
||||
rewrote the entire file from a parsed dict, stripping comments and
|
||||
reordering keys alphabetically.
|
||||
|
||||
Fix:
|
||||
- onboarding.py now imports _write_env_file from providers.py (which holds
|
||||
_ENV_LOCK from api.streaming for the entire load→modify→write cycle).
|
||||
- _write_env_file in providers.py now preserves comments, blank lines, and
|
||||
original key order instead of rebuilding from a sorted dict.
|
||||
|
||||
Sprint/commit: v0.50.227+
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestEnvFileCommentPreservation(unittest.TestCase):
|
||||
"""Verify _write_env_file preserves comments, blank lines, and key order."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.env_path = Path(self.tmpdir) / ".env"
|
||||
# Must import AFTER setting up, as the module has top-level code
|
||||
from api.providers import _write_env_file
|
||||
self._write_env_file = _write_env_file
|
||||
|
||||
def tearDown(self):
|
||||
# Clean os.environ entries set during tests
|
||||
for key in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "NEW_KEY"):
|
||||
os.environ.pop(key, None)
|
||||
import shutil
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
def _read(self) -> str:
|
||||
return self.env_path.read_text(encoding="utf-8")
|
||||
|
||||
# ── Comment preservation ──────────────────────────────────────────
|
||||
|
||||
def test_comments_preserved_on_update(self):
|
||||
"""Comments in .env must survive a key value update."""
|
||||
self.env_path.write_text(textwrap.dedent("""\
|
||||
# Hermes API keys
|
||||
OPENROUTER_API_KEY=sk-or-old
|
||||
# Another comment
|
||||
OPENAI_API_KEY=sk-oai-old
|
||||
""").strip() + "\n", encoding="utf-8")
|
||||
|
||||
self._write_env_file(self.env_path, {"OPENROUTER_API_KEY": "sk-or-new"})
|
||||
|
||||
content = self._read()
|
||||
self.assertIn("# Hermes API keys", content,
|
||||
"Leading comment must be preserved")
|
||||
self.assertIn("# Another comment", content,
|
||||
"Inline comment must be preserved")
|
||||
self.assertIn("sk-or-new", content)
|
||||
|
||||
def test_blank_lines_preserved(self):
|
||||
"""Blank lines between key blocks must be preserved."""
|
||||
self.env_path.write_text(
|
||||
"KEY_A=val_a\n\nKEY_B=val_b\n", encoding="utf-8")
|
||||
|
||||
self._write_env_file(self.env_path, {"KEY_A": "updated"})
|
||||
|
||||
content = self._read()
|
||||
self.assertEqual(content.count("\n\n"), 1,
|
||||
"Blank line between keys must be preserved")
|
||||
|
||||
def test_key_order_preserved(self):
|
||||
"""Original key order must not be sorted alphabetically."""
|
||||
self.env_path.write_text(
|
||||
"ZZZ_KEY=last\nAAA_KEY=first\nBBB_KEY=middle\n",
|
||||
encoding="utf-8")
|
||||
|
||||
self._write_env_file(self.env_path, {"AAA_KEY": "updated"})
|
||||
|
||||
content = self._read()
|
||||
zzz_pos = content.find("ZZZ_KEY")
|
||||
aaa_pos = content.find("AAA_KEY")
|
||||
bbb_pos = content.find("BBB_KEY")
|
||||
# Original order: ZZZ, AAA, BBB
|
||||
self.assertLess(zzz_pos, aaa_pos,
|
||||
"ZZZ_KEY must still come before AAA_KEY (original order)")
|
||||
self.assertLess(aaa_pos, bbb_pos,
|
||||
"AAA_KEY must still come before BBB_KEY (original order)")
|
||||
|
||||
def test_new_key_appended_with_separator(self):
|
||||
"""New keys are appended at the end with a blank-line separator."""
|
||||
self.env_path.write_text(
|
||||
"EXISTING_KEY=value\n", encoding="utf-8")
|
||||
|
||||
self._write_env_file(self.env_path, {"NEW_KEY": "new_value"})
|
||||
|
||||
content = self._read()
|
||||
self.assertIn("NEW_KEY=new_value", content)
|
||||
# New key should appear after the existing one
|
||||
self.assertGreater(content.find("NEW_KEY"), content.find("EXISTING_KEY"))
|
||||
|
||||
def test_key_removal_preserves_others(self):
|
||||
"""Removing a key leaves other keys and comments intact."""
|
||||
self.env_path.write_text(textwrap.dedent("""\
|
||||
# Comment A
|
||||
KEY_A=val_a
|
||||
# Comment B
|
||||
KEY_B=val_b
|
||||
""").strip() + "\n", encoding="utf-8")
|
||||
|
||||
self._write_env_file(self.env_path, {"KEY_B": None})
|
||||
|
||||
content = self._read()
|
||||
self.assertIn("KEY_A=val_a", content)
|
||||
self.assertIn("# Comment A", content)
|
||||
self.assertNotIn("KEY_B", content)
|
||||
# Comment B stays (it's just a comment, not tied to KEY_B structurally)
|
||||
self.assertIn("# Comment B", content)
|
||||
|
||||
def test_empty_file_handled_gracefully(self):
|
||||
"""Writing to a non-existent .env file works."""
|
||||
self.assertFalse(self.env_path.exists())
|
||||
self._write_env_file(self.env_path, {"NEW_KEY": "value"})
|
||||
self.assertTrue(self.env_path.exists())
|
||||
self.assertEqual(self._read().strip(), "NEW_KEY=value")
|
||||
|
||||
|
||||
class TestOnboardingUsesProviderWriteEnv(unittest.TestCase):
|
||||
"""Verify that onboarding.py delegates to providers._write_env_file
|
||||
(which holds _ENV_LOCK), eliminating the duplicate unprotected path."""
|
||||
|
||||
def test_onboarding_imports_write_env_from_providers(self):
|
||||
"""api.onboarding._write_env_file must be the same object as
|
||||
api.providers._write_env_file (shared implementation with lock)."""
|
||||
from api import onboarding, providers
|
||||
self.assertIs(
|
||||
onboarding._write_env_file,
|
||||
providers._write_env_file,
|
||||
"onboarding must use providers._write_env_file for thread safety (#1164)"
|
||||
)
|
||||
|
||||
def test_providers_write_env_holds_env_lock(self):
|
||||
"""providers._write_env_file must acquire _ENV_LOCK from api.streaming."""
|
||||
import inspect
|
||||
from api.providers import _write_env_file
|
||||
source = inspect.getsource(_write_env_file)
|
||||
self.assertIn("_ENV_LOCK", source,
|
||||
"_write_env_file must use _ENV_LOCK for concurrency safety")
|
||||
self.assertIn("from api.streaming import _ENV_LOCK", source,
|
||||
"_ENV_LOCK must be imported from api.streaming")
|
||||
|
||||
def test_providers_write_env_uses_atomic_rename(self):
|
||||
"""providers._write_env_file must write atomically via tempfile +
|
||||
os.replace so cross-process readers (Telegram, CLI) never observe
|
||||
a truncated half-written file (#1164 cross-process leg)."""
|
||||
import inspect
|
||||
from api.providers import _write_env_file
|
||||
source = inspect.getsource(_write_env_file)
|
||||
self.assertIn("tempfile", source,
|
||||
"_write_env_file must stage writes through a tempfile")
|
||||
self.assertIn("os.replace(", source,
|
||||
"_write_env_file must atomically rename via os.replace")
|
||||
# The original O_TRUNC pattern must NOT remain — it is the source of
|
||||
# the cross-process race the PR is closing.
|
||||
self.assertNotIn("O_TRUNC", source,
|
||||
"_write_env_file must not truncate-in-place (#1164)")
|
||||
@@ -147,14 +147,17 @@ def test_toggle_mobile_files_js_defined():
|
||||
def test_new_conversation_closes_mobile_sidebar():
|
||||
"""New conversation must close the mobile drawer so the chat pane is visible immediately."""
|
||||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
click_line = next((ln for ln in boot_js.splitlines() if "$('btnNewChat').onclick" in ln), "")
|
||||
assert click_line, "btnNewChat onclick handler missing from static/boot.js"
|
||||
assert "closeMobileSidebar" in click_line, \
|
||||
# Handler is now multi-line — search for the full block rather than a single line.
|
||||
assert "$('btnNewChat').onclick" in boot_js, "btnNewChat onclick handler missing from static/boot.js"
|
||||
# Find the handler block and verify closeMobileSidebar appears in it.
|
||||
idx = boot_js.find("$('btnNewChat').onclick")
|
||||
handler_block = boot_js[idx:idx+500]
|
||||
assert "closeMobileSidebar" in handler_block, \
|
||||
"btnNewChat handler must closeMobileSidebar() after creating the new session"
|
||||
|
||||
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
|
||||
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
|
||||
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+4])
|
||||
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+6])
|
||||
assert "closeMobileSidebar" in shortcut_block, \
|
||||
"Cmd/Ctrl+K new chat shortcut must closeMobileSidebar() after creating the new session"
|
||||
|
||||
|
||||
@@ -461,3 +461,30 @@ class TestBlockquoteEntityEncodedInput:
|
||||
f"Entity-encoded blockquote with fenced code must render: {out!r}"
|
||||
)
|
||||
assert "<pre>" in out, f"Fenced code inside entity-encoded blockquote must render: {out!r}"
|
||||
|
||||
|
||||
class TestRawPreCodePreservation:
|
||||
"""Raw <pre><code> HTML from model output should remain structurally intact."""
|
||||
|
||||
def test_multiline_pre_code_blocks_do_not_degrade_to_backticks(self, driver_path):
|
||||
src = (
|
||||
"<pre><code>line 1\n"
|
||||
"line 2\n"
|
||||
"</code></pre>\n\n"
|
||||
"After paragraph.\n\n"
|
||||
"<pre><code>line 3\n"
|
||||
"line 4\n"
|
||||
"</code></pre>\n\n"
|
||||
"Done."
|
||||
)
|
||||
out = _render(driver_path, src)
|
||||
assert out.count("<pre>") == 2 and out.count("</pre>") == 2, (
|
||||
f"Expected two balanced <pre> blocks, got: {out!r}"
|
||||
)
|
||||
assert out.count("<code>") == 2 and out.count("</code>") == 2, (
|
||||
f"Expected two balanced <code> blocks, got: {out!r}"
|
||||
)
|
||||
assert "`line 1" not in out and "line 2\n`</pre>" not in out, (
|
||||
f"<code> content inside <pre> must not be rewritten to backticks: {out!r}"
|
||||
)
|
||||
assert "After paragraph." in out and "Done." in out
|
||||
|
||||
@@ -16,7 +16,12 @@ def test_tool_card_toggle_uses_transformable_layout_and_transition():
|
||||
def test_tool_card_detail_uses_transitionable_collapsed_state():
|
||||
assert ".tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;" in COMPACT_CSS
|
||||
assert re.search(
|
||||
r"\.tool-card\.open\s+\.tool-card-detail\s*\{[^}]*max-height:\s*520px;[^}]*opacity:\s*1;",
|
||||
r"\.tool-card\.open\s+\.tool-card-detail\s*\{[^}]*max-height:\s*600px;[^}]*opacity:\s*1;",
|
||||
STYLE_CSS,
|
||||
)
|
||||
# Open state must set overflow to auto so the inner <pre> scroll is not clipped (#1170).
|
||||
assert re.search(
|
||||
r"\.tool-card\.open\s+\.tool-card-detail\s*\{[^}]*overflow:\s*auto;",
|
||||
STYLE_CSS,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user