', toolbar_start)
toolbar = document[toolbar_start:toolbar_end]
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_library_bulk_actions.py b/tests/test_email_library_bulk_actions.py
index 3d196e915..0c31856d0 100644
--- a/tests/test_email_library_bulk_actions.py
+++ b/tests/test_email_library_bulk_actions.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.document_source import document_source
_REPO = Path(__file__).resolve().parents[1]
@@ -164,8 +165,7 @@ def test_email_client_cache_drops_fixture_rows():
def test_email_compose_can_attach_gallery_images():
"""Compose attachments should support local files, documents, and Gallery images."""
- frontend = _REPO / "static" / "js" / "document.js"
- frontend_text = frontend.read_text(encoding="utf-8")
+ frontend_text = document_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "Upload from computer" in frontend_text
diff --git a/tests/test_email_open_dedup_js.py b/tests/test_email_open_dedup_js.py
index 9e5bd9f1c..9af8020b0 100644
--- a/tests/test_email_open_dedup_js.py
+++ b/tests/test_email_open_dedup_js.py
@@ -6,6 +6,7 @@ import subprocess
from pathlib import Path
import pytest
+from tests.helpers.document_source import document_source
_REPO = Path(__file__).resolve().parent.parent
@@ -120,7 +121,7 @@ def test_library_reply_open_carries_immutable_mailbox_context():
def test_attachment_warning_only_checks_authored_reply_text():
- source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
+ source = document_source()
helper = source[source.index("function _bodyMentionsAttachment"):source.index("\n\n function _clearMissingAttachmentWarnings", source.index("function _bodyMentionsAttachment"))]
assert "_emailReplyOwnText(text)" in helper
@@ -128,7 +129,7 @@ def test_attachment_warning_only_checks_authored_reply_text():
def test_email_send_saves_recovery_draft_before_send_and_retains_it_on_failure():
- source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
+ source = document_source()
send = source[source.index("async function _sendEmail"):source.index("\n\n async function _saveDraft", source.index("async function _sendEmail"))]
assert "async function _saveEmailDraftForRecovery" in source
diff --git a/tests/test_email_send_target_guard.py b/tests/test_email_send_target_guard.py
index 4f1f8ec71..562bdbba6 100644
--- a/tests/test_email_send_target_guard.py
+++ b/tests/test_email_send_target_guard.py
@@ -1,9 +1,8 @@
from pathlib import Path
+from tests.helpers.document_source import document_source
-SCRIPT = (
- Path(__file__).resolve().parents[1] / "static/js/document.js"
-).read_text(encoding="utf-8")
+SCRIPT = document_source()
def test_email_send_requires_actual_send_button_event_target():
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_email_ui_async_identity.py b/tests/test_email_ui_async_identity.py
index 35e92501a..36eb43c66 100644
--- a/tests/test_email_ui_async_identity.py
+++ b/tests/test_email_ui_async_identity.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
@@ -14,7 +15,7 @@ def test_card_delete_waits_for_durable_success_and_uses_email_identity():
def test_finished_send_does_not_close_whichever_library_opened_later():
- source = (ROOT / "static/js/document.js").read_text()
+ source = document_source()
start = source.index("async function _sendEmail()")
end = source.index("async function _saveDraft()", start)
send = source[start:end]
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_external_context_tool_gate.py b/tests/test_external_context_tool_gate.py
index dc571e359..e949f88e0 100644
--- a/tests/test_external_context_tool_gate.py
+++ b/tests/test_external_context_tool_gate.py
@@ -19,6 +19,7 @@ from src.tool_capabilities import (
messages_contain_external_untrusted_context,
tool_result_should_arm_gate,
)
+from tests.helpers.document_source import document_source
ToolBlock = namedtuple("ToolBlock", ["tool_type", "content"])
@@ -1403,13 +1404,15 @@ def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
"static/js/chat.js",
"static/js/chatRenderer.js",
"static/js/chatStream.js",
- "static/js/document.js",
"static/js/emailInbox.js",
"static/js/emailLibrary.js",
"static/js/settings.js",
"static/js/slashCommands.js",
)
]
+ # The document editor is a module set, not one file: a stale version string
+ # must not be able to hide in a module extracted out of document.js.
+ approval_module_sources.append(document_source())
assert all(
"20260722emailfastindex1" not in source
for source in approval_module_sources
diff --git a/tests/test_frontend_module_graph.py b/tests/test_frontend_module_graph.py
new file mode 100644
index 000000000..75eb51194
--- /dev/null
+++ b/tests/test_frontend_module_graph.py
@@ -0,0 +1,102 @@
+"""Every module the frontend imports must exist, and the document set must be cached.
+
+There is no bundler here, so nothing resolves the import graph before a browser
+does. A specifier that names a file which is not there is valid JavaScript:
+``node --check`` passes, and ``test_frontend_module_version_parity.py`` checks
+that a module is loaded under one URL identity without checking that the URL
+leads anywhere. The failure surfaces as a blank panel at runtime, and in the
+test suite as a scatter of unrelated browser tests going red at once with no
+mention of the missing file.
+
+That is affordable to close statically, so this closes it.
+"""
+
+import re
+from pathlib import Path
+
+from tests.helpers.document_source import document_source_paths
+
+ROOT = Path(__file__).resolve().parents[1]
+STATIC = ROOT / "static"
+
+# Vendored third-party builds and the committed editor build output are not
+# ours to reason about.
+_SKIP_DIRS = ("lib/", "js/editor/build/")
+
+# `import x from '...'`, `export ... from '...'`, and `import('...')`. Only
+# quoted specifiers: a template literal is not statically resolvable, and the
+# app does not use one.
+_SPECIFIER = re.compile(
+ r"""(?:^|[^\w.$])(?:import|export)\s*(?:[\w*{},\s$]*?\s*from\s*)?['"]([^'"]+)['"]"""
+ r"""|\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)""",
+ re.M,
+)
+
+
+def _own_scripts() -> list[Path]:
+ out = []
+ for path in sorted(STATIC.rglob("*.js")):
+ rel = path.relative_to(STATIC).as_posix()
+ if any(rel.startswith(d) or f"/{d}" in rel for d in _SKIP_DIRS):
+ continue
+ out.append(path)
+ return out
+
+
+def _imports(path: Path):
+ """(line, specifier, resolved path) for each relative/app-absolute import."""
+ source = path.read_text(encoding="utf-8")
+ for match in _SPECIFIER.finditer(source):
+ specifier = match.group(1) or match.group(2)
+ if not specifier:
+ continue
+ if not (specifier.startswith(".") or specifier.startswith("/static/")):
+ continue # bare specifier: not a file in this tree
+ bare = specifier.split("?")[0].split("#")[0]
+ if bare.startswith("/static/"):
+ target = STATIC / bare.removeprefix("/static/")
+ else:
+ target = path.parent / bare
+ line = source.count("\n", 0, match.start()) + 1
+ yield line, specifier, target
+
+
+def test_sources_are_discoverable() -> None:
+ """Guard the guard: this must not pass by scanning nothing."""
+ scripts = _own_scripts()
+ assert len(scripts) > 100, len(scripts)
+ total = sum(1 for p in scripts for _ in _imports(p))
+ assert total > 300, total
+
+
+def test_every_frontend_import_resolves_to_a_file() -> None:
+ broken = [
+ f"{path.relative_to(ROOT)}:{line} -> {specifier}"
+ for path in _own_scripts()
+ for line, specifier, target in _imports(path)
+ if not target.is_file()
+ ]
+
+ assert not broken, "imports naming files that do not exist: " + repr(broken)
+
+
+def test_document_implementation_set_is_precached() -> None:
+ """A module extracted out of document.js must join the offline manifest.
+
+ ``static/sw.js`` fetches the URLs it lists, nothing they in turn import, so
+ a new module under ``static/js/document/`` is not cached just because the
+ entry point that imports it is. Without it the editor breaks offline for
+ anyone whose cache predates the split.
+ """
+ service_worker = (STATIC / "sw.js").read_text(encoding="utf-8")
+
+ missing = []
+ for path in document_source_paths():
+ url = "/static/" + path.relative_to(STATIC).as_posix()
+ if not re.search(rf"['\"]{re.escape(url)}(?:\?[^'\"]*)?['\"]", service_worker):
+ missing.append(url)
+
+ assert not missing, (
+ "document editor modules absent from the sw.js precache lists: "
+ f"{missing}"
+ )
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_markdown_dom_xss_helpers.py b/tests/test_markdown_dom_xss_helpers.py
index db9ab9c9b..a2dd63b16 100644
--- a/tests/test_markdown_dom_xss_helpers.py
+++ b/tests/test_markdown_dom_xss_helpers.py
@@ -1,6 +1,7 @@
"""Regression guards for markdown raw-HTML sanitizer helpers."""
from pathlib import Path
+from tests.helpers.document_source import document_source
_REPO = Path(__file__).resolve().parent.parent
@@ -27,7 +28,7 @@ def test_markdown_raw_html_sanitizer_strips_scriptable_css():
def test_email_rich_body_render_path_reuses_raw_html_sanitizer():
markdown_src = (_REPO / "static" / "js" / "markdown.js").read_text(encoding="utf-8")
- document_src = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
+ document_src = document_source()
email_body_helper = document_src.split("function _emailBodyToHtml(text)", 1)[1].split(
" // Mirror the rich body's plain text", 1
)[0]
diff --git a/tests/test_markdown_lazy_lib_loading_js.py b/tests/test_markdown_lazy_lib_loading_js.py
index ee334a828..d9fa31109 100644
--- a/tests/test_markdown_lazy_lib_loading_js.py
+++ b/tests/test_markdown_lazy_lib_loading_js.py
@@ -15,6 +15,7 @@ import textwrap
from pathlib import Path
import pytest
+from tests.helpers.document_source import document_source
_REPO = Path(__file__).resolve().parent.parent
_HAS_NODE = shutil.which("node") is not None
@@ -401,7 +402,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
def test_pdf_export_typesets_its_container_before_html2pdf():
"""Ordering in a call site, so pin the call site. No node needed."""
- source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
+ source = document_source()
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
assert match, "exportAsPdf not found"
body = match.group(1)
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_document_mutual_minimize.py b/tests/test_notes_document_mutual_minimize.py
index b09ac943b..88315e5c8 100644
--- a/tests/test_notes_document_mutual_minimize.py
+++ b/tests/test_notes_document_mutual_minimize.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
@@ -12,7 +13,7 @@ def test_opening_notes_minimizes_open_document():
def test_opening_document_minimizes_notes():
- script = (ROOT / "static/js/document.js").read_text()
+ script = document_source()
assert "function _minimizeNotesForDocumentOpen()" in script
assert "window.notesModule.closePanel('down')" in script
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_pdf_export_preserves_import_static.py b/tests/test_pdf_export_preserves_import_static.py
index 03688594f..0488ffd2b 100644
--- a/tests/test_pdf_export_preserves_import_static.py
+++ b/tests/test_pdf_export_preserves_import_static.py
@@ -1,11 +1,12 @@
from pathlib import Path
+from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
def test_pdf_backed_documents_do_not_offer_destructive_html_pdf_export():
- source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
+ source = document_source()
assert "if (!isForm) {" in source
assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF'" in source
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_preview_hides_import_action_static.py b/tests/test_preview_hides_import_action_static.py
index 41635c867..8f70b222c 100644
--- a/tests/test_preview_hides_import_action_static.py
+++ b/tests/test_preview_hides_import_action_static.py
@@ -1,11 +1,12 @@
from pathlib import Path
+from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
def test_preview_hides_import_action_and_restores_it_for_empty_editor():
- source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
+ source = document_source()
assert "const emptyImport = document.getElementById('doc-rich-empty-import');" in source
assert "if (emptyImport) emptyImport.style.display = 'none';" in source
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_review_docx_async_identity.py b/tests/test_review_docx_async_identity.py
index 06cecd278..d4547dc70 100644
--- a/tests/test_review_docx_async_identity.py
+++ b/tests/test_review_docx_async_identity.py
@@ -1,10 +1,11 @@
"""Execute the actual DOCX handlers with deferred network responses."""
import subprocess
from pathlib import Path
+from tests.helpers.document_source import document_source
def test_docx_responses_do_not_overwrite_new_tabs_or_hidden_previews():
- source = (Path(__file__).resolve().parents[1] / "static/js/document.js").read_text()
+ source = document_source()
handlers = source.split(" let _docxPreviewRequest = 0;", 1)[1].split(" /** Parse CSV", 1)[0]
script = r'''
import assert from 'node:assert/strict';
diff --git a/tests/test_richtext_format_selection_static.py b/tests/test_richtext_format_selection_static.py
index c14be28a8..5259325cb 100644
--- a/tests/test_richtext_format_selection_static.py
+++ b/tests/test_richtext_format_selection_static.py
@@ -1,11 +1,12 @@
from pathlib import Path
+from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
def test_richtext_toolbar_preserves_selection_before_formatting():
- source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
+ source = document_source()
assert "let _savedFormatTextareaSelection = null;" in source
assert "let _savedFormatRichRange = null;" in source
diff --git a/tests/test_richtext_preview_returns_to_editor_static.py b/tests/test_richtext_preview_returns_to_editor_static.py
index 90ea870da..c9dfa007a 100644
--- a/tests/test_richtext_preview_returns_to_editor_static.py
+++ b/tests/test_richtext_preview_returns_to_editor_static.py
@@ -1,11 +1,12 @@
from pathlib import Path
+from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
def test_richtext_preview_returns_to_contenteditable_editor():
- source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
+ source = document_source()
assert "const currentLang = document.getElementById('doc-language-select')?.value || '';" in source
assert "if (richMode) {" in source
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 d3671fc67..1e32c975c 100644
--- a/tests/test_selection_overlay_clear_static.py
+++ b/tests/test_selection_overlay_clear_static.py
@@ -1,12 +1,14 @@
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]
def test_selection_overlays_have_individual_clear_controls():
- js = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
- css = (ROOT / "static/style.css").read_text(encoding="utf-8")
+ js = document_source()
+ 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")