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")