diff --git a/core/platform_compat.py b/core/platform_compat.py
index efa496ac6..e667c1e96 100644
--- a/core/platform_compat.py
+++ b/core/platform_compat.py
@@ -36,6 +36,19 @@ IS_APPLE_SILICON = (
)
+# ── procfs ──────────────────────────────────────────────────────────────────
+# Linux exposes one directory per pid under /proc; macOS and Windows have no
+# procfs at all. Any code that walks it must skip the walk rather than raise.
+# Kept as a module attribute so both branches stay testable on either kind of
+# host.
+PROC_ROOT = Path("/proc")
+
+
+def has_procfs() -> bool:
+ """True when the host exposes a procfs pid tree that can be scanned."""
+ return PROC_ROOT.is_dir()
+
+
# ── File permissions ────────────────────────────────────────────────────────
def safe_chmod(path, mode: int) -> bool:
"""``os.chmod`` that is a harmless no-op on Windows.
diff --git a/routes/mcp/mcp_routes.py b/routes/mcp/mcp_routes.py
index 94c83f8dd..0e2b30276 100644
--- a/routes/mcp/mcp_routes.py
+++ b/routes/mcp/mcp_routes.py
@@ -181,10 +181,17 @@ def setup_mcp_routes(mcp_manager: McpManager):
if transport == "http" and not url:
raise HTTPException(400, "url is required for HTTP transport")
- # Parse JSON fields
- try:
- parsed_args = json.loads(args) if args else []
- except json.JSONDecodeError:
+ # Parse JSON fields. args is not defaulted on a parse failure: an
+ # unparseable value is silently discarded downstream (stdio spawns
+ # with an empty argv), so the caller must be told instead.
+ if args:
+ try:
+ parsed_args = json.loads(args)
+ except json.JSONDecodeError:
+ raise HTTPException(400, "args must be valid JSON, e.g. [\"-y\", \"pkg\"]")
+ if not isinstance(parsed_args, list):
+ raise HTTPException(400, "args must be a JSON array, e.g. [\"-y\", \"pkg\"]")
+ else:
parsed_args = []
try:
parsed_env = json.loads(env) if env else {}
diff --git a/src/model_discovery.py b/src/model_discovery.py
index 4d67502c5..116951f9d 100644
--- a/src/model_discovery.py
+++ b/src/model_discovery.py
@@ -38,7 +38,10 @@ def discover_tailscale_hosts() -> List[str]:
global _hosts_cache, _hosts_cache_time
now = time.time()
- if _hosts_cache and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL:
+ # Gate on the timestamp, not the list: a successful query that found no
+ # eligible peers is a real answer, and testing the list's truthiness made
+ # that case re-run `tailscale status` (up to a 5s timeout) on every call.
+ if _hosts_cache_time and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL:
return list(_hosts_cache)
hosts = []
diff --git a/src/tools/cookbook.py b/src/tools/cookbook.py
index 96529d94e..c2def959b 100644
--- a/src/tools/cookbook.py
+++ b/src/tools/cookbook.py
@@ -17,6 +17,7 @@ import re
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
+from core import platform_compat
from routes._validators import validate_remote_host, validate_ssh_port
from src.tools._common import _parse_tool_args
@@ -676,17 +677,17 @@ def _scan_running_model_processes() -> List[Dict[str, Any]]:
a dict shaped like a cookbook task so the caller can merge cleanly.
"""
import os
- if not os.path.isdir("/proc"):
+ if not platform_compat.has_procfs():
return []
+ proc_root = platform_compat.PROC_ROOT
out: List[Dict[str, Any]] = []
seen_keys = set()
try:
- for pid_dir in os.listdir("/proc"):
+ for pid_dir in os.listdir(proc_root):
if not pid_dir.isdigit():
continue
try:
- with open(f"/proc/{pid_dir}/cmdline", "rb") as f:
- raw = f.read()
+ raw = (proc_root / pid_dir / "cmdline").read_bytes()
except (OSError, PermissionError):
continue
if not raw:
@@ -1124,12 +1125,16 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
import signal
tracked_cmd = str((matched.get("payload") or {}).get("_cmd") or "").strip()
matched_pids: list[int] = []
- if tracked_cmd:
- for pid_name in os.listdir("/proc"):
+ # No procfs means no way to match a survivor by its command line.
+ # The tmux kill above already stopped the session, so skip the
+ # sweep instead of failing a stop that worked.
+ if tracked_cmd and platform_compat.has_procfs():
+ proc_root = platform_compat.PROC_ROOT
+ for pid_name in os.listdir(proc_root):
if not pid_name.isdigit() or int(pid_name) == os.getpid():
continue
try:
- raw = open(f"/proc/{pid_name}/cmdline", "rb").read()
+ raw = (proc_root / pid_name / "cmdline").read_bytes()
process_cmd = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip()
except (OSError, PermissionError):
continue
diff --git a/static/css/cookbook-research-memory-settings.css b/static/css/cookbook-research-memory-settings.css
index 3bcbb08cd..67f425dae 100644
--- a/static/css/cookbook-research-memory-settings.css
+++ b/static/css/cookbook-research-memory-settings.css
@@ -28,10 +28,6 @@
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.7; }
}
-@keyframes research-pulse {
- 0%, 100% { background: color-mix(in srgb, var(--red) 12%, transparent); }
- 50% { background: color-mix(in srgb, var(--red) 22%, transparent); }
- }
#cookbook-gguf-delete-overlay {
background:rgba(0,0,0,0.5);
backdrop-filter:blur(4px);
diff --git a/static/js/admin.js b/static/js/admin.js
index 3e3463e9e..9a7ac8bf9 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -3142,6 +3142,7 @@ function initMcpForm() {
if (transport === 'stdio' && !command) { msg.textContent = 'Command is required for stdio'; msg.className = 'admin-error'; return; }
if (transport === 'sse' && !url) { msg.textContent = 'URL is required for SSE'; msg.className = 'admin-error'; return; }
try { JSON.parse(env); } catch { msg.textContent = 'Env must be valid JSON'; msg.className = 'admin-error'; return; }
+ try { JSON.parse(args); } catch { msg.textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; msg.className = 'admin-error'; return; }
const fd = new FormData();
fd.append('name', name); fd.append('transport', transport); fd.append('command', command); fd.append('args', args); fd.append('env', env); fd.append('url', url);
// If preset has oauthFile config, send credentials for file generation
@@ -3162,6 +3163,10 @@ function initMcpForm() {
try {
const res = await fetch('/api/mcp/servers', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json();
+ if (!res.ok) {
+ msg.textContent = data.detail || `Failed (${res.status})`; msg.className = 'admin-error';
+ return;
+ }
if (data.needs_oauth) {
msg.innerHTML = `Added ${esc(name)} — Authorize with Google to connect`;
msg.className = 'admin-success';
diff --git a/static/js/settings.js b/static/js/settings.js
index d6c13558c..c59b751e1 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -5102,7 +5102,11 @@ async function initUnifiedIntegrations() {
fd.append('transport', transport);
if (transport === 'stdio') {
fd.append('command', el('uf-mcp-cmd').value);
- let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {}
+ // Unlike env below, an unparseable args value is not silently
+ // defaulted: it would spawn the subprocess with an empty argv.
+ let args;
+ try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); }
+ catch (_) { el('uf-mcp-msg').textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; return; }
let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {}
fd.append('args', args);
fd.append('env', env);
@@ -5124,7 +5128,12 @@ async function initUnifiedIntegrations() {
} else if (r.ok) {
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
} else {
- el('uf-mcp-msg').textContent = `Failed (${r.status})`;
+ // Surface the server's reason. The Args validation above rejects
+ // unparseable JSON, but `"x"` and `{}` parse and are refused by
+ // routes/mcp/mcp_routes.py with a message naming the expected
+ // shape; a bare status code sends the user looking in the wrong
+ // place. Matches what admin.js shows for the same endpoint.
+ el('uf-mcp-msg').textContent = data.detail || `Failed (${r.status})`;
}
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
diff --git a/static/style.css b/static/style.css
index 34711963c..94788c585 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1480,7 +1480,6 @@ body.bg-pattern-ascii-fireflies {
0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); }
100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.94); }
}
- @keyframes spin { to { transform: rotate(360deg); } }
.row { display:flex; gap:6px; align-items:center; }
.list-item:hover,
.models-row:hover {
@@ -6119,9 +6118,6 @@ body.bg-pattern-ascii-fireflies {
#mobile-backdrop, #mobile-menu-btn { display:none !important; }
#sidebar-backdrop { display:none !important; }
/* ----- Loading spinner ----- */
- @keyframes spin {
- to { transform: rotate(360deg); }
- }
.spinner {
width: 24px;
height: 24px;
@@ -6240,14 +6236,6 @@ body.bg-pattern-ascii-fireflies {
.session-skeleton-bubble.is-user { width: 72%; }
.session-skeleton-line { height: 9px; margin-top: 7px; }
}
- @keyframes loading-bounce {
- 0%, 80%, 100% {
- transform: scale(0);
- }
- 40% {
- transform: scale(1);
- }
- }
/* Modal styling */
.modal {
position:fixed;
@@ -7555,11 +7543,6 @@ body.bg-pattern-ascii-fireflies {
animation: pulse 1.5s infinite;
}
- @keyframes pulse {
- 0% { opacity: 1; }
- 50% { opacity: 0.7; }
- 100% { opacity: 1; }
- }
#recording-indicator {
position: fixed;
@@ -8720,7 +8703,6 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; }
}
.compare-probe-start-anyway svg { color: currentColor; }
.compare-probe-start-anyway span { font-weight: 700; }
- @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes pane-shake {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-3px) rotate(-0.5deg); }
diff --git a/tests/KNOWN_FAILURES.md b/tests/KNOWN_FAILURES.md
new file mode 100644
index 000000000..aa7f22fc7
--- /dev/null
+++ b/tests/KNOWN_FAILURES.md
@@ -0,0 +1,133 @@
+# Known full-suite failures
+
+`python -m pytest -q` does not come back clean on every machine, and it never
+has. Without a list of which failures are expected, a first local run is
+uninterpretable: you cannot tell "you broke something" from "you are on a Mac",
+so the usual result is either chasing a non-bug or ignoring a real one.
+
+This is that list. It is a record of observation, not a permission slip: a test
+here is still a test that does not pass, and the three that remain below are
+all still worth someone's time.
+
+Last measured: `lab @ c499c01b` plus the fixes in this change, macOS 15 on
+Apple Silicon, Python 3.11, with the **default** `$TMPDIR` — see the socket
+entry below for why that qualifier is load-bearing.
+
+```
+3 failed, 10658 passed, 6 skipped
+```
+
+## Get the prerequisites right first
+
+Most "surprise" failures are a missing dependency rather than anything in this
+file. A clean run needs all of:
+
+```bash
+python3.11 -m venv venv
+./venv/bin/python -m pip install -r requirements.txt
+npm ci # the browser tests shell out to node
+npx playwright install chromium # ~30 tests drive a real browser
+mkdir -p data # SQLite lives at ./data/app.db
+```
+
+plus `ffmpeg` on `PATH` for the media tests.
+
+If you already have a ChromaDB running, point `CHROMADB_PORT` at a closed port
+for the run. The client reaches Chroma over HTTP regardless of the data
+directory, so a test run will otherwise attach to whatever store is listening,
+including one holding real data.
+
+Miss `npm ci` and roughly 36 browser tests fail on `Cannot find package
+'playwright'`. That is not a regression, it is the missing install.
+
+## The three that remain, and the seven that no longer do
+
+### Test bugs: comparing an unresolved path against a resolved one
+
+Three failures compared an unresolved `/tmp` path against a resolved
+`/private/tmp` one, and are fixed rather than listed:
+
+- `tests/test_code_nav_tools.py` (two tests) built a fixture under
+ `tempfile.mkdtemp(dir="/tmp")` and compared it against the path the code
+ reports, which it resolves.
+- `tests/test_workspace_confine.py::test_glob_confined_e2e` mixed
+ `os.path.realpath(ws)` with an unresolved secret directory, so `relpath`
+ produced `../../../../tmp/` and the assertion that the
+ absolute path was absent matched it as a substring.
+
+Both now resolve consistently. They are recorded here because the shape recurs:
+on macOS, mixing a resolved and an unresolved temp path is a test bug that
+looks like a platform failure.
+
+### Test bugs: a temp path too long to bind a socket to
+
+Four more, same family, invisible unless `$TMPDIR` is long enough:
+
+- `tests/test_shell_routes.py::TestHostDockerAccess` (three tests)
+- `tests/test_cookbook_docker_access.py::test_container_opt_in_with_unix_socket_is_allowed`
+
+```
+OSError: AF_UNIX path too long
+```
+
+Each bound an `AF_UNIX` socket at `tmp_path / "docker.sock"`. macOS gives
+`sun_path` 104 bytes including the terminator, and pytest's `tmp_path` is
+rooted at `$TMPDIR`, which on a stock Mac is a 49-character
+`/var/folders/<2>/<30>/T/`. Add `pytest-of-/pytest-/` and the test's
+own name and the bind path is 115 bytes before the filename.
+
+This is why the counts above depend on where you run from: under a shortened
+`$TMPDIR` the path lands at 103 and the tests pass, and it tips over the moment
+pytest's run counter reaches two digits. Linux allows 108 bytes and roots
+`$TMPDIR` at `/tmp`, so it never bites there and CI stays green.
+
+They now bind through `tests/helpers/unix_sockets.bound_unix_socket`, which
+puts the socket under a short directory. **Measure with the default `$TMPDIR`**
+— `env -u TMPDIR` or an explicit `/var/folders/...` — or this whole file
+records a run nobody else has.
+
+### Optional dependency: ffmpeg without a WebP encoder
+
+- `tests/test_inspect_media_tool.py::test_inspect_media_exports_final_decodable_frame_at_exact_duration`
+
+```
+ffmpeg still extraction failed: Automatic encoder selection failed ...
+Error opening output files: Encoder not found
+```
+
+The test asks ffmpeg for a `.webp` still and asserts `exit_code == 0`. WebP
+encoding is a build option, and Homebrew's ffmpeg does not always carry it. CI
+installs a build that does, which is why this is green there.
+
+**Needs a decision**: skip when the encoder is absent, or fall back to PNG. The
+current shape asserts success from a codec that is not guaranteed present.
+
+### Environmental: real sockets
+
+- `tests/test_integration_api_call_ssrf.py::test_real_socket_falls_back_from_dead_first_to_live_second`
+
+```
+httpcore.ConnectTimeout / httpx.ConnectTimeout
+```
+
+Opens real sockets and depends on a connection to a dead address being refused
+quickly rather than hanging. Sandboxed and restricted-network machines time out
+instead. Genuinely environmental.
+
+### Unexplained: rich-text colour contrast
+
+- `tests/test_document_rich_color_reset_and_contrast.py::test_rich_colors_follow_theme_and_undo_as_one_edit`
+
+A Playwright run times out waiting for `#doc-email-richbody p` to contain a
+`span` after a colour is applied.
+
+**This one is not flaky.** Three consecutive runs failed identically, each at
+about 31 seconds. It was previously written off as timing noise and that was
+wrong. The cause is not established, and until it is, treat it as a possible
+real defect in the rich-text colour path rather than a platform artifact.
+
+## Keeping this current
+
+Re-measure on a clean checkout of `lab` with the prerequisites above, and
+update the header revision, the counts and any entry that changed. A failure
+that appears and is not listed here is a regression until shown otherwise.
diff --git a/tests/README.md b/tests/README.md
index 085cb5f84..9d5d1f80a 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -33,6 +33,8 @@ the sub-area. The `area_*` names are registered in `pyproject.toml`; the dynamic
`sub_*` names are registered before collection by `pytest_configure` in
`tests/conftest.py`, so unknown-mark warnings still flag genuine typos.
+The full suite does not come back clean on every machine. [KNOWN_FAILURES.md](KNOWN_FAILURES.md) lists which failures are expected, which are test bugs worth fixing, and the prerequisites a clean run needs; anything not on that list is a regression until shown otherwise.
+
For common focused runs, use `tests/run_focus.py`. It validates area and
sub-area names, accepts sub-areas with or without the `sub_` prefix, and passes
extra pytest arguments after `--`:
diff --git a/tests/conftest.py b/tests/conftest.py
index 5fcf02113..97e249b41 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -159,3 +159,43 @@ def _serve_test_static():
os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = previous_origin
server.shutdown()
server.server_close()
+
+
+@pytest.fixture(autouse=True)
+def _no_leaked_module_stubs():
+ """Fail the test that leaves a bare ``src.*``/``core.*`` stub behind.
+
+ Several test modules install empty stand-in modules so an import-heavy
+ production module can be loaded under the mocks above. When one of those
+ writes is not undone, the stub stays in ``sys.modules`` for the rest of the
+ session and every later test that imports the real module silently gets an
+ empty one instead. The suite still passes as a whole, because the victims
+ usually run before the leak; it only breaks under a different collection
+ order, which is why this class of bug reaches CI green.
+
+ This fixture is declared in the root conftest, so it is set up before any
+ test-module fixture and torn down after all of them — a stub that a test's
+ own teardown removes is not reported. The leaked entries are dropped here
+ as well as reported, so the failure stays attributed to the test that
+ introduced it instead of cascading into the rest of the run.
+
+ Bare stubs present before the test starts are ignored: this guards against
+ new leaks, it does not police import state the session began with.
+ """
+ from tests.helpers.import_state import bare_module_stubs, clear_module
+
+ before = bare_module_stubs()
+ yield
+ leaked = sorted(bare_module_stubs() - before)
+ if not leaked:
+ return
+ for name in leaked:
+ clear_module(name)
+ pytest.fail(
+ "test left bare module stub(s) in sys.modules: "
+ + ", ".join(leaked)
+ + ". Register the stub through monkeypatch.setitem(sys.modules, ...) "
+ "or tests.helpers.import_state.preserve_import_state so it is undone "
+ "at teardown.",
+ pytrace=False,
+ )
diff --git a/tests/helpers/import_state.py b/tests/helpers/import_state.py
index 0eea62d9d..f58c86c37 100644
--- a/tests/helpers/import_state.py
+++ b/tests/helpers/import_state.py
@@ -31,6 +31,7 @@ safe for callers that pass both a parent package and a child module.
"""
import sys
+import types
from contextlib import contextmanager
_ABSENT = object()
@@ -167,3 +168,33 @@ def preserve_import_state(*module_names):
# Phase 2: restore all parent-package attributes.
for name, (_, saved_attr) in saved.items():
_restore_parent_attr(name, saved_attr)
+
+
+# Names under these prefixes are the ones a leaked stub actually breaks: a
+# later test doing ``import src.x`` or ``import core.x`` silently gets the
+# empty stub instead of the real module.
+_GUARDED_PREFIXES = ("src.", "core.")
+
+
+def bare_module_stubs():
+ """Return the ``src.*``/``core.*`` names currently bound to a bare stub.
+
+ A bare stub is a plain :class:`types.ModuleType` with no on-disk
+ ``__file__`` — the object ``types.ModuleType(name)`` produces. That is the
+ same "is this a fake?" test the ``clear_fake_*`` helpers above use, so a
+ module imported from disk is never reported.
+
+ ``MagicMock`` stand-ins are deliberately out of scope: they answer every
+ attribute, so they fail loudly at use rather than silently, and several
+ test modules install them on purpose.
+ """
+ found = set()
+ for name, mod in list(sys.modules.items()):
+ if not name.startswith(_GUARDED_PREFIXES):
+ continue
+ if type(mod) is not types.ModuleType:
+ continue
+ if getattr(mod, "__file__", None):
+ continue
+ found.add(name)
+ return found
diff --git a/tests/helpers/unix_sockets.py b/tests/helpers/unix_sockets.py
new file mode 100644
index 000000000..c8ddcb2cc
--- /dev/null
+++ b/tests/helpers/unix_sockets.py
@@ -0,0 +1,46 @@
+"""Bind an AF_UNIX socket at a path the kernel will actually accept.
+
+``sun_path`` is 104 bytes on macOS, terminator included, so a bind path longer
+than 103 characters fails with ``OSError: AF_UNIX path too long``. pytest's
+``tmp_path`` is rooted at ``$TMPDIR``, which on stock macOS is a 49-character
+``/var/folders/<2>/<30>/T/`` path; adding ``pytest-of-/pytest-/`` and
+the test's own (truncated) name spends the rest of the budget before the
+filename is appended.
+
+That is why this reads as flaky rather than broken. Linux allows 108 bytes and
+roots ``$TMPDIR`` at ``/tmp``, so it never bites there; on macOS whether it
+bites depends on the length of ``$TMPDIR``, the test's name, and how many
+digits pytest's run counter is currently using. A run under a shortened
+``$TMPDIR`` passes, the same checkout under the default one does not.
+
+The path is resolved before it is handed back, for the same reason the rest of
+this change resolves temp paths: on macOS ``/tmp`` is a symlink to
+``/private/tmp``, and a test that binds one spelling and asserts on the other
+is comparing two names for the same socket.
+"""
+
+import os
+import shutil
+import socket
+import tempfile
+from contextlib import contextmanager
+
+# Short enough to leave room for the socket's own name under every platform's
+# sun_path budget. A relative root would depend on the working directory.
+_SHORT_ROOT = os.path.realpath(tempfile.gettempdir() if os.name == "nt" else "/tmp")
+
+
+@contextmanager
+def bound_unix_socket(name="docker.sock"):
+ """Yield the path of a listening AF_UNIX socket, cleaned up on exit."""
+ directory = os.path.realpath(tempfile.mkdtemp(prefix="odysseus-sock-", dir=_SHORT_ROOT))
+ path = os.path.join(directory, name)
+ if len(path) > 103: # pragma: no cover - guards the guard
+ raise AssertionError(f"socket path is {len(path)} bytes, over the limit: {path}")
+ sock = socket.socket(socket.AF_UNIX)
+ try:
+ sock.bind(path)
+ yield path
+ finally:
+ sock.close()
+ shutil.rmtree(directory, ignore_errors=True)
diff --git a/tests/test_agent_thread_chevron_static.py b/tests/test_agent_thread_chevron_static.py
index 62c0103f8..413506e23 100644
--- a/tests/test_agent_thread_chevron_static.py
+++ b/tests/test_agent_thread_chevron_static.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_agent_thread_chevron_uses_css_shape_in_live_and_history_renderers():
live = (ROOT / "static/js/chat.js").read_text()
history = (ROOT / "static/js/chatRenderer.js").read_text()
- css = (ROOT / "static/style.css").read_text()
+ css = app_css()
for src in (live, history):
assert 'class="agent-thread-chevron" aria-hidden="true">' in src
diff --git a/tests/test_agent_thread_dot_alignment_css.py b/tests/test_agent_thread_dot_alignment_css.py
index 5c3558f7b..f0f394225 100644
--- a/tests/test_agent_thread_dot_alignment_css.py
+++ b/tests/test_agent_thread_dot_alignment_css.py
@@ -14,11 +14,10 @@ file identifies the breakpoint it belongs to.
import re
from pathlib import Path
+from tests.helpers.stylesheets import app_css
-CSS = (Path(__file__).resolve().parents[1] / "static" / "style.css").read_text(
- encoding="utf-8"
-)
+CSS = app_css()
THREAD = r"^[ \t]*\.agent-thread[ \t]*\{"
RAIL = r"^[ \t]*\.agent-thread::before[ \t]*\{"
diff --git a/tests/test_auth_regressions.py b/tests/test_auth_regressions.py
index 62b479748..b7403f4ca 100644
--- a/tests/test_auth_regressions.py
+++ b/tests/test_auth_regressions.py
@@ -21,7 +21,7 @@ from unittest.mock import MagicMock
# (Same trick as test_null_owner_gates.py — the real modules instantiate
# SQLAlchemy declarative classes at import-time which blow up under the
# conftest's `sqlalchemy.*` MagicMock stubs.)
-def _ensure_stub(name: str, **attrs):
+def _ensure_stub(monkeypatch, name: str, **attrs):
"""Create or augment a stub module with the given attributes.
Augments existing entries because earlier-run tests may have already
stubbed the same module with a different attribute set.
@@ -48,7 +48,7 @@ def _ensure_stub(name: str, **attrs):
*parent_name.split("."),
)
parent.__path__ = [real_path] if os.path.isdir(real_path) else []
- sys.modules[parent_name] = parent
+ monkeypatch.setitem(sys.modules, parent_name, parent)
else:
parent = sys.modules[parent_name]
else:
@@ -58,17 +58,17 @@ def _ensure_stub(name: str, **attrs):
mod = sys.modules.get(name)
if mod is None:
mod = types.ModuleType(name)
- sys.modules[name] = mod
+ monkeypatch.setitem(sys.modules, name, mod)
for k, v in attrs.items():
if not hasattr(mod, k):
- setattr(mod, k, v)
+ monkeypatch.setattr(mod, k, v, raising=False)
if parent is not None and not hasattr(parent, child_name):
- setattr(parent, child_name, mod)
+ monkeypatch.setattr(parent, child_name, mod, raising=False)
return mod
@pytest.fixture(autouse=True)
def _auth_regressions_stubs(monkeypatch):
- db = _ensure_stub("core.database",
+ db = _ensure_stub(monkeypatch, "core.database",
SessionLocal=MagicMock(), ScheduledTask=MagicMock(), TaskRun=MagicMock(),
ModelEndpoint=MagicMock(), Session=MagicMock(), ChatMessage=MagicMock(),
CalendarCal=MagicMock(), CalendarEvent=MagicMock(),
@@ -76,17 +76,18 @@ def _auth_regressions_stubs(monkeypatch):
GalleryImage=MagicMock(), GalleryAlbum=MagicMock(), Note=MagicMock(),
McpServer=MagicMock(),
)
- auth = _ensure_stub("core.auth", AuthManager=MagicMock())
- ep = _ensure_stub("src.endpoint_resolver",
+ auth = _ensure_stub(monkeypatch, "core.auth", AuthManager=MagicMock())
+ ep = _ensure_stub(monkeypatch, "src.endpoint_resolver",
resolve_endpoint=MagicMock(return_value=("", "", {})),
normalize_base=MagicMock(),
build_chat_url=MagicMock(),
build_models_url=MagicMock(),
build_headers=MagicMock(),
)
- monkeypatch.setitem(sys.modules, "core.database", db)
- monkeypatch.setitem(sys.modules, "core.auth", auth)
- monkeypatch.setitem(sys.modules, "src.endpoint_resolver", ep)
+ # _ensure_stub now registers each stub through monkeypatch itself, so the
+ # whole set is undone at teardown. Re-setting them here would capture the
+ # stub as the restore target and leave it behind for the rest of the run.
+ assert db and auth and ep
from fastapi import HTTPException
@@ -293,7 +294,7 @@ def test_research_spinoff_rejects_wrong_owner():
# pop_notifications owner filter
# ---------------------------------------------------------------------------
-def test_pop_notifications_owner_filtered():
+def test_pop_notifications_owner_filtered(monkeypatch):
"""pop_notifications(owner='alice') must return only alice's items.
bob's and legacy ownerless items stay behind in the queue."""
# Build a minimal scheduler instance that we can hit directly.
@@ -302,11 +303,15 @@ def test_pop_notifications_owner_filtered():
import sys, types
from unittest.mock import MagicMock as _MM
# `task_scheduler` pulls in lots of helpers — stub the ones it uses.
+ # monkeypatch.setitem, not a bare assignment: a plain write leaves these
+ # empty stubs in sys.modules for the rest of the session, and every later
+ # test that imports a real name from one of them fails with
+ # "cannot import name ... (unknown location)". The stubs above in this file
+ # already use monkeypatch for the same reason.
for s in ["src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver",
"src.agent_loop", "src.session_manager"]:
if s not in sys.modules:
- mod = types.ModuleType(s)
- sys.modules[s] = mod
+ monkeypatch.setitem(sys.modules, s, types.ModuleType(s))
from src.task_scheduler import TaskScheduler
sch = TaskScheduler.__new__(TaskScheduler) # bypass __init__ network etc.
sch._pending_notifications = []
diff --git a/tests/test_background_chat_completion_ui_static.py b/tests/test_background_chat_completion_ui_static.py
index 3a7f50b51..a64f7fb47 100644
--- a/tests/test_background_chat_completion_ui_static.py
+++ b/tests/test_background_chat_completion_ui_static.py
@@ -1,10 +1,11 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
CHAT = (ROOT / "static/js/chat.js").read_text()
SESSIONS = (ROOT / "static/js/sessions.js").read_text()
-CSS = (ROOT / "static/style.css").read_text()
+CSS = app_css()
def test_queued_prompts_are_persisted_per_session_and_restored_on_return():
diff --git a/tests/test_calendar_event_contrast.py b/tests/test_calendar_event_contrast.py
index 1558551a5..ac692e451 100644
--- a/tests/test_calendar_event_contrast.py
+++ b/tests/test_calendar_event_contrast.py
@@ -4,11 +4,12 @@ import subprocess
from pathlib import Path
import pytest
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
CALENDAR_JS = ROOT / "static" / "js" / "calendar.js"
-STYLE_CSS = ROOT / "static" / "style.css"
+STYLE_CSS_TEXT = app_css()
UTILS_JS = ROOT / "static" / "js" / "calendar" / "utils.js"
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
@@ -65,7 +66,7 @@ def test_calendar_readable_text_color_keeps_light_text_for_dark_colors():
def test_calendar_event_surfaces_use_computed_foreground_variable():
calendar_js = CALENDAR_JS.read_text(encoding="utf-8")
- style_css = STYLE_CSS.read_text(encoding="utf-8")
+ style_css = STYLE_CSS_TEXT
utils_js = UTILS_JS.read_text(encoding="utf-8")
assert "_calReadableTextColor" in utils_js
diff --git a/tests/test_card_menu_style_contract.py b/tests/test_card_menu_style_contract.py
index f32fb3950..f073a56d2 100644
--- a/tests/test_card_menu_style_contract.py
+++ b/tests/test_card_menu_style_contract.py
@@ -1,8 +1,9 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
LIBRARY = (ROOT / "static/js/documentLibrary.js").read_text(encoding="utf-8")
diff --git a/tests/test_chat_mode_pill_static.py b/tests/test_chat_mode_pill_static.py
index 6ef49150b..8cf375bcf 100644
--- a/tests/test_chat_mode_pill_static.py
+++ b/tests/test_chat_mode_pill_static.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_user_mode_pill_is_rendered_and_live_updated():
renderer = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
chat = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
- styles = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ styles = app_css()
routes = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
helpers = (ROOT / "routes/chat_helpers.py").read_text(encoding="utf-8")
diff --git a/tests/test_chatgpt_reasoning_and_grouping.py b/tests/test_chatgpt_reasoning_and_grouping.py
index 9c36ad474..ebdbbc0fa 100644
--- a/tests/test_chatgpt_reasoning_and_grouping.py
+++ b/tests/test_chatgpt_reasoning_and_grouping.py
@@ -12,6 +12,7 @@ import subprocess
import pytest
from src import chatgpt_subscription, llm_core
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).parents[1]
@@ -101,7 +102,7 @@ def test_model_picker_source_invariants():
def test_composer_reasoning_effort_ui_markup():
"""Verify static/index.html and static/style.css include reasoning effort controls."""
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
# HTML elements
assert 'id="reasoning-effort-wrap"' in html
assert 'id="reasoning-effort-btn"' in html
diff --git a/tests/test_chatgpt_subscription_usage_js.py b/tests/test_chatgpt_subscription_usage_js.py
index 401cef6de..6014a85e9 100644
--- a/tests/test_chatgpt_subscription_usage_js.py
+++ b/tests/test_chatgpt_subscription_usage_js.py
@@ -6,11 +6,12 @@ import subprocess
from pathlib import Path
import pytest
+from tests.helpers.stylesheets import app_css
_REPO = Path(__file__).resolve().parent.parent
_MODULE = _REPO / "static" / "js" / "chatgptSubscriptionUsage.js"
_ADMIN = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
-_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
+_STYLE = app_css()
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
@@ -269,7 +270,7 @@ def test_refresh_and_reconnect_handlers_target_only_the_clicked_account():
def test_admin_renders_chatgpt_usage_collapsible_and_styled():
admin_source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
- style_source = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
+ style_source = app_css()
load_block = admin_source[admin_source.index("async function loadEndpoints()"):admin_source.index("function initEndpointForm()")]
assert "adm-chatgpt-controls" in load_block
assert "adm-chatgpt-usage-toggle" in load_block
diff --git a/tests/test_code_nav_tools.py b/tests/test_code_nav_tools.py
index 2c472be9f..33fd4c8d8 100644
--- a/tests/test_code_nav_tools.py
+++ b/tests/test_code_nav_tools.py
@@ -17,7 +17,10 @@ def _run(tool, content):
@pytest.fixture
def repo():
# Built under /tmp, which is on the default tool-path allowlist.
- root = tempfile.mkdtemp(dir="/tmp", prefix="codenav_")
+ # realpath because the code under test resolves the path it reports, and on
+ # macOS /tmp is a symlink to /private/tmp: comparing the unresolved path
+ # against the resolved one fails on a file both sides found correctly.
+ root = os.path.realpath(tempfile.mkdtemp(dir="/tmp", prefix="codenav_"))
try:
with open(os.path.join(root, "a.py"), "w") as f:
f.write("import os\n# needle here\nprint('x')\n")
diff --git a/tests/test_compare_ask_user_routing.py b/tests/test_compare_ask_user_routing.py
index 6fb61cecc..e39ab5b78 100644
--- a/tests/test_compare_ask_user_routing.py
+++ b/tests/test_compare_ask_user_routing.py
@@ -1,5 +1,6 @@
from pathlib import Path
import re
+from tests.helpers.stylesheets import app_css
def test_compare_renders_ask_user_in_the_originating_pane():
@@ -80,7 +81,7 @@ def test_compare_pane_templates_hide_response_actions_until_response_exists():
root = Path(__file__).resolve().parents[1]
index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
panes = (root / "static/js/compare/panes.js").read_text(encoding="utf-8")
- styles = (root / "static/style.css").read_text(encoding="utf-8")
+ styles = app_css()
assert re.search(r"from './panes\.js\?v=[A-Za-z0-9_-]+'", index)
assert re.search(r"from './selector\.js\?v=[A-Za-z0-9_-]+'", index)
@@ -152,7 +153,7 @@ def test_compare_panes_have_visible_runtime_state_without_revealing_empty_action
root = Path(__file__).resolve().parents[1]
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
panes = (root / "static/js/compare/panes.js").read_text(encoding="utf-8")
- styles = (root / "static/style.css").read_text(encoding="utf-8")
+ styles = app_css()
assert "_paneEl.classList.remove('is-done', 'is-failed', 'is-awaiting-input');" in stream
assert "_paneEl.classList.add('is-streaming');" in stream
@@ -179,7 +180,7 @@ def test_compare_panes_surface_compact_result_summary():
index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
panes = (root / "static/js/compare/panes.js").read_text(encoding="utf-8")
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
- styles = (root / "static/style.css").read_text(encoding="utf-8")
+ styles = app_css()
assert 'pane-header-row pane-header-secondary' in index
assert 'class=\"pane-summary\" id=\"cmp-summary-' in index
@@ -203,7 +204,7 @@ def test_compare_panes_surface_compact_result_summary():
def test_compare_selector_surfaces_duplicate_warning_without_blocking_start():
root = Path(__file__).resolve().parents[1]
selector = (root / "static/js/compare/selector.js").read_text(encoding="utf-8")
- styles = (root / "static/style.css").read_text(encoding="utf-8")
+ styles = app_css()
assert "function _selectionKey(sel)" in selector
assert "function _duplicateSelectionKeys()" in selector
diff --git a/tests/test_compare_mobile_tabs_static.py b/tests/test_compare_mobile_tabs_static.py
index 14270050b..55ad7ab5a 100644
--- a/tests/test_compare_mobile_tabs_static.py
+++ b/tests/test_compare_mobile_tabs_static.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parent.parent
@@ -32,7 +33,7 @@ def test_mobile_compare_reconciles_tabs_after_pane_lifecycle_changes():
def test_mobile_compare_css_shows_only_the_active_card():
- css = _read("static/style.css")
+ css = app_css()
mobile = css[css.index("/* Compare uses one full-width response card on phones."):]
assert ".compare-mobile-tabs" in mobile
@@ -44,7 +45,7 @@ def test_mobile_compare_css_shows_only_the_active_card():
def test_probe_feedback_and_actions_use_the_shared_card_layout():
selector = _read("static/js/compare/selector.js")
- css = _read("static/style.css")
+ css = app_css()
assert "probeFeedback.className = 'compare-probe-feedback'" in selector
assert "probeFeedback.appendChild(detail)" in selector
@@ -66,7 +67,7 @@ def test_probe_feedback_and_actions_use_the_shared_card_layout():
def test_probe_swap_reopens_and_highlights_the_failed_model_slot():
selector = _read("static/js/compare/selector.js")
- css = _read("static/style.css")
+ css = app_css()
assert "function _expandModelSlot(slotIdx)" in selector
assert "row.dataset.slotIndex = String(idx);" in selector
diff --git a/tests/test_compare_shuffle_visual_static.py b/tests/test_compare_shuffle_visual_static.py
index 1e2f00505..70768c70a 100644
--- a/tests/test_compare_shuffle_visual_static.py
+++ b/tests/test_compare_shuffle_visual_static.py
@@ -1,5 +1,6 @@
from pathlib import Path
import re
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parent.parent
@@ -11,7 +12,7 @@ def _read(path: str) -> str:
def test_compare_shuffle_shows_center_notice_with_dice_icon():
panes = _read("static/js/compare/panes.js")
- css = _read("static/style.css")
+ css = app_css()
assert "ICON_DICE" in panes
assert "compare-shuffle-notice" in panes
@@ -23,7 +24,7 @@ def test_compare_shuffle_shows_center_notice_with_dice_icon():
def test_compare_chat_and_agent_panes_expose_per_pane_inference_settings():
index = _read("static/js/compare/index.js")
panes = _read("static/js/compare/panes.js")
- css = _read("static/style.css")
+ css = app_css()
assert "pane-settings-btn" in panes
assert "paneSettingsButtonHtml" in index
@@ -41,7 +42,7 @@ def test_compare_chat_and_agent_panes_expose_per_pane_inference_settings():
def test_compare_probe_control_has_requested_vertical_alignment():
index = _read("static/js/compare/index.js")
probe = _read("static/js/compare/probe.js")
- css = _read("static/style.css")
+ css = app_css()
assert 'class="compare-check-icon"' in index
assert 'Probe' in index
@@ -75,7 +76,7 @@ def test_compare_cache_key_bumped_for_shuffle_notice():
def test_compare_score_button_label_is_nudged_up():
vote = _read("static/js/compare/vote.js")
- css = _read("static/style.css")
+ css = app_css()
assert 'Score' in vote
assert ".compare-score-label" in css
diff --git a/tests/test_cookbook_docker_access.py b/tests/test_cookbook_docker_access.py
index 47110b04d..5acf49e0a 100644
--- a/tests/test_cookbook_docker_access.py
+++ b/tests/test_cookbook_docker_access.py
@@ -1,4 +1,3 @@
-import socket
from unittest.mock import AsyncMock
import pytest
@@ -9,6 +8,7 @@ from starlette.requests import Request
import routes.cookbook_routes as cookbook_routes
from routes.cookbook_helpers import ServeRequest, _validate_serve_cmd
from src.host_docker_access import HOST_DOCKER_ACCESS_HINT
+from tests.helpers.unix_sockets import bound_unix_socket
def _model_serve_endpoint():
@@ -57,19 +57,18 @@ async def test_container_cli_only_is_rejected(monkeypatch, tmp_path):
@pytest.mark.asyncio
-async def test_container_opt_in_with_unix_socket_is_allowed(monkeypatch, tmp_path):
+async def test_container_opt_in_with_unix_socket_is_allowed(monkeypatch):
monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker")
- socket_path = tmp_path / "docker.sock"
- with socket.socket(socket.AF_UNIX) as unix_socket:
- unix_socket.bind(str(socket_path))
+ # Not tmp_path: binding under $TMPDIR overruns sun_path on macOS.
+ with bound_unix_socket() as socket_path:
available = await cookbook_routes._binary_available(
"docker",
None,
None,
in_container=True,
environ={"ODYSSEUS_ENABLE_HOST_DOCKER": "true"},
- socket_path=str(socket_path),
+ socket_path=socket_path,
)
assert available is True
diff --git a/tests/test_cookbook_stop_without_procfs.py b/tests/test_cookbook_stop_without_procfs.py
new file mode 100644
index 000000000..ad6e225a8
--- /dev/null
+++ b/tests/test_cookbook_stop_without_procfs.py
@@ -0,0 +1,166 @@
+"""Stopping a Cookbook server must succeed on a host with no procfs.
+
+The tmux kill is what actually stops the server; the pid sweep that follows
+it only catches model servers that survive the session's SIGHUP. On macOS and
+Windows there is no ``/proc`` to sweep, and letting that raise turned a
+successful stop into a reported failure *and* skipped the state write that
+marks the session stopped for the Cookbook UI.
+"""
+import asyncio
+import json
+import signal
+
+import pytest
+
+from core import platform_compat
+from src import tool_implementations as tools
+
+
+class FakeResponse:
+ def __init__(self, data=None, status_code=200):
+ self._data = data or {}
+ self.status_code = status_code
+ self.text = json.dumps(self._data)
+
+ def json(self):
+ return self._data
+
+
+def _tracked_state(session_id="serve-abc123", cmd="python -m vllm.entrypoints.openai.api_server"):
+ return {
+ "tasks": [
+ {
+ "sessionId": session_id,
+ "model": "org/model",
+ "type": "serve",
+ "status": "running",
+ "payload": {"_cmd": cmd},
+ }
+ ]
+ }
+
+
+def _install_httpx_client(monkeypatch, state):
+ """Serve cookbook state over a fake httpx and record every POST body."""
+ import httpx
+
+ posts = []
+
+ class FakeAsyncClient:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def get(self, url, **kwargs):
+ return FakeResponse(state)
+
+ async def post(self, url, json=None, **kwargs):
+ posts.append((url, json))
+ return FakeResponse({"ok": True})
+
+ monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
+ return posts
+
+
+def _install_successful_tmux_kill(monkeypatch):
+ """Replace the real ``tmux kill-session`` with a process that succeeds."""
+
+ class FakeProc:
+ returncode = 0
+
+ async def communicate(self):
+ return b"", b""
+
+ async def fake_exec(*argv, **kwargs):
+ assert argv[:2] == ("tmux", "kill-session")
+ return FakeProc()
+
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
+
+
+def _stopped_statuses(posts, session_id):
+ out = []
+ for _url, body in posts:
+ for task in (body or {}).get("tasks") or []:
+ if task.get("sessionId") == session_id:
+ out.append(task.get("status"))
+ return out
+
+
+@pytest.mark.asyncio
+async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
+ monkeypatch, tmp_path
+):
+ state = _tracked_state()
+ posts = _install_httpx_client(monkeypatch, state)
+ _install_successful_tmux_kill(monkeypatch)
+ monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "no-procfs")
+
+ import os
+
+ def _unexpected_listdir(*args, **kwargs):
+ raise AssertionError("the pid sweep must not run without procfs")
+
+ monkeypatch.setattr(os, "listdir", _unexpected_listdir)
+
+ result = await tools.do_stop_served_model(
+ json.dumps({"session_id": "serve-abc123"})
+ )
+
+ assert result == {"output": "Stopped server serve-abc123", "exit_code": 0}
+ assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
+
+
+@pytest.mark.asyncio
+async def test_stop_sweeps_surviving_pids_when_procfs_is_present(
+ monkeypatch, tmp_path
+):
+ tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
+ state = _tracked_state(cmd=tracked_cmd)
+ posts = _install_httpx_client(monkeypatch, state)
+ _install_successful_tmux_kill(monkeypatch)
+
+ proc = tmp_path / "proc"
+
+ def _write_pid(pid, cmdline):
+ entry = proc / pid
+ entry.mkdir(parents=True)
+ (entry / "cmdline").write_bytes(cmdline.replace(" ", "\0").encode())
+
+ _write_pid("101", tracked_cmd)
+ _write_pid("202", "python -m http.server")
+ (proc / "self").mkdir()
+ monkeypatch.setattr(platform_compat, "PROC_ROOT", proc)
+
+ signalled = []
+ import os
+
+ monkeypatch.setattr(os, "kill", lambda pid, sig: signalled.append((pid, sig)))
+
+ result = await tools.do_stop_served_model(
+ json.dumps({"session_id": "serve-abc123"})
+ )
+
+ assert result["exit_code"] == 0
+ assert (101, signal.SIGTERM) in signalled
+ assert not any(pid == 202 for pid, _sig in signalled)
+ assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
+
+
+def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
+ """The other procfs scan in the same module already guards; pin it."""
+ monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "no-procfs")
+
+ import os
+
+ def _unexpected_listdir(*args, **kwargs):
+ raise AssertionError("the model-process scan must not run without procfs")
+
+ monkeypatch.setattr(os, "listdir", _unexpected_listdir)
+
+ assert tools._scan_running_model_processes() == []
diff --git a/tests/test_dialog_aria.py b/tests/test_dialog_aria.py
index 3513f9b0d..5ce8ce427 100644
--- a/tests/test_dialog_aria.py
+++ b/tests/test_dialog_aria.py
@@ -10,6 +10,7 @@ of the other tests in this suite.
"""
import re
from pathlib import Path
+from tests.helpers.stylesheets import app_css
_REPO = Path(__file__).resolve().parent.parent
_INDEX = (_REPO / "static" / "index.html").read_text(encoding="utf-8")
@@ -47,7 +48,7 @@ def test_styled_confirm_and_prompt_are_modal_dialogs():
def test_styled_confirm_cancel_or_close_label_is_shifted_without_moving_button():
- css = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
+ css = app_css()
assert "cancelLabel.textContent = cancelText;" in _UI
assert "cancelBtn.appendChild(cancelLabel);" in _UI
diff --git a/tests/test_doc_library_open_orphaned.py b/tests/test_doc_library_open_orphaned.py
index 58e52d673..76e47b7cc 100644
--- a/tests/test_doc_library_open_orphaned.py
+++ b/tests/test_doc_library_open_orphaned.py
@@ -18,6 +18,7 @@ import re
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
SRC = Path(__file__).resolve().parent.parent / "static/js/documentLibrary.js"
@@ -96,7 +97,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
const state = await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&mobile-library-open-test=1');
mod.init('/api');
@@ -134,6 +135,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock():
console.log(JSON.stringify(state));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=SRC.parents[1],
diff --git a/tests/test_document_edit_reference_js.py b/tests/test_document_edit_reference_js.py
index 9d255933c..9ee7842ff 100644
--- a/tests/test_document_edit_reference_js.py
+++ b/tests/test_document_edit_reference_js.py
@@ -1,13 +1,14 @@
"""Regression guards for document-selection references in chat bubbles."""
from pathlib import Path
+from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
DOCUMENT = document_source()
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
APP = (ROOT / "static/app.js").read_text(encoding="utf-8")
diff --git a/tests/test_document_editor_scroll.py b/tests/test_document_editor_scroll.py
index a205131dc..a84876dd8 100644
--- a/tests/test_document_editor_scroll.py
+++ b/tests/test_document_editor_scroll.py
@@ -9,12 +9,12 @@ document.js is browser-coupled and not importable in pytest.
"""
from pathlib import Path
+from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
-
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
-STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE_CSS = app_css()
def test_document_textarea_scrollbar_is_visible():
diff --git a/tests/test_document_history_controls.py b/tests/test_document_history_controls.py
index 5b4894e5c..709937586 100644
--- a/tests/test_document_history_controls.py
+++ b/tests/test_document_history_controls.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -25,7 +26,7 @@ def test_mobile_rich_text_history_state_and_document_switch():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&history-controls-test=1');
window.documentModuleForTest = mod;
@@ -86,6 +87,7 @@ def test_mobile_rich_text_history_state_and_document_switch():
console.log(JSON.stringify({ initial, typed, undone, redone, switched, overflow }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_library_mobile_footer.py b/tests/test_document_library_mobile_footer.py
index f5a9921ae..ad94861c9 100644
--- a/tests/test_document_library_mobile_footer.py
+++ b/tests/test_document_library_mobile_footer.py
@@ -3,11 +3,13 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import app_css
+from tests.helpers.stylesheets import stylesheet_link_tags
ROOT = Path(__file__).resolve().parents[1]
SOURCE = (ROOT / "static/js/documentLibrary.js").read_text(encoding="utf-8")
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
def test_mobile_footer_exposes_delete_open_and_more():
@@ -53,7 +55,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
const state = await page.evaluate(async () => {
let currentSession = 'current-chat';
let createDirectCalls = 0;
@@ -106,6 +108,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session():
console.log(JSON.stringify(state));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_outline.py b/tests/test_document_outline.py
index 9cf365231..ae5859b20 100644
--- a/tests/test_document_outline.py
+++ b/tests/test_document_outline.py
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import app_css
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
-
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
def _run_node(script: str):
@@ -42,7 +43,7 @@ def test_markdown_outline_parses_structure_and_ignores_fenced_code():
'#### Final `code` section',
].join('\n');
console.log(JSON.stringify(parseMarkdownOutline(source)));
- """
+ """.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
)
assert [(entry["level"], entry["text"]) for entry in data] == [
(1, "Overview"),
@@ -72,7 +73,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&outline-test=1');
mod.init('/api');
@@ -127,7 +128,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile():
console.log(JSON.stringify({ markdownLabels, selected, liveLabels, mobileBox, richLabels, richCaretHeading }));
await browser.close();
- """
+ """.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
)
assert data["markdownLabels"] == ["Intro", "Details", "End"]
assert data["selected"] == "## Details"
diff --git a/tests/test_document_preview_edit_button.py b/tests/test_document_preview_edit_button.py
index 2ceb077a2..9eaa5708e 100644
--- a/tests/test_document_preview_edit_button.py
+++ b/tests/test_document_preview_edit_button.py
@@ -1,12 +1,12 @@
"""Regression guards for the Markdown preview hover-to-edit control."""
from pathlib import Path
+from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
-
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
-STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE_CSS = app_css()
def test_preview_installs_hover_edit_button():
diff --git a/tests/test_document_rich_checklist_enter.py b/tests/test_document_rich_checklist_enter.py
index 7f886d21b..0e633e284 100644
--- a/tests/test_document_rich_checklist_enter.py
+++ b/tests/test_document_rich_checklist_enter.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -27,7 +28,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&checklist-enter-test=1');
mod.init('/api');
@@ -79,6 +80,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly():
console.log(JSON.stringify({ afterFirstEnter, ...data }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_color_reset_and_contrast.py b/tests/test_document_rich_color_reset_and_contrast.py
index 1b3971e42..9a89e5764 100644
--- a/tests/test_document_rich_color_reset_and_contrast.py
+++ b/tests/test_document_rich_color_reset_and_contrast.py
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import app_css
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
-
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
def test_color_controls_have_theme_reset_and_split_palettes():
@@ -21,6 +22,12 @@ def test_color_controls_have_theme_reset_and_split_palettes():
def test_rich_colors_follow_theme_and_undo_as_one_edit():
+ # Two input conventions in here are platform-sensitive and must stay that
+ # way. Palette entries are opened with a plain click: on macOS a
+ # Control+click is delivered as `contextmenu`, so the menu item's `click`
+ # handler never runs and nothing is applied. Undo uses Playwright's
+ # `ControlOrMeta` alias because the editor's undo accelerator is Cmd+Z on
+ # macOS and Ctrl+Z everywhere else.
script = r"""
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
@@ -28,7 +35,7 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit():
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent(`
+ __ODY_STYLESHEETS__
`);
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?rich-color-test=' + Date.now());
@@ -59,28 +66,29 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit():
labels: [...document.querySelectorAll('.rich-color-palette-label')].map(item => item.textContent),
reset: document.querySelector('.rich-color-reset')?.textContent.trim(),
}));
- await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Lemon' }).click({ modifiers: ['Control'] });
+ await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Lemon' }).click();
const highlighted = await page.locator('#doc-email-richbody p').nth(0).locator('span').evaluate(span => ({
color: getComputedStyle(span).color,
background: getComputedStyle(span).backgroundColor,
}));
- await page.locator('#doc-email-richbody').press('Control+z');
+ await page.locator('#doc-email-richbody').press('ControlOrMeta+z');
const highlightUndone = await page.locator('#doc-email-richbody p').nth(0).innerHTML();
await selectParagraph(1);
await openMenu('color');
- await page.locator('.rich-color-reset').click({ modifiers: ['Control'] });
+ await page.locator('.rich-color-reset').click();
const defaultColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => ({
style: span.getAttribute('style'),
color: getComputedStyle(span).color,
}));
await page.evaluate(() => document.documentElement.style.setProperty('--fg', '#88cc44'));
const changedThemeColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => getComputedStyle(span).color);
- await page.locator('#doc-email-richbody').press('Control+z');
+ await page.locator('#doc-email-richbody').press('ControlOrMeta+z');
const colorUndone = await page.locator('#doc-email-richbody p').nth(1).innerHTML();
console.log(JSON.stringify({ palette, highlighted, highlightUndone, defaultColor, changedThemeColor, colorUndone }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_docx_export.py b/tests/test_document_rich_docx_export.py
index 0f9d634be..6635051e7 100644
--- a/tests/test_document_rich_docx_export.py
+++ b/tests/test_document_rich_docx_export.py
@@ -5,6 +5,7 @@ import subprocess
import tempfile
import zipfile
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -65,7 +66,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml():
contentType: 'image/png',
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'),
}}));
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {{
const mod = await import('/static/js/document.js?v=20260831richtexttools91&docx-export-test=1');
mod.init('/api');
@@ -90,6 +91,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml():
}}));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -159,7 +161,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting():
acceptDownloads: true,
}});
await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {{
const mod = await import('/static/js/document.js?v=20260831richtexttools91&markdown-docx-export-test=1');
mod.init('/api');
@@ -181,6 +183,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting():
console.log(JSON.stringify({{ failure: await download.failure() }}));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_find_boundaries.py b/tests/test_document_rich_find_boundaries.py
index f45176b67..dfc77ef20 100644
--- a/tests/test_document_rich_find_boundaries.py
+++ b/tests/test_document_rich_find_boundaries.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -24,7 +25,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&find-boundaries-test=1');
mod.init('/api');
@@ -72,6 +73,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac
console.log(JSON.stringify({ crossParagraph, crossBreak, crossInline, ...data }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_font_color_controls.py b/tests/test_document_rich_font_color_controls.py
index 9cd2df299..764e6152e 100644
--- a/tests/test_document_rich_font_color_controls.py
+++ b/tests/test_document_rich_font_color_controls.py
@@ -6,6 +6,7 @@ import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -95,7 +96,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile():
async function exercise(viewport, suffix) {
const page = await browser.newPage({ viewport });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async suffix => {
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&font-color=${suffix}`);
mod.init('/api');
@@ -191,6 +192,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile():
console.log(JSON.stringify({ desktop, mobile }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_heading_enter.py b/tests/test_document_rich_heading_enter.py
index 2c1c03b78..0bd4fe317 100644
--- a/tests/test_document_rich_heading_enter.py
+++ b/tests/test_document_rich_heading_enter.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -28,7 +29,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&heading-enter=1');
mod.init('/api');
@@ -72,6 +73,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable():
console.log(JSON.stringify({ entered, undone, redone }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -151,6 +153,7 @@ def test_heading_enter_preserves_shift_middle_and_empty_heading_semantics():
console.log(JSON.stringify(state));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_image_caption.py b/tests/test_document_rich_image_caption.py
index c09316d4c..551b9da00 100644
--- a/tests/test_document_rich_image_caption.py
+++ b/tests/test_document_rich_image_caption.py
@@ -5,6 +5,7 @@ import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -33,7 +34,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&image-caption=1');
mod.init('/api');
@@ -113,6 +114,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal():
console.log(JSON.stringify({ added, undone, redone, resized, removed, removalUndone, menuRect, overflow }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_input_rules.py b/tests/test_document_rich_input_rules.py
index aae84456e..4d697e6c1 100644
--- a/tests/test_document_rich_input_rules.py
+++ b/tests/test_document_rich_input_rules.py
@@ -5,6 +5,7 @@ import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -29,7 +30,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&input-rules-test=1');
mod.init('/api');
@@ -102,6 +103,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text():
console.log(JSON.stringify(data));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_keyboard_shortcuts.py b/tests/test_document_rich_keyboard_shortcuts.py
index 89a09ec27..570daa87e 100644
--- a/tests/test_document_rich_keyboard_shortcuts.py
+++ b/tests/test_document_rich_keyboard_shortcuts.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
ROOT = Path(__file__).resolve().parents[1]
@@ -16,7 +17,7 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths():
async function exercise(viewport, suffix) {
const page = await browser.newPage({ viewport });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async suffix => {
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&keyboard-shortcuts=${suffix}`);
mod.init('/api');
@@ -94,6 +95,7 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths():
console.log(JSON.stringify({ desktop, mobile }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_selection_toolbar.py b/tests/test_document_rich_selection_toolbar.py
index 93d861d6a..76f15a4f3 100644
--- a/tests/test_document_rich_selection_toolbar.py
+++ b/tests/test_document_rich_selection_toolbar.py
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import app_css
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
-
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
def test_selection_toolbar_uses_shared_formatting_path_and_preserves_range():
@@ -27,7 +28,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&selection-toolbar-test=1');
mod.init('/api');
@@ -103,6 +104,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports
console.log(JSON.stringify({ activeBold, desktopGeometry, formattedHtml, selectedAfterFormat, dismissed, mobileGeometry }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_slash_menu.py b/tests/test_document_rich_slash_menu.py
index 0466721c0..d4e8bd09d 100644
--- a/tests/test_document_rich_slash_menu.py
+++ b/tests/test_document_rich_slash_menu.py
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import app_css
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
-
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
def test_slash_menu_reuses_rich_text_actions_and_is_accessible():
@@ -42,7 +43,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&slash-menu-test=1');
mod.init('/api');
@@ -150,6 +151,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
console.log(JSON.stringify({ filtered, heading, noHeadingQuery, tableFiltered, table, noTableQuery, mobile, homeOptions, homeLabel, escaped, slashRemains, cleanedAria }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_smart_link_paste.py b/tests/test_document_rich_smart_link_paste.py
index 8f8c675e7..51b844209 100644
--- a/tests/test_document_rich_smart_link_paste.py
+++ b/tests/test_document_rich_smart_link_paste.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
ROOT = Path(__file__).resolve().parents[1]
@@ -14,7 +15,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&smart-link-paste=1');
mod.init('/api');
@@ -133,6 +134,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks
console.log(JSON.stringify({ selectedLink, storedAfterPaste, afterUndo, afterRedoLinks, caretLink, plainResults, emailLink, crossBlock }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_structure_tools.py b/tests/test_document_rich_structure_tools.py
index e7d5090ef..1ff6f9a96 100644
--- a/tests/test_document_rich_structure_tools.py
+++ b/tests/test_document_rich_structure_tools.py
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import app_css
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import declaration, document_source, function_body
-
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
def test_heading_levels_and_page_break_are_exposed_everywhere():
@@ -39,7 +40,7 @@ def test_mobile_headings_page_break_history_and_persistence():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&structure-tools-test=1');
window.__structureToolsDocModule = mod;
@@ -147,6 +148,7 @@ def test_mobile_headings_page_break_history_and_persistence():
}));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_table_cell_alignment.py b/tests/test_document_rich_table_cell_alignment.py
index 4f875b5c1..ac2b850b2 100644
--- a/tests/test_document_rich_table_cell_alignment.py
+++ b/tests/test_document_rich_table_cell_alignment.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -28,7 +29,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-cell-alignment=1');
mod.init('/api');
@@ -119,6 +120,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history():
}));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_table_header_preservation.py b/tests/test_document_rich_table_header_preservation.py
index 4d0a90384..378d02828 100644
--- a/tests/test_document_rich_table_header_preservation.py
+++ b/tests/test_document_rich_table_header_preservation.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -35,7 +36,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-preservation-test=1');
mod.init('/api');
@@ -118,6 +119,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history():
}));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_table_headers.py b/tests/test_document_rich_table_headers.py
index e5c8fed16..5d0d67693 100644
--- a/tests/test_document_rich_table_headers.py
+++ b/tests/test_document_rich_table_headers.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -37,7 +38,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-test=1');
mod.init('/api');
@@ -114,6 +115,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo():
console.log(JSON.stringify({ initial, menuRect, columnOn, undone, redone, bothOn, rowOff, overflow }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_table_merge_split.py b/tests/test_document_rich_table_merge_split.py
index abecf2b66..9269c154e 100644
--- a/tests/test_document_rich_table_merge_split.py
+++ b/tests/test_document_rich_table_merge_split.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -33,7 +34,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history(
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-merge-split=1');
mod.init('/api');
@@ -137,6 +138,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history(
}));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_table_tab_history.py b/tests/test_document_rich_table_tab_history.py
index a9af656bb..cbe116e0b 100644
--- a/tests/test_document_rich_table_tab_history.py
+++ b/tests/test_document_rich_table_tab_history.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -30,7 +31,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-tab-history-test=1');
mod.init('/api');
@@ -97,6 +98,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history():
console.log(JSON.stringify({ forward, backward, appended, undone, redone, overflow }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_rich_toolbar_menus.py b/tests/test_document_rich_toolbar_menus.py
index eafc997fb..b6837d0c3 100644
--- a/tests/test_document_rich_toolbar_menus.py
+++ b/tests/test_document_rich_toolbar_menus.py
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import app_css
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
-
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
def test_rich_toolbar_menus_expose_keyboard_and_context_state():
@@ -59,7 +60,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toggle-test=1');
mod.init('/api');
@@ -102,6 +103,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens():
console.log(JSON.stringify({ opened, closed, sliderOpened, sliderClosed, pickerOpened, colorClosed, before, scroll }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -128,7 +130,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-menu-test=1');
mod.init('/api');
@@ -205,6 +207,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus():
}));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -243,7 +246,7 @@ def test_rich_toolbar_menus_track_live_formatting_values():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-state-test=1');
mod.init('/api');
@@ -323,6 +326,7 @@ def test_rich_toolbar_menus_track_live_formatting_values():
}));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_save_shortcut.py b/tests/test_document_save_shortcut.py
index d03edaee1..acf833def 100644
--- a/tests/test_document_save_shortcut.py
+++ b/tests/test_document_save_shortcut.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -39,7 +40,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status():
});
});
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-shortcut-test=1');
mod.init('/api');
@@ -72,6 +73,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status():
console.log(JSON.stringify({ dirty, saved, badge, requests }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_save_status.py b/tests/test_document_save_status.py
index f29698ff0..487667799 100644
--- a/tests/test_document_save_status.py
+++ b/tests/test_document_save_status.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
ROOT = Path(__file__).resolve().parents[1]
@@ -32,7 +33,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures():
active -= 1;
});
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-status-test=1');
mod.init('/api');
@@ -81,6 +82,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures():
console.log(JSON.stringify({ initial, dirty, duringSave, afterSave, afterFailure, bodies, maxActive, footer, mobile }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_document_suggestion_highlight.py b/tests/test_document_suggestion_highlight.py
index 653773f1d..8fe6912ac 100644
--- a/tests/test_document_suggestion_highlight.py
+++ b/tests/test_document_suggestion_highlight.py
@@ -1,12 +1,12 @@
"""Regression guards for exact inline document-suggestion highlighting."""
from pathlib import Path
+from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
-
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
-STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE_CSS = app_css()
def test_suggestion_highlight_measures_exact_referenced_range():
diff --git a/tests/test_document_toolbar_arrow_offset.py b/tests/test_document_toolbar_arrow_offset.py
index 6f95f8fb7..3df2232ea 100644
--- a/tests/test_document_toolbar_arrow_offset.py
+++ b/tests/test_document_toolbar_arrow_offset.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
@@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_toolbar_arrows_have_real_flex_slots_outside_icon_scroller():
script = document_source()
- styles = (ROOT / "static/style.css").read_text()
+ styles = app_css()
leading = script.index('class="md-toolbar-leading-controls"')
left_arrow = script.index('id="md-scroll-left"')
diff --git a/tests/test_document_toolbar_order.py b/tests/test_document_toolbar_order.py
index 683a29aa7..218067fbc 100644
--- a/tests/test_document_toolbar_order.py
+++ b/tests/test_document_toolbar_order.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -39,7 +40,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
async function inspect(viewport, suffix) {
const page = await browser.newPage({ viewport });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async suffix => {
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&toolbar-order=${suffix}`);
mod.init('/api');
@@ -80,6 +81,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
console.log(JSON.stringify({ desktop, mobile }));
await browser.close();
"""
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
diff --git a/tests/test_editor_layer_thumbnail_render.py b/tests/test_editor_layer_thumbnail_render.py
index aa9e283e1..286042011 100644
--- a/tests/test_editor_layer_thumbnail_render.py
+++ b/tests/test_editor_layer_thumbnail_render.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -49,7 +50,7 @@ def test_group_rows_support_keyboard_selection():
def test_keyboard_focus_is_visible_for_layer_and_group_rows():
- styles = (ROOT / "static/style.css").read_text()
+ styles = app_css()
assert ".ge-layer-item:focus-visible," in styles
assert ".ge-layer-group-row:focus-visible" in styles
diff --git a/tests/test_editor_quick_edit_static.py b/tests/test_editor_quick_edit_static.py
index 7f90d92c4..72b17115a 100644
--- a/tests/test_editor_quick_edit_static.py
+++ b/tests/test_editor_quick_edit_static.py
@@ -1,9 +1,10 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
def test_quick_edit_has_persistent_expanded_identity_and_labeled_input():
diff --git a/tests/test_editor_topbar_static.py b/tests/test_editor_topbar_static.py
index 8f2ed3d50..377e47cf8 100644
--- a/tests/test_editor_topbar_static.py
+++ b/tests/test_editor_topbar_static.py
@@ -1,11 +1,12 @@
from pathlib import Path
import re
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
TOPBAR = (ROOT / "static/js/editor/build/topbar.js").read_text(encoding="utf-8")
OVERFLOW = (ROOT / "static/js/editor/wire-topbar-overflow.js").read_text(encoding="utf-8")
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
def test_primary_editor_text_actions_use_stacked_toolbar_contract():
diff --git a/tests/test_email_folder_chip_static.py b/tests/test_email_folder_chip_static.py
index 193cfa6aa..4410414ab 100644
--- a/tests/test_email_folder_chip_static.py
+++ b/tests/test_email_folder_chip_static.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_folder_chip_stays_with_date_and_moves_down():
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
- css = (ROOT / "static" / "style.css").read_text()
+ css = app_css()
assert 'class="email-meta-date-group"' in source
group_markup = source[source.index('class="email-meta-date-group"'):][:180]
diff --git a/tests/test_email_split_border_css.py b/tests/test_email_split_border_css.py
index cf34d51b9..78a11899c 100644
--- a/tests/test_email_split_border_css.py
+++ b/tests/test_email_split_border_css.py
@@ -1,7 +1,8 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
-CSS = (Path(__file__).parents[1] / "static" / "style.css").read_text(encoding="utf-8")
+CSS = app_css()
def _rule(selector: str) -> str:
diff --git a/tests/test_endpoint_card_ui_polish.py b/tests/test_endpoint_card_ui_polish.py
index afd160b25..6f31e2a06 100644
--- a/tests/test_endpoint_card_ui_polish.py
+++ b/tests/test_endpoint_card_ui_polish.py
@@ -5,11 +5,12 @@ import shutil
import subprocess
from pathlib import Path
import pytest
+from tests.helpers.stylesheets import app_css
_REPO = Path(__file__).resolve().parent.parent
_ADMIN_JS = _REPO / "static" / "js" / "admin.js"
_ADMIN = _ADMIN_JS.read_text(encoding="utf-8")
-_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
+_STYLE = app_css()
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
diff --git a/tests/test_escape_inner_layers.py b/tests/test_escape_inner_layers.py
index f7818178d..d29dd5bb5 100644
--- a/tests/test_escape_inner_layers.py
+++ b/tests/test_escape_inner_layers.py
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
+from tests.helpers.stylesheets import stylesheet_link_tags
ROOT = Path(__file__).resolve().parents[1]
@@ -14,7 +15,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
- await page.setContent('');
+ await page.setContent('__ODY_STYLESHEETS__');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=escape-regression-1');
mod.init('/api');
@@ -55,6 +56,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
console.log(JSON.stringify({ before, afterOne, afterTwo }));
await browser.close();
'''
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
['node', '--input-type=module', '-e', script],
cwd=ROOT,
@@ -114,6 +116,7 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None:
console.log(JSON.stringify({ settings, select, reading }));
await browser.close();
'''
+ script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
['node', '--input-type=module', '-e', script],
cwd=ROOT,
diff --git a/tests/test_image_research_settings_static.py b/tests/test_image_research_settings_static.py
index 1e07626e1..f56126469 100644
--- a/tests/test_image_research_settings_static.py
+++ b/tests/test_image_research_settings_static.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -15,7 +16,7 @@ def test_image_settings_only_list_online_served_image_models():
def test_deep_research_fields_are_constrained_to_their_card():
- styles = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ styles = app_css()
rule = styles[styles.index("/* Deep Research uses long labels"):]
assert ".admin-card:has(#set-researchSearch) .settings-row { min-width: 0; }" in rule
diff --git a/tests/test_inspect_media_tool.py b/tests/test_inspect_media_tool.py
index f6ebc64ae..b00a41baf 100644
--- a/tests/test_inspect_media_tool.py
+++ b/tests/test_inspect_media_tool.py
@@ -1,5 +1,6 @@
import asyncio
import base64
+import functools
import io
import json
from pathlib import Path
@@ -20,6 +21,28 @@ from src.tool_execution import _active_workspace
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
+@functools.lru_cache(maxsize=None)
+def _ffmpeg_has_encoder(name: str) -> bool:
+ """Whether the ffmpeg on PATH was built with the named encoder.
+
+ Codec support is a build option, not something the project requires. The
+ Homebrew ffmpeg on macOS ships without libwebp, for instance, so a test
+ that asserts a successful `.webp` export there fails on the build rather
+ than on the tool.
+ """
+ if not shutil.which("ffmpeg"):
+ return False
+ listed = subprocess.run(
+ ["ffmpeg", "-hide_banner", "-loglevel", "error", "-encoders"],
+ check=False, capture_output=True, text=True,
+ )
+ return any(
+ line.split()[1:2] == [name] or f"(codec {name})" in line
+ for line in listed.stdout.splitlines()
+ if line.strip()
+ )
+
+
def test_media_timestamp_parser_accepts_units_and_four_field_timecodes():
assert _parse_seconds("0m", default=-1) == 0
assert _parse_seconds("30m", default=-1) == 1800
@@ -491,13 +514,16 @@ def test_inspect_media_exports_final_decodable_frame_at_exact_duration(tmp_path:
result = asyncio.run(InspectMediaTool().execute(json.dumps({
"path": "/workspace/video.mp4",
"timestamp": "end",
- "output_path": "/workspace/final.webp",
+ # PNG, not WebP: this asserts that the *final* frame is decodable at
+ # the exact duration, so it must not also depend on an optional
+ # ffmpeg encoder. WebP export is covered separately below.
+ "output_path": "/workspace/final.png",
}), {}))
finally:
_active_workspace.reset(token)
assert result["exit_code"] == 0, result
- assert (tmp_path / "final.webp").stat().st_size > 0
+ assert (tmp_path / "final.png").stat().st_size > 0
token = _active_workspace.set(str(tmp_path))
try:
@@ -514,6 +540,67 @@ def test_inspect_media_exports_final_decodable_frame_at_exact_duration(tmp_path:
assert Image.open(io.BytesIO(base64.b64decode(high_detail["images"][0]["data"]))).size == (768, 432)
+@pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg required")
+@pytest.mark.skipif(not _ffmpeg_has_encoder("webp"), reason="ffmpeg built without a webp encoder")
+def test_inspect_media_exports_a_webp_still(tmp_path: Path):
+ """A `.webp` output_path is passed straight through to ffmpeg.
+
+ Guarded on the encoder rather than asserted unconditionally: WebP is a
+ build option (Homebrew's macOS ffmpeg omits it) and the project does not
+ require it. When the encoder is missing the tool reports ffmpeg's failure
+ with `exit_code` 1, which is covered by
+ `test_inspect_media_reports_a_missing_encoder_instead_of_crashing`.
+ """
+ video = tmp_path / "video.mp4"
+ subprocess.run([
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi",
+ "-i", "testsrc2=size=320x180:rate=4:duration=2", "-pix_fmt", "yuv420p",
+ "-y", str(video),
+ ], check=True)
+ token = _active_workspace.set(str(tmp_path))
+ try:
+ result = asyncio.run(InspectMediaTool().execute(json.dumps({
+ "path": "/workspace/video.mp4",
+ "timestamp": "end",
+ "output_path": "/workspace/final.webp",
+ }), {}))
+ finally:
+ _active_workspace.reset(token)
+
+ assert result["exit_code"] == 0, result
+ assert Image.open(tmp_path / "final.webp").format == "WEBP"
+
+
+@pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg required")
+@pytest.mark.skipif(_ffmpeg_has_encoder("webp"), reason="needs an ffmpeg built without webp")
+def test_inspect_media_reports_a_missing_encoder_instead_of_crashing(tmp_path: Path):
+ """An export in a format this ffmpeg cannot encode fails as a tool error.
+
+ The tool does not probe the encoder list, so the only contract it can keep
+ is to surface ffmpeg's own failure rather than raise or write a truncated
+ file. Asserted only on builds that actually lack the encoder.
+ """
+ video = tmp_path / "video.mp4"
+ subprocess.run([
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi",
+ "-i", "testsrc2=size=320x180:rate=4:duration=2", "-pix_fmt", "yuv420p",
+ "-y", str(video),
+ ], check=True)
+ token = _active_workspace.set(str(tmp_path))
+ try:
+ result = asyncio.run(InspectMediaTool().execute(json.dumps({
+ "path": "/workspace/video.mp4",
+ "timestamp": "end",
+ "output_path": "/workspace/final.webp",
+ }), {}))
+ finally:
+ _active_workspace.reset(token)
+
+ assert result["exit_code"] == 1
+ assert "ffmpeg still extraction failed" in result["error"]
+ assert not (tmp_path / "final.webp").exists()
+
+
@pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg required")
def test_inspect_media_rejects_ambiguous_multi_frame_single_image_export(tmp_path: Path):
video = tmp_path / "video.mp4"
diff --git a/tests/test_interrupted_resume_label_static.py b/tests/test_interrupted_resume_label_static.py
index d4fddf388..b56119da6 100644
--- a/tests/test_interrupted_resume_label_static.py
+++ b/tests/test_interrupted_resume_label_static.py
@@ -1,11 +1,12 @@
from pathlib import Path
import re
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
CHAT = (ROOT / "static/js/chat.js").read_text()
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text()
-STYLE = (ROOT / "static/style.css").read_text()
+STYLE = app_css()
APP = (ROOT / "static/app.js").read_text()
INDEX = (ROOT / "static/index.html").read_text()
diff --git a/tests/test_mcp_add_server_args_validation.py b/tests/test_mcp_add_server_args_validation.py
new file mode 100644
index 000000000..7550a99a0
--- /dev/null
+++ b/tests/test_mcp_add_server_args_validation.py
@@ -0,0 +1,174 @@
+"""Regression test for issue #6211: a malformed Args value on the "Add MCP
+Server" form must not be silently discarded into an empty argv.
+
+routes/mcp/mcp_routes.py's add_server() wrapped json.loads(args) in a bare
+except that fell back to `[]`, so a non-JSON Args value registered the
+server as "Connected" while forwarding no arguments to the spawned stdio
+subprocess at all, with no error surfaced anywhere.
+"""
+import asyncio
+import json
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from fastapi import HTTPException
+
+from routes.mcp import mcp_routes
+
+
+class _FakeSession:
+ """Stands in for core.database.SessionLocal(); add_server only adds+commits."""
+
+ def __init__(self):
+ self.added = []
+
+ def add(self, obj):
+ self.added.append(obj)
+
+ def commit(self):
+ pass
+
+ def close(self):
+ pass
+
+
+def _add_server(monkeypatch):
+ """Register add_server on the shared module-level router and return the
+ freshly-added route's raw endpoint function, bypassing HTTP/Form parsing
+ (require_admin is the only other thing the function touches via `request`).
+
+ Callers must pass every Form(...) parameter add_server reads past the args
+ check (url, oauth_file, oauth_config): calling the endpoint directly skips
+ FastAPI's dependency resolution, so an omitted one arrives as the Form
+ marker object itself rather than its declared default, and later code
+ (e.g. `if oauth_file:`) reads that marker as truthy.
+ """
+ monkeypatch.setattr(mcp_routes, "require_admin", lambda request: None)
+ manager = MagicMock()
+ manager.connect_server = AsyncMock(return_value=True)
+ manager.get_server_status = MagicMock(return_value={"status": "connected", "tool_count": 1})
+ router = mcp_routes.setup_mcp_routes(manager)
+ # setup_mcp_routes appends new APIRoute objects to the shared router on
+ # every call, so take the LAST "add_server" route: the one just registered
+ # with our fake manager, not an earlier registration from importing app.py.
+ route = [r for r in router.routes if getattr(r, "name", None) == "add_server"][-1]
+ return route.endpoint, manager
+
+
+def test_add_server_rejects_malformed_args_instead_of_defaulting(monkeypatch):
+ add_server, manager = _add_server(monkeypatch)
+ monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: (_ for _ in ()).throw(
+ AssertionError("must not reach the DB when args is rejected")))
+
+ with pytest.raises(HTTPException) as exc:
+ asyncio.run(add_server(
+ request=None,
+ name="filesystem",
+ transport="stdio",
+ command="mcp-server-filesystem",
+ args="/app/data/jarvis-files", # the exact value from issue #6211
+ env="{}",
+ url=None,
+ oauth_file=None,
+ oauth_config=None,
+ ))
+
+ assert exc.value.status_code == 400
+ manager.connect_server.assert_not_called()
+
+
+def test_add_server_still_accepts_valid_json_args(monkeypatch):
+ add_server, manager = _add_server(monkeypatch)
+ fake_session = _FakeSession()
+ monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: fake_session)
+
+ result = asyncio.run(add_server(
+ request=None,
+ name="filesystem",
+ transport="stdio",
+ command="mcp-server-filesystem",
+ args=json.dumps(["/app/data/jarvis-files"]),
+ env="{}",
+ url=None,
+ oauth_file=None,
+ oauth_config=None,
+ ))
+
+ assert result["connected"] is True
+ manager.connect_server.assert_awaited_once()
+ assert manager.connect_server.call_args.kwargs["args"] == ["/app/data/jarvis-files"]
+ assert fake_session.added[0].args == json.dumps(["/app/data/jarvis-files"])
+
+
+def test_add_server_rejects_valid_json_args_that_is_not_a_list(monkeypatch):
+ """Valid JSON that is not a list (e.g. args=5) must not reach
+ StdioServerParameters(args=5), which raises an unhandled TypeError when
+ the error formatter later does " ".join([command, *args])."""
+ add_server, manager = _add_server(monkeypatch)
+ monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: (_ for _ in ()).throw(
+ AssertionError("must not reach the DB when args has the wrong shape")))
+
+ with pytest.raises(HTTPException) as exc:
+ asyncio.run(add_server(
+ request=None,
+ name="filesystem",
+ transport="stdio",
+ command="mcp-server-filesystem",
+ args="5",
+ env="{}",
+ url=None,
+ oauth_file=None,
+ oauth_config=None,
+ ))
+
+ assert exc.value.status_code == 400
+ manager.connect_server.assert_not_called()
+
+
+def test_add_server_still_defaults_empty_args_to_empty_list(monkeypatch):
+ """No behavior change for the common case of an empty Args field."""
+ add_server, manager = _add_server(monkeypatch)
+ fake_session = _FakeSession()
+ monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: fake_session)
+
+ result = asyncio.run(add_server(
+ request=None,
+ name="no-args-server",
+ transport="stdio",
+ command="some-command",
+ args="",
+ env="{}",
+ url=None,
+ oauth_file=None,
+ oauth_config=None,
+ ))
+
+ assert result["connected"] is True
+ assert manager.connect_server.call_args.kwargs["args"] == []
+
+
+# ---------------------------------------------------------------------------
+# The two forms that post to this endpoint
+# ---------------------------------------------------------------------------
+#
+# The route now answers 400 with a message naming the expected shape. That is
+# only worth anything if the form the user is looking at prints it, and the two
+# forms did not agree: admin.js reads `data.detail`, settings.js printed the
+# bare status code. Read as source, because the artifact under test is the
+# string in the file and neither form is reachable without a browser.
+
+_REPO = Path(__file__).resolve().parents[1]
+
+
+def test_admin_form_reports_the_reason_the_route_gave():
+ source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
+ assert "msg.textContent = data.detail || `Failed (${res.status})`;" in source
+
+
+def test_unified_integrations_form_reports_the_reason_the_route_gave():
+ source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
+ assert (
+ "el('uf-mcp-msg').textContent = data.detail || `Failed (${r.status})`;"
+ in source
+ ), "settings.js drops the route's message and prints only the status code"
diff --git a/tests/test_mobile_search_position_static.py b/tests/test_mobile_search_position_static.py
index 411244286..eb7d31bfb 100644
--- a/tests/test_mobile_search_position_static.py
+++ b/tests/test_mobile_search_position_static.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_search_popup_freezes_offset_before_mobile_keyboard_focus() -> None:
source = (ROOT / "static/js/search-chat.js").read_text(encoding="utf-8")
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
set_offset = source.index("--search-overlay-top")
show_overlay = source.index("overlay.classList.remove('hidden')", set_offset)
diff --git a/tests/test_mobile_welcome_keyboard_static.py b/tests/test_mobile_welcome_keyboard_static.py
index c957724c8..9339f1563 100644
--- a/tests/test_mobile_welcome_keyboard_static.py
+++ b/tests/test_mobile_welcome_keyboard_static.py
@@ -1,11 +1,12 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
def test_mobile_keyboard_fades_welcome_without_moving_it() -> None:
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
init_js = (ROOT / "static/js/init.js").read_text(encoding="utf-8")
kb_rule = css.split("#welcome-screen.kb-hidden {", 1)[1].split("}", 1)[0]
@@ -22,7 +23,7 @@ def test_mobile_keyboard_fades_welcome_without_moving_it() -> None:
def test_nobody_label_collapses_by_width_not_keyboard_height() -> None:
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
label_rule = css.rsplit(".incognito-btn .incognito-label {", 1)[0]
media_header = label_rule.rsplit("@media", 1)[1].split("{", 1)[0]
diff --git a/tests/test_modal_dock_composer_clearance.py b/tests/test_modal_dock_composer_clearance.py
index 5dfcfe2c1..b9480e06e 100644
--- a/tests/test_modal_dock_composer_clearance.py
+++ b/tests/test_modal_dock_composer_clearance.py
@@ -1,7 +1,8 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
-CSS = Path("static/style.css").read_text(encoding="utf-8")
+CSS = app_css()
INIT_JS = Path("static/js/init.js").read_text(encoding="utf-8")
diff --git a/tests/test_notes_sidebar_new_note.py b/tests/test_notes_sidebar_new_note.py
index 6caf9f9a3..7c78818be 100644
--- a/tests/test_notes_sidebar_new_note.py
+++ b/tests/test_notes_sidebar_new_note.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_notes_sidebar_new_action_matches_library_structure_and_animation():
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
assert 'class="list-item-plus-btn sidebar-new-item-btn" id="notes-new-note-btn"' in html
assert 'new' in html[html.index('id="notes-new-note-btn"'):]
@@ -51,7 +52,7 @@ def test_checklist_preview_starts_at_first_unfinished_item():
def test_checklist_preview_releases_scroll_at_its_boundaries():
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
assert "el.addEventListener('wheel'" in notes
assert "el.closest('.notes-pane-body')" in notes
@@ -60,7 +61,7 @@ def test_checklist_preview_releases_scroll_at_its_boundaries():
def test_notes_tags_toggle_is_nudged_down():
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
rule = css[css.index(".notes-search-bar .notes-label-toggle {"):]
rule = rule[:rule.index("}")]
@@ -68,7 +69,7 @@ def test_notes_tags_toggle_is_nudged_down():
def test_notes_body_and_tag_arrow_are_nudged_down():
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
assert css.count("padding: 10px 8px 8px") >= 2
assert ".notes-pane-body .doclib-chip-scroll-arrow.right" in css
@@ -76,7 +77,7 @@ def test_notes_body_and_tag_arrow_are_nudged_down():
def test_notes_tag_strip_cannot_grow_into_blank_space():
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
rule = css[css.index(".notes-pane-body > .doclib-chip-scroll-frame:has(> .notes-labels-bar) {"):]
rule = rule[:rule.index("}")]
diff --git a/tests/test_portal_dropdown_z_js.py b/tests/test_portal_dropdown_z_js.py
index 71248ee7c..2e15549e9 100644
--- a/tests/test_portal_dropdown_z_js.py
+++ b/tests/test_portal_dropdown_z_js.py
@@ -16,6 +16,7 @@ import textwrap
from pathlib import Path
import pytest
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -100,9 +101,11 @@ def test_late_routed_dropdowns_use_top_portal_z(rel):
assert "topPortalZ()" in src, f"{rel} must call topPortalZ() for its dropdown z"
-@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js", "static/style.css"])
+@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js", "app-css"])
def test_no_hardcoded_portal_z_literals_remain(rel):
- src = (ROOT / rel).read_text()
+ # "app-css" is the whole cascade: a moved rule must not escape this check
+ # by landing in a stylesheet this list does not name.
+ src = app_css() if rel == "app-css" else (ROOT / rel).read_text()
# Match the exact 100000/100002 these dropdowns used; the trailing-digit
# guard avoids false-matching an unrelated 1000000 elsewhere.
hits = re.findall(r"z-index:\s*10000[02](?!\d)", src)
diff --git a/tests/test_procfs_scan_guard.py b/tests/test_procfs_scan_guard.py
new file mode 100644
index 000000000..9d3f91ef1
--- /dev/null
+++ b/tests/test_procfs_scan_guard.py
@@ -0,0 +1,178 @@
+"""Every procfs pid scan in the app tree must be guarded by an existence check.
+
+This is the third instance of the same defect: code walks ``/proc`` on a host
+that has no procfs, and the resulting ``FileNotFoundError`` breaks a path that
+had otherwise succeeded. Two of the three were found by reading source, so
+this pins the class rather than the instances.
+
+Deliberate AST assertion under the narrow exception in
+``tests/TESTING_STANDARD.md``: the invariant is "no *other* module grows an
+unguarded scan", which cannot be driven at runtime without importing and
+exercising every procfs-touching code path on both a Linux and a non-Linux
+host. ``test_cookbook_stop_without_procfs.py`` covers the behaviour itself.
+"""
+import ast
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+
+# Trees that ship as the application. Tests and vendored code are excluded.
+APP_TREES = (
+ "app.py",
+ "core",
+ "routes",
+ "src",
+ "services",
+ "scripts",
+ "mcp_servers",
+ "integrations",
+ "companion",
+)
+
+# Calls that enumerate a directory's entries. Reading one known file under
+# /proc is a different shape — it fails per-file and callers already handle
+# that — so only the enumerating calls are in scope here.
+_SCAN_FUNCS = {"listdir", "scandir"}
+_SCAN_METHODS = {"iterdir", "glob", "rglob"}
+
+# Calls that prove the scan is conditional on procfs being present.
+_GUARD_FUNCS = {"has_procfs", "isdir", "is_dir", "exists"}
+
+PROCFS_ROOT = "/proc"
+
+
+def _is_procfs_root(node: ast.AST) -> bool:
+ """True if ``node`` evaluates to the procfs root directory."""
+ if isinstance(node, ast.Constant) and node.value == PROCFS_ROOT:
+ return True
+ # Path("/proc")
+ if isinstance(node, ast.Call):
+ return any(_is_procfs_root(arg) for arg in node.args)
+ # PROC_ROOT / _PROC_ROOT / proc_root / platform_compat.PROC_ROOT
+ name = None
+ if isinstance(node, ast.Name):
+ name = node.id
+ elif isinstance(node, ast.Attribute):
+ name = node.attr
+ return bool(name) and name.lower().lstrip("_") == "proc_root"
+
+
+def _scan_sites(tree: ast.AST) -> list[ast.Call]:
+ sites = []
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ func = node.func
+ if isinstance(func, ast.Attribute) and func.attr in _SCAN_FUNCS:
+ if node.args and _is_procfs_root(node.args[0]):
+ sites.append(node)
+ elif isinstance(func, ast.Name) and func.id in _SCAN_FUNCS:
+ if node.args and _is_procfs_root(node.args[0]):
+ sites.append(node)
+ elif isinstance(func, ast.Attribute) and func.attr in _SCAN_METHODS:
+ if _is_procfs_root(func.value):
+ sites.append(node)
+ return sites
+
+
+def _guard_lines(tree: ast.AST) -> list[int]:
+ """Line numbers of calls that test whether procfs is present."""
+ lines = []
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ func = node.func
+ name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
+ if name not in _GUARD_FUNCS:
+ continue
+ subject_is_procfs = (
+ name in {"has_procfs", "is_wsl"}
+ or (isinstance(func, ast.Attribute) and _is_procfs_root(func.value))
+ or any(_is_procfs_root(arg) for arg in node.args)
+ )
+ if subject_is_procfs:
+ lines.append(node.lineno)
+ return lines
+
+
+def _enclosing_scope(tree: ast.AST, node: ast.AST) -> ast.AST:
+ """Smallest function/module scope containing ``node``."""
+ best = tree
+ for candidate in ast.walk(tree):
+ if not isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ continue
+ end = getattr(candidate, "end_lineno", None) or candidate.lineno
+ if candidate.lineno <= node.lineno <= end:
+ if best is tree or candidate.lineno > best.lineno:
+ best = candidate
+ return best
+
+
+def _app_python_files() -> list[Path]:
+ files = []
+ for entry in APP_TREES:
+ target = REPO_ROOT / entry
+ if target.is_file():
+ files.append(target)
+ elif target.is_dir():
+ files.extend(
+ p for p in target.rglob("*.py") if "__pycache__" not in p.parts
+ )
+ return sorted(files)
+
+
+def _collect_sites() -> tuple[list[str], list[str]]:
+ """Return (guarded, unguarded) ``path:line`` labels for procfs scans."""
+ guarded, unguarded = [], []
+ for path in _app_python_files():
+ try:
+ source = path.read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ continue
+ # Cheap pre-filter: a scan has to name the root somehow.
+ if PROCFS_ROOT not in source and "proc_root" not in source.lower():
+ continue
+ try:
+ tree = ast.parse(source)
+ except SyntaxError:
+ continue
+ sites = _scan_sites(tree)
+ if not sites:
+ continue
+ guards = _guard_lines(tree)
+ for site in sites:
+ scope = _enclosing_scope(tree, site)
+ start = getattr(scope, "lineno", 0)
+ label = f"{path.relative_to(REPO_ROOT)}:{site.lineno}"
+ if any(start <= g < site.lineno for g in guards):
+ guarded.append(label)
+ else:
+ unguarded.append(label)
+ return guarded, unguarded
+
+
+def test_every_procfs_scan_is_guarded_by_an_existence_check():
+ _guarded, unguarded = _collect_sites()
+ assert not unguarded, (
+ "procfs pid scans with no existence check in the enclosing function — "
+ "these raise FileNotFoundError on macOS and Windows: "
+ + ", ".join(unguarded)
+ )
+
+
+def test_the_guard_detector_still_sees_the_known_scans():
+ """A rename must not silently turn the assertion above into a no-op.
+
+ Lower bound, not an exact count: a new *guarded* scan is fine and should
+ not fail this. What must not happen is the detector going blind, which
+ shows up as sites disappearing.
+ """
+ guarded, unguarded = _collect_sites()
+ found = set(guarded) | set(unguarded)
+ files = {label.rsplit(":", 1)[0] for label in found}
+ known = {"src/agent_tools/web_tools.py", "src/tools/cookbook.py"}
+ assert known <= files, (
+ "the detector no longer sees a known procfs scan — check whether the "
+ f"root was renamed. Found: {sorted(found)}"
+ )
+ assert len(found) >= 3, f"expected at least 3 procfs scans, found {sorted(found)}"
diff --git a/tests/test_research_panel_ui.py b/tests/test_research_panel_ui.py
index 304b273a7..ecec6db8a 100644
--- a/tests/test_research_panel_ui.py
+++ b/tests/test_research_panel_ui.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_research_settings_use_custom_pickers_and_shared_provider_icons():
panel = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8")
- style = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ style = app_css()
settings = (ROOT / "static/js/settings.js").read_text(encoding="utf-8")
icons = (ROOT / "static/js/searchProviderIcons.js").read_text(encoding="utf-8")
diff --git a/tests/test_resend_message_nondestructive.py b/tests/test_resend_message_nondestructive.py
index ebd9bf435..a5274cc17 100644
--- a/tests/test_resend_message_nondestructive.py
+++ b/tests/test_resend_message_nondestructive.py
@@ -9,6 +9,7 @@ from pathlib import Path
from types import SimpleNamespace
from routes.history.history_routes import _keep_count_before_message
+from tests.helpers.stylesheets import app_css
_REPO = Path(__file__).resolve().parent.parent
@@ -76,7 +77,7 @@ def test_footer_resend_uses_default_replacement_behavior():
def test_footer_resend_uses_round_svg_icon_not_text_glyph():
renderer = _CHAT_RENDERER_JS.read_text(encoding="utf-8")
- style = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
+ style = app_css()
assert "const RESEND_ICON =" in renderer
assert "resend-message-icon" in renderer
diff --git a/tests/test_scheduler_restart_doublefire.py b/tests/test_scheduler_restart_doublefire.py
index 9f0c87372..ca90c55bc 100644
--- a/tests/test_scheduler_restart_doublefire.py
+++ b/tests/test_scheduler_restart_doublefire.py
@@ -21,12 +21,21 @@ def _test_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
-def _stub_heavy():
+def _stub_heavy(monkeypatch):
+ """Stub the heavy modules ``task_scheduler`` imports, for this test only.
+
+ Registered through ``monkeypatch.setitem`` so every entry is removed at
+ teardown. A bare ``sys.modules[name] = ...`` leaves an empty module behind
+ for the rest of the session, and any later test that imports the real one
+ silently gets the stub instead - a failure that only shows up under a
+ different collection order.
+ """
for name in [
"src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver",
"src.agent_loop", "src.session_manager",
]:
- sys.modules.setdefault(name, types.ModuleType(name))
+ if name not in sys.modules:
+ monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
def _setup_isolated_db():
@@ -74,7 +83,7 @@ def test_scheduler_utcnow_preserves_naive_utc_contract():
def _drive_scheduler(monkeypatch, pre_start_setup=None):
"""Build a TaskScheduler bypassing __init__ and run start() + two polls."""
- _stub_heavy()
+ _stub_heavy(monkeypatch)
cd, ScheduledTask, TaskRun = _setup_isolated_db()
from src.task_scheduler import TaskScheduler
diff --git a/tests/test_select_dropdown_theme_css.py b/tests/test_select_dropdown_theme_css.py
index bcfdf23ec..80a59df8c 100644
--- a/tests/test_select_dropdown_theme_css.py
+++ b/tests/test_select_dropdown_theme_css.py
@@ -1,11 +1,10 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
-STYLE_CSS = Path(__file__).resolve().parents[1] / "static" / "style.css"
-
-
+STYLE_CSS_TEXT = app_css()
def _style_text() -> str:
- return STYLE_CSS.read_text(encoding="utf-8")
+ return STYLE_CSS_TEXT
def test_native_select_options_use_theme_tokens():
diff --git a/tests/test_selection_overlay_clear_static.py b/tests/test_selection_overlay_clear_static.py
index 4dd8ece57..1e32c975c 100644
--- a/tests/test_selection_overlay_clear_static.py
+++ b/tests/test_selection_overlay_clear_static.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
@@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_selection_overlays_have_individual_clear_controls():
js = document_source()
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
assert "function clearSelectionAt(index)" in js
assert "className = 'doc-selection-overlay-clear'" in js
diff --git a/tests/test_settings_shell_js_behavior.py b/tests/test_settings_shell_js_behavior.py
index e353972a6..d1bb57635 100644
--- a/tests/test_settings_shell_js_behavior.py
+++ b/tests/test_settings_shell_js_behavior.py
@@ -14,6 +14,7 @@ import subprocess
from pathlib import Path
import pytest
+from tests.helpers.stylesheets import app_css
_REPO = Path(__file__).resolve().parent.parent
@@ -22,11 +23,9 @@ _COORDINATOR_HELPER = (
_REPO / "tests" / "helpers" / "test_settings_shell_coordinator.mjs"
)
_HAS_NODE = shutil.which("node") is not None
-_STYLE = _REPO / "static" / "style.css"
-
-
+_STYLE_TEXT = app_css()
def test_settings_desktop_width_targets_settings_not_cookbook():
- source = _STYLE.read_text(encoding="utf-8")
+ source = _STYLE_TEXT
settings_rule = re.search(
r"(?ms)^\.settings-modal-content\s*\{[^}]*"
diff --git a/tests/test_shared_tag_strips_static.py b/tests/test_shared_tag_strips_static.py
index ba79b71ae..f58ee3b1b 100644
--- a/tests/test_shared_tag_strips_static.py
+++ b/tests/test_shared_tag_strips_static.py
@@ -1,8 +1,9 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
CHIP_SCROLL = (ROOT / "static/js/chipScroll.js").read_text(encoding="utf-8")
diff --git a/tests/test_shell_routes.py b/tests/test_shell_routes.py
index 6ee7bbe15..072a13d96 100644
--- a/tests/test_shell_routes.py
+++ b/tests/test_shell_routes.py
@@ -5,7 +5,6 @@ import importlib
import importlib.util
import json
import os
-import socket
import sys
from pathlib import Path
from types import SimpleNamespace
@@ -28,6 +27,7 @@ from routes.shell_routes import (
_venv_activate_prefix,
DOCKER_IN_CONTAINER_HINT,
)
+from tests.helpers.unix_sockets import bound_unix_socket
def test_shell_routes_import_without_posix_pty_modules(monkeypatch):
@@ -294,30 +294,25 @@ class TestHostDockerAccess:
def test_socket_without_explicit_opt_in_is_disabled(
self,
monkeypatch,
- tmp_path,
flag,
):
- socket_path = tmp_path / "docker.sock"
- with socket.socket(socket.AF_UNIX) as unix_socket:
- unix_socket.bind(str(socket_path))
+ # Not tmp_path: binding under $TMPDIR overruns sun_path on macOS.
+ with bound_unix_socket() as socket_path:
if flag is None:
monkeypatch.delenv("ODYSSEUS_ENABLE_HOST_DOCKER", raising=False)
else:
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", flag)
- assert _host_docker_access_enabled(str(socket_path)) is False
+ assert _host_docker_access_enabled(socket_path) is False
def test_explicit_opt_in_with_unix_socket_is_enabled(
self,
monkeypatch,
- tmp_path,
):
- socket_path = tmp_path / "docker.sock"
- with socket.socket(socket.AF_UNIX) as unix_socket:
- unix_socket.bind(str(socket_path))
+ with bound_unix_socket() as socket_path:
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true")
- assert _host_docker_access_enabled(str(socket_path)) is True
+ assert _host_docker_access_enabled(socket_path) is True
class TestPackageProbeStatus:
diff --git a/tests/test_sidebar_chat_list_sizing_static.py b/tests/test_sidebar_chat_list_sizing_static.py
index aa923ba2c..488da68b9 100644
--- a/tests/test_sidebar_chat_list_sizing_static.py
+++ b/tests/test_sidebar_chat_list_sizing_static.py
@@ -1,11 +1,12 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parent.parent
def test_sidebar_chat_list_uses_content_height_when_collapsed():
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
desktop = css.split("#sessions-section #session-list {", 1)[1].split("}", 1)[0]
mobile = css.split("#sessions-section #session-list {", 2)[2].split("}", 1)[0]
diff --git a/tests/test_skill_deeplink_static.py b/tests/test_skill_deeplink_static.py
index b391c6677..d7985c3b1 100644
--- a/tests/test_skill_deeplink_static.py
+++ b/tests/test_skill_deeplink_static.py
@@ -1,5 +1,6 @@
from pathlib import Path
import re
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -102,7 +103,7 @@ def test_markdown_flattens_legacy_notes_more_details():
def test_markdown_expands_note_and_skill_more_links_without_details():
markdown_src = (ROOT / "static/js/markdown.js").read_text()
- style_src = (ROOT / "static/style.css").read_text()
+ style_src = app_css()
assert "function extractMoreListPayloads" in markdown_src
assert "ody-more-(notes|skills|memories|events|sessions)" in markdown_src
@@ -119,7 +120,7 @@ def test_markdown_expands_note_and_skill_more_links_without_details():
def test_terminal_skill_listing_uses_clickable_bounded_formatter():
agent_src = (ROOT / "src/agent_loop.py").read_text()
- style_src = (ROOT / "static/style.css").read_text()
+ style_src = app_css()
assert "_qwen_skills_terminal_summary = _skills_list_summary_from_tool_output(" in agent_src
assert '.msg-ai .body a[href^="#skill-"]' in style_src
@@ -127,7 +128,7 @@ def test_terminal_skill_listing_uses_clickable_bounded_formatter():
def test_list_links_share_compact_typography():
- style_src = (ROOT / "static/style.css").read_text()
+ style_src = app_css()
assert 'a.chat-link[href^="#events-more-"]' in style_src
assert "font-size: 11px;" in style_src
@@ -145,7 +146,7 @@ def test_open_skills_also_persists_skill_list():
def test_memory_deeplink_scrolls_and_flashes_even_when_filtered():
memory_src = (ROOT / "static/js/memory.js").read_text()
- style_src = (ROOT / "static/style.css").read_text()
+ style_src = app_css()
assert "function _resetMemoryDeepLinkFilters()" in memory_src
assert "activeCategory = 'all';" in memory_src
@@ -158,7 +159,7 @@ def test_memory_deeplink_scrolls_and_flashes_even_when_filtered():
def test_skill_and_memory_links_use_polished_chat_link_style():
- style_src = (ROOT / "static/style.css").read_text()
+ style_src = app_css()
assert '.msg-ai .body a[href^="#skill-"]' in style_src
assert '.msg-ai .body a[href^="#memory-"]' in style_src
diff --git a/tests/test_stream_completion_scroll_stability.py b/tests/test_stream_completion_scroll_stability.py
index 752c107be..dc360a774 100644
--- a/tests/test_stream_completion_scroll_stability.py
+++ b/tests/test_stream_completion_scroll_stability.py
@@ -1,11 +1,12 @@
from pathlib import Path
import re
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
UI = (ROOT / "static/js/ui.js").read_text(encoding="utf-8")
-STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
+STYLE = app_css()
def test_terminal_and_canonical_renders_preserve_chat_scroll_anchor():
diff --git a/tests/test_stylesheet_keyframe_uniqueness.py b/tests/test_stylesheet_keyframe_uniqueness.py
new file mode 100644
index 000000000..9fe26337d
--- /dev/null
+++ b/tests/test_stylesheet_keyframe_uniqueness.py
@@ -0,0 +1,52 @@
+"""No two @keyframes may share a name across the app's stylesheets.
+
+Duplicate names resolve last-wins across the whole cascade, so an earlier
+definition is dead code that still looks live at its call site. Two of them
+existed and differed from the definition that actually won, which also blocked
+moving either animation during the stylesheet decomposition: relocating one
+changes which is last and therefore changes rendered behaviour.
+"""
+
+import collections
+import re
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+STATIC = ROOT / "static"
+
+
+def _stylesheets() -> list[Path]:
+ """App-owned stylesheets, in no particular order. Vendored CSS is excluded."""
+ sheets = [STATIC / "style.css"]
+ sheets.extend(sorted((STATIC / "css").glob("*.css")))
+ return [s for s in sheets if s.exists()]
+
+
+def _keyframe_names(text: str) -> list[str]:
+ without_comments = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
+ return re.findall(
+ r"@(?:-webkit-)?keyframes\s+([A-Za-z0-9_-]+)\s*\{", without_comments
+ )
+
+
+def test_app_stylesheets_exist() -> None:
+ """Guard the guard: a rename must not turn this file into a no-op."""
+ assert _stylesheets(), "no app stylesheets found to check"
+
+
+def test_no_keyframes_name_is_declared_twice() -> None:
+ counts: collections.Counter[str] = collections.Counter()
+ where: dict[str, list[str]] = collections.defaultdict(list)
+
+ for sheet in _stylesheets():
+ for name in _keyframe_names(sheet.read_text(encoding="utf-8")):
+ counts[name] += 1
+ where[name].append(sheet.relative_to(ROOT).as_posix())
+
+ duplicates = {name: where[name] for name, n in counts.items() if n > 1}
+
+ assert duplicates == {}, (
+ "duplicate @keyframes names resolve last-wins, so every definition but "
+ f"the last is dead: {duplicates}"
+ )
diff --git a/tests/test_stylesheet_test_hygiene.py b/tests/test_stylesheet_test_hygiene.py
new file mode 100644
index 000000000..550a6c59a
--- /dev/null
+++ b/tests/test_stylesheet_test_hygiene.py
@@ -0,0 +1,54 @@
+"""Tests must reason about the whole cascade, not one file of it.
+
+``static/style.css`` is being decomposed. A test that reads that file alone,
+or builds a synthetic page linking only that file, silently loses every rule
+that has moved: it keeps passing while covering less. Both mistakes existed
+and are cheap to detect, so this fails on either.
+"""
+
+import re
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SELF = Path(__file__).name
+
+# The helper module and the manifest/snapshot tests are about the stylesheet
+# set itself, so naming the file is the point rather than a mistake.
+ALLOWED = {SELF, "test_static_stylesheet_manifest.py", "test_css_computed_style_snapshot.py"}
+
+_DIRECT_READ = re.compile(r'["\']static/style\.css["\']|"static"\s*/\s*"style\.css"')
+_LONE_LINK = re.compile(r']*href="/static/style\.css')
+
+
+def _test_sources():
+ return [p for p in sorted((ROOT / "tests").glob("*.py")) if p.name not in ALLOWED]
+
+
+def test_sources_are_discoverable() -> None:
+ """Guard the guard: a layout change must not make this vacuous."""
+ assert len(_test_sources()) > 100
+
+
+def test_no_test_reads_style_css_as_the_whole_cascade() -> None:
+ offenders = [
+ p.name for p in _test_sources()
+ if _DIRECT_READ.search(p.read_text(encoding="utf-8"))
+ ]
+
+ assert offenders == [], (
+ "read the cascade with tests.helpers.stylesheets.app_css() instead of "
+ f"static/style.css alone: {offenders}"
+ )
+
+
+def test_no_synthetic_page_links_style_css_alone() -> None:
+ offenders = [
+ p.name for p in _test_sources()
+ if _LONE_LINK.search(p.read_text(encoding="utf-8"))
+ ]
+
+ assert offenders == [], (
+ "build synthetic pages with tests.helpers.stylesheets.stylesheet_link_tags() "
+ f"so they get every stylesheet index.html loads: {offenders}"
+ )
diff --git a/tests/test_tailscale_discovery_cache.py b/tests/test_tailscale_discovery_cache.py
new file mode 100644
index 000000000..80c2f60d4
--- /dev/null
+++ b/tests/test_tailscale_discovery_cache.py
@@ -0,0 +1,69 @@
+"""A successful Tailscale query with no eligible hosts is still cached knowledge.
+
+`discover_tailscale_hosts` gated its cache on the host list being non-empty, so a
+valid "nothing to see here" answer looked identical to a cold cache and every
+caller paid for another `tailscale status --json` (up to a 5s timeout). Failures
+stay uncached so a peer coming online is still picked up promptly.
+"""
+
+import pytest
+
+from src import model_discovery
+
+
+class _Result:
+ def __init__(self, returncode, stdout):
+ self.returncode = returncode
+ self.stdout = stdout
+
+
+@pytest.fixture
+def tailscale(monkeypatch):
+ """Count `tailscale status` invocations and start from a cold cache."""
+ calls = []
+
+ def _record(result):
+ def _run(*_args, **_kwargs):
+ calls.append(1)
+ if isinstance(result, Exception):
+ raise result
+ return result
+ monkeypatch.setattr(model_discovery.subprocess, "run", _run)
+ return calls
+
+ monkeypatch.setattr(model_discovery, "_hosts_cache", [])
+ monkeypatch.setattr(model_discovery, "_hosts_cache_time", 0)
+ return _record
+
+
+def test_empty_but_successful_discovery_is_only_run_once(tailscale):
+ calls = tailscale(_Result(0, '{"Self":{},"Peer":{}}'))
+
+ assert model_discovery.discover_tailscale_hosts() == []
+ assert model_discovery.discover_tailscale_hosts() == []
+ assert len(calls) == 1
+
+
+def test_nonempty_discovery_is_still_cached(tailscale):
+ calls = tailscale(_Result(0, '{"Self":{"TailscaleIPs":["100.1.1.1"]},"Peer":{}}'))
+
+ assert model_discovery.discover_tailscale_hosts() == ["100.1.1.1"]
+ assert model_discovery.discover_tailscale_hosts() == ["100.1.1.1"]
+ assert len(calls) == 1
+
+
+@pytest.mark.parametrize(
+ "result",
+ [
+ _Result(1, ""), # tailscale installed but logged out
+ _Result(0, "not json"), # unparseable output
+ FileNotFoundError("tailscale"), # not installed
+ ],
+ ids=["nonzero_exit", "bad_json", "not_installed"],
+)
+def test_failures_stay_retryable(tailscale, result):
+ calls = tailscale(result)
+
+ assert model_discovery.discover_tailscale_hosts() == []
+ assert model_discovery.discover_tailscale_hosts() == []
+ assert len(calls) == 2
diff --git a/tests/test_theme_sidebar_mobile.py b/tests/test_theme_sidebar_mobile.py
index 9830adcb9..fa0aa0c5e 100644
--- a/tests/test_theme_sidebar_mobile.py
+++ b/tests/test_theme_sidebar_mobile.py
@@ -1,11 +1,10 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
-STYLE = Path(__file__).resolve().parents[1] / "static" / "style.css"
-
-
+STYLE_TEXT = app_css()
def test_mobile_sidebar_uses_the_sidebar_theme_surface():
- css = STYLE.read_text()
+ css = STYLE_TEXT
mobile_drawer = css[css.index("/* Sidebar overlays chat on mobile */"):css.index("/* Backdrop behind sidebar */")]
assert "background: var(--sidebar-bg, var(--panel)) !important;" in mobile_drawer
assert "background: var(--panel) !important;" not in mobile_drawer
diff --git a/tests/test_toast_ui_polish.py b/tests/test_toast_ui_polish.py
index ef6ff1c39..c94528d6d 100644
--- a/tests/test_toast_ui_polish.py
+++ b/tests/test_toast_ui_polish.py
@@ -1,6 +1,7 @@
"""Regression coverage for transient toast polish and accurate welcome tips."""
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parent.parent
@@ -9,7 +10,7 @@ ROOT = Path(__file__).resolve().parent.parent
def test_notification_history_does_not_clutter_navigation_or_capture_toasts():
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
ui = (ROOT / "static/js/ui.js").read_text(encoding="utf-8")
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
assert 'id="rail-notifications"' not in html
assert 'id="sidebar-notifications-btn"' not in html
@@ -32,7 +33,7 @@ def test_action_hint_is_part_of_action_button_and_close_is_grouped_beside_it():
def test_welcome_tips_are_plain_and_brief():
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
assert "Tip: Ctrl+K searches chats." in html
assert "Tip: Ctrl+Alt+B toggles the sidebar." in html
diff --git a/tests/test_tool_approval_frontend_routing.py b/tests/test_tool_approval_frontend_routing.py
index 8b2f66d9a..3983b6330 100644
--- a/tests/test_tool_approval_frontend_routing.py
+++ b/tests/test_tool_approval_frontend_routing.py
@@ -1,5 +1,6 @@
from pathlib import Path
import re
+from tests.helpers.stylesheets import app_css
def test_tool_approval_bypasses_polymorphic_send_button_actions():
@@ -22,7 +23,7 @@ def test_tool_approval_bypasses_polymorphic_send_button_actions():
def test_ask_user_card_has_no_close_button_and_chat_scale_text():
root = Path(__file__).resolve().parents[1]
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
- styles = (root / "static/style.css").read_text(encoding="utf-8")
+ styles = app_css()
assert "closeBtn.className = 'modal-close ask-user-close';" not in renderer
assert "closeBtn.setAttribute('aria-label', 'Dismiss question');" not in renderer
@@ -64,7 +65,7 @@ def test_ask_user_card_has_no_close_button_and_chat_scale_text():
def test_scroll_bottom_button_uses_dropdown_caret_glyph():
root = Path(__file__).resolve().parents[1]
html = (root / "static/index.html").read_text(encoding="utf-8")
- styles = (root / "static/style.css").read_text(encoding="utf-8")
+ styles = app_css()
assert 'class="scroll-nav-caret"' in html
assert "▾" in html
diff --git a/tests/test_tool_header_icons_static.py b/tests/test_tool_header_icons_static.py
index 5479ea368..e9e6d2e87 100644
--- a/tests/test_tool_header_icons_static.py
+++ b/tests/test_tool_header_icons_static.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_tool_header_has_semantic_icons_for_live_and_saved_calls():
chat = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
renderer = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ css = app_css()
assert "manage_calendar" in renderer
assert "manage_memory" in renderer
diff --git a/tests/test_visual_research_mode.py b/tests/test_visual_research_mode.py
index bdcd6da37..53c175c3c 100644
--- a/tests/test_visual_research_mode.py
+++ b/tests/test_visual_research_mode.py
@@ -5,6 +5,7 @@ import pytest
from src.deep_research import CATEGORY_PROMPTS, _infer_research_category
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.visual_report import _standard_visual_variant
+from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -20,7 +21,7 @@ def _tool_schema(name: str) -> dict:
def test_visual_research_mode_is_removed_from_ui_prompt_and_tool_schema():
panel_source = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8")
- style_source = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ style_source = app_css()
routes_source = (ROOT / "routes/research/research_routes.py").read_text(encoding="utf-8")
category_schema = _tool_schema("trigger_research")["parameters"]["properties"]["category"]
rounds_schema = _tool_schema("trigger_research")["parameters"]["properties"]["max_rounds"]
diff --git a/tests/test_workspace_confine.py b/tests/test_workspace_confine.py
index 3d746d7d2..25ca7c192 100644
--- a/tests/test_workspace_confine.py
+++ b/tests/test_workspace_confine.py
@@ -225,8 +225,13 @@ async def test_glob_confined_e2e(ws, admin):
assert ws not in r["output"]
assert "/workspace/found.py" in r["output"]
- # a secret outside the workspace must not be discoverable via glob
- outside = tempfile.mkdtemp()
+ # a secret outside the workspace must not be discoverable via glob.
+ # realpath so this directory and os.path.realpath(ws) below sit in the same
+ # resolved tree. On macOS /tmp is a symlink to /private/tmp, and mixing a
+ # resolved workspace with an unresolved secret makes relpath emit
+ # "../../../../tmp/", which trivially contains the absolute path
+ # the assertion is checking for.
+ outside = os.path.realpath(tempfile.mkdtemp())
secret = os.path.join(outside, "secret.txt")
with open(secret, "w") as f:
f.write("nope")