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 c25e74823..d9f35f3b2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -41,6 +41,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 3a8e7f4e4..0affe928a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -161,3 +161,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/document_source.py b/tests/helpers/document_source.py new file mode 100644 index 000000000..691466978 --- /dev/null +++ b/tests/helpers/document_source.py @@ -0,0 +1,288 @@ +"""Read the document editor's JavaScript the way the browser loads it. + +``static/js/document.js`` is being decomposed. It stays the entry point the +browser requests -- ``static/index.html`` names it, ``static/sw.js`` precaches +it, and five modules import it -- but the implementation moves into modules +under ``static/js/document/``. The implementation set is the entry plus that +directory. + +Two habits in the existing tests do not survive that move, and this module +exists to replace both. + +**Reading the entry file alone.** A membership assertion against +``document.js`` silently covers less the moment the behaviour it names moves +out. Use :func:`document_source` for those: it is the whole implementation set, +so a test keeps finding what it asserts on wherever the code lands. + +**Slicing between two adjacent functions.** ``function_body("a")`` means "the region between a and b", which is only +the body of ``a`` while ``a`` and ``b`` happen to be neighbours in one file. +After a split they may sit in different modules, and then the slice runs to the +end of the concatenation and quietly grows: an ``assert "x" in region`` passes +against code it was never meant to see. Several of these also hard-code the +entry file's two-space indentation (``"\\n function showDocTabMenu"``), which +no extracted module reproduces. Use :func:`function_body` or +:func:`declaration` instead -- they find the construct by name, in whichever +module defines it, and end at its real closing brace. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_STATIC = Path(__file__).resolve().parents[2] / "static" +_ENTRY = _STATIC / "js" / "document.js" + +# Extracted implementation modules get one home, so the set is discoverable +# without a manifest anyone has to remember to update. +_IMPL_DIR = _STATIC / "js" / "document" + + +def document_source_paths() -> list[Path]: + """Every file holding document-editor implementation, entry first. + + The entry comes first so a concatenation reads in the order the browser + evaluates the graph's root; the rest are sorted for determinism. + """ + if not _ENTRY.is_file(): + raise AssertionError(f"document editor entry point is missing: {_ENTRY}") + extracted = sorted(_IMPL_DIR.rglob("*.js")) if _IMPL_DIR.is_dir() else [] + return [_ENTRY, *extracted] + + +def document_source() -> str: + """The whole implementation set as one string, entry first. + + For membership assertions (``assert "..." in document_source()``). For + anything positional use :func:`function_body` or :func:`declaration`. + """ + return "\n".join(p.read_text(encoding="utf-8") for p in document_source_paths()) + + +# --- Locating a construct by name, not by what follows it ------------------ + +def _defining_source(pattern: re.Pattern[str], what: str) -> tuple[str, int]: + """The source text that defines ``what``, and the offset of the match.""" + hits = [] + for path in document_source_paths(): + src = path.read_text(encoding="utf-8") + for m in pattern.finditer(src): + hits.append((path, src, m.start())) + if not hits: + raise AssertionError(f"{what} is not defined anywhere in {_describe_set()}") + if len(hits) > 1: + where = ", ".join( + f"{p.relative_to(_STATIC.parent)}:{s.count(chr(10), 0, o) + 1}" + for p, s, o in hits + ) + raise AssertionError(f"{what} is defined more than once ({where})") + _path, src, offset = hits[0] + return src, offset + + +def _describe_set() -> str: + return ", ".join(str(p.relative_to(_STATIC.parent)) for p in document_source_paths()) + + +def function_body(name: str) -> str: + """The full text of function ``name``, signature through closing brace. + + Matches ``function name``, optionally prefixed by ``export`` and/or + ``async``, at any indentation, in whichever module of the implementation + set defines it. The end is found by matching braces rather than by naming + whatever declaration follows, so moving the function -- or the one after + it -- does not change the region a test sees. + """ + pattern = re.compile( + r"^[ \t]*(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(", + re.M, + ) + src, offset = _defining_source(pattern, f"function {name}") + # Skip the parameter list before looking for the body. A destructured + # parameter -- `function f(table, { headerRow, headerColumn })` -- opens a + # brace that is not the body, and matching it would return the signature + # alone. + body_start = _end_of_params(src, src.index("(", offset)) + return src[offset : _end_of_block(src, body_start)] + + +def declaration(name: str) -> str: + """The full text of a top-level ``const``/``let``/``var`` named ``name``. + + For the array and object tables the tests assert on (toolbar groups, slash + commands, input rules). Ends at the declaration's closing bracket or brace, + or at the end of the statement for a simple initialiser. + """ + pattern = re.compile( + r"^[ \t]*(?:export\s+)?(?:const|let|var)\s+" + re.escape(name) + r"\b", + re.M, + ) + src, offset = _defining_source(pattern, f"declaration {name}") + return src[offset : _end_of_statement(src, offset)] + + +# --- A brace matcher that is not fooled by braces inside literals ---------- +# +# `document.js` is full of template literals building DOM, regexes containing +# braces, and apostrophes inside comments. Counting raw `{`/`}` mis-slices on +# all three, so the scan tracks what kind of text it is inside. + +# After one of these, `/` starts a regex literal; after a value it is division. +_REGEX_OK_BEFORE = re.compile(r"[({\[,;:=!&|?+\-*~^%<>]\s*$|\b(?:return|typeof|case|in|of|new|delete|void|do|else|yield|await)\s*$") + + +def _scan(src: str, start: int, stop): + """Walk ``src`` from ``start``, skipping literals and comments. + + Calls ``stop(index, depth_delta_applied)``-free: instead it yields + ``(index, char)`` for code positions only, so callers can track nesting. + """ + i, n = start, len(src) + # Stack of template-literal depths: entering `${` pushes brace depth. + template_stack: list[int] = [] + while i < n: + c = src[i] + two = src[i : i + 2] + if two == "//": + j = src.find("\n", i) + i = n if j == -1 else j + 1 + continue + if two == "/*": + j = src.find("*/", i + 2) + i = n if j == -1 else j + 2 + continue + if c in "'\"": + i = _skip_quoted(src, i, c) + continue + if c == "`": + i += 1 + i, entered = _skip_template(src, i) + if entered: + template_stack.append(0) + continue + if c == "/" and _REGEX_OK_BEFORE.search(src[max(0, i - 24) : i]): + j = _skip_regex(src, i) + if j is not None: + i = j + continue + if template_stack: + # Inside `${ ... }`: a `}` that closes it returns to template text. + if c == "{": + template_stack[-1] += 1 + elif c == "}": + if template_stack[-1] == 0: + template_stack.pop() + i += 1 + i, entered = _skip_template(src, i) + if entered: + template_stack.append(0) + continue + template_stack[-1] -= 1 + yield i, c + i += 1 + + +def _skip_quoted(src: str, i: int, quote: str) -> int: + i += 1 + n = len(src) + while i < n: + if src[i] == "\\": + i += 2 + continue + if src[i] == quote: + return i + 1 + if src[i] == "\n": # unterminated; do not run away + return i + i += 1 + return n + + +def _skip_template(src: str, i: int) -> tuple[int, bool]: + """From inside template text, advance to the backtick end or a ``${``. + + Returns the new index and whether an interpolation was entered. + """ + n = len(src) + while i < n: + if src[i] == "\\": + i += 2 + continue + if src[i] == "`": + return i + 1, False + if src[i : i + 2] == "${": + return i + 2, True + i += 1 + return n, False + + +def _skip_regex(src: str, i: int) -> int | None: + """Past a regex literal starting at ``i``, or None if it is not one.""" + i += 1 + n = len(src) + in_class = False + while i < n: + c = src[i] + if c == "\\": + i += 2 + continue + if c == "\n": + return None + if in_class: + if c == "]": + in_class = False + elif c == "[": + in_class = True + elif c == "/": + i += 1 + while i < n and src[i].isalpha(): # flags + i += 1 + return i + i += 1 + return None + + +def _end_of_block(src: str, start: int) -> int: + """Index just past the ``}`` closing the first ``{`` at or after ``start``.""" + depth = 0 + seen = False + for i, c in _scan(src, start, None): + if c == "{": + depth += 1 + seen = True + elif c == "}": + depth -= 1 + if seen and depth == 0: + return i + 1 + raise AssertionError(f"unbalanced braces from offset {start}") + + +def _end_of_statement(src: str, start: int) -> int: + """Index just past the end of the declaration statement at ``start``. + + Ends on the ``;`` or newline that closes it at nesting depth zero, so an + array or object initialiser is returned whole. + """ + depth = 0 + for i, c in _scan(src, start, None): + if c in "{[(": + depth += 1 + elif c in "}])": + depth -= 1 + elif depth == 0 and c == ";": + return i + 1 + elif depth == 0 and c == "\n" and i > start: + return i + return len(src) + + +def _end_of_params(src: str, open_paren: int) -> int: + """Index just past the ``)`` closing the parameter list at ``open_paren``.""" + depth = 0 + for i, c in _scan(src, open_paren, None): + if c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + return i + 1 + raise AssertionError(f"unbalanced parameter list at offset {open_paren}") 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_active_document_visibility_contract.py b/tests/test_active_document_visibility_contract.py index efe3c2f2f..81bbfdfea 100644 --- a/tests/test_active_document_visibility_contract.py +++ b/tests/test_active_document_visibility_contract.py @@ -1,9 +1,10 @@ from pathlib import Path import re +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOCUMENT_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOCUMENT_JS = document_source() CHAT_JS = (ROOT / "static/js/chat.js").read_text(encoding="utf-8") APP_JS = (ROOT / "static/app.js").read_text(encoding="utf-8") SETTINGS_JS = (ROOT / "static/js/settings.js").read_text(encoding="utf-8") @@ -12,9 +13,7 @@ CHAT_ROUTE = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8") def test_visible_or_minimized_linked_document_is_sent_as_chat_context(): - function = DOCUMENT_JS.split("export function getChatDocumentId()", 1)[1].split( - "export function getActiveEmailComposerContext()", 1 - )[0] + function = function_body("getChatDocumentId") assert "pane?.isConnected" in function assert "document.body.classList.contains('doc-view')" not in function assert "style?.display !== 'none'" in function 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_active_restore.py b/tests/test_document_active_restore.py index 527cad7d8..1a2e32c8d 100644 --- a/tests/test_document_active_restore.py +++ b/tests/test_document_active_restore.py @@ -1,11 +1,10 @@ """Regression guards for restoring a chat's exact active document.""" from pathlib import Path +from tests.helpers.document_source import document_source, function_body -DOC_JS = ( - Path(__file__).resolve().parents[1] / "static/js/document.js" -).read_text(encoding="utf-8") +DOC_JS = document_source() def test_active_document_is_persisted_per_session(): @@ -32,9 +31,7 @@ def test_closing_active_document_clears_stale_restore_pointer(): def test_explicit_document_open_clears_minimized_dock_state(): - ensure_mounted = DOC_JS.split("function _ensureDocPaneMounted()", 1)[1].split( - "export async function loadDocument", 1 - )[0] + ensure_mounted = function_body("_ensureDocPaneMounted") assert "Modals.isMinimized('doc-panel')" in ensure_mounted assert "Modals.unregister('doc-panel');" in ensure_mounted @@ -42,9 +39,7 @@ def test_explicit_document_open_clears_minimized_dock_state(): def test_library_open_intent_is_persisted_before_delayed_session_restore(): - body = DOC_JS.split("export function prepareDocumentOpen(sessionId)", 1)[1].split( - "/** Switch chat", 1 - )[0] + body = function_body("prepareDocumentOpen") assert "_markDocVisibleState(sessionId, 'open');" in body assert "Modals.isMinimized('doc-panel')" in body diff --git a/tests/test_document_ai_preview_refresh_js.py b/tests/test_document_ai_preview_refresh_js.py index ff57d50e1..6138e1fe5 100644 --- a/tests/test_document_ai_preview_refresh_js.py +++ b/tests/test_document_ai_preview_refresh_js.py @@ -2,13 +2,13 @@ import re from pathlib import Path +from tests.helpers.document_source import document_source -SRC = Path(__file__).resolve().parent.parent / "static/js/document.js" def _function_body(name: str) -> str: - text = SRC.read_text(encoding="utf-8") + text = document_source() match = re.search(rf"\n\s*(?:export\s+)?(?:async\s+)?function\s+{name}\([^)]*\)\s*\{{", text) assert match, f"{name} not found" diff --git a/tests/test_document_deeplink.py b/tests/test_document_deeplink.py index 95ee24f43..621249834 100644 --- a/tests/test_document_deeplink.py +++ b/tests/test_document_deeplink.py @@ -6,6 +6,7 @@ no JS unit harness for it — these pin the source-level invariants that the """ from pathlib import Path +from tests.helpers.document_source import document_source _REPO = Path(__file__).resolve().parents[1] @@ -21,13 +22,13 @@ def test_chat_document_links_use_the_document_id(): def test_document_deeplink_handled_on_hashchange_and_load(): """#document- in the URL must open the doc on refresh / URL-bar nav, not just on click.""" - js = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8") + js = document_source() assert "addEventListener('hashchange', _maybeOpenDocFromHash)" in js assert "#document-" in js def test_failed_document_load_surfaces_user_error(): """A missing/failed document must tell the user, not fail silently.""" - js = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8") + js = document_source() assert "uiModule.showError" in js assert "Document not found" in js diff --git a/tests/test_document_diff_discard_on_update_js.py b/tests/test_document_diff_discard_on_update_js.py index eb2ed05b0..40375d915 100644 --- a/tests/test_document_diff_discard_on_update_js.py +++ b/tests/test_document_diff_discard_on_update_js.py @@ -19,9 +19,10 @@ browser-coupled and not importable in pytest. """ from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text() +DOC_JS = document_source() GUARD = "if (_diffModeActive) exitDiffMode(true);" diff --git a/tests/test_document_edit_reference_js.py b/tests/test_document_edit_reference_js.py index 243d23406..9ee7842ff 100644 --- a/tests/test_document_edit_reference_js.py +++ b/tests/test_document_edit_reference_js.py @@ -1,12 +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 = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +DOCUMENT = document_source() +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 89cbc7b81..a84876dd8 100644 --- a/tests/test_document_editor_scroll.py +++ b/tests/test_document_editor_scroll.py @@ -9,11 +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 = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") +DOC_JS = document_source() +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 bd4b297ac..709937586 100644 --- a/tests/test_document_history_controls.py +++ b/tests/test_document_history_controls.py @@ -3,10 +3,12 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_history_buttons_start_disabled_and_follow_native_history(): @@ -24,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; @@ -85,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_module_api.py b/tests/test_document_module_api.py new file mode 100644 index 000000000..97c95de75 --- /dev/null +++ b/tests/test_document_module_api.py @@ -0,0 +1,166 @@ +"""The document editor's public surface, pinned against the running module. + +``static/js/document.js`` is becoming a re-export wrapper. Five modules import +its default export, ``static/index.html`` loads it, and ``documentLibrary.js`` +is handed named functions through its config object -- so the surface is the +contract that decomposition must not change, and a method that quietly stops +being re-exported is a runtime ``TypeError`` in whichever panel used it. + +This loads the module in a browser and reads what it actually exports, rather +than grepping the source for the literal object: after extraction the object +may be assembled from imports, and a source-shape assertion would pass while +the export was broken. +""" + +import json +import os +import subprocess +from pathlib import Path + +from tests.helpers.document_source import document_source + +ROOT = Path(__file__).resolve().parents[1] +DOC_JS = document_source() + +# Every key on the default export. Consumers reach the editor through this +# object, so removing one is a breaking change; adding one is not. +DEFAULT_EXPORT_KEYS = { + "clearAll", + "clearSelection", + "closeLibrary", + "closePanel", + "createDocument", + "ensureDocPanel", + "ensureEmailDraftEnvelope", + "ensurePaneMounted", + "enterDiffMode", + "exitDiffMode", + "findEmailDocId", + "focusEmailReplyBody", + "getActiveEmailComposerContext", + "getChatDocumentId", + "getCurrentDocId", + "getSelectionContext", + "handleDocSuggestions", + "handleDocUpdate", + "init", + "injectFreshDoc", + "isDiffModeActive", + "isLibraryOpen", + "isPanelOpen", + "loadDocument", + "loadSessionDocs", + "moveActiveDocumentToCurrentChat", + "moveActiveDocumentToNewChat", + "newDocument", + "openEmailDraft", + "openLibrary", + "openPanel", + "replaceEmailReplyBody", + "restoreSelectionReference", + "saveDocument", + "streamDocDelta", + "streamDocFinalize", + "streamDocOpen", + "swapSide", +} + +# Named exports. `prepareDocumentOpen` is deliberately in this set and not on +# the default export: `documentLibrary.js` receives it through `initLibrary`'s +# config, and a browser test calls it off the module namespace. +NAMED_EXPORTS = { + "clearAll", + "closePanel", + "createDocument", + "ensureDocPanel", + "ensureEmailDraftEnvelope", + "findEmailDocId", + "focusEmailReplyBody", + "getActiveEmailComposerContext", + "getChatDocumentId", + "getCurrentDocId", + "getSelectionContext", + "handleDocSuggestions", + "handleDocUpdate", + "init", + "injectFreshDoc", + "isPanelOpen", + "loadDocument", + "loadSessionDocs", + "newDocument", + "openEmailDraft", + "openPanel", + "prepareDocumentOpen", + "replaceEmailReplyBody", + "restoreSelectionReference", + "saveDocument", + "streamDocDelta", + "streamDocFinalize", + "streamDocOpen", + "swapSide", +} + + +def _module_surface(): + script = r""" + import { chromium } from 'playwright'; + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); + await page.setContent('
'); + const surface = await page.evaluate(async () => { + const mod = await import('/static/js/document.js?v=module-api-surface-1'); + const fn = (o) => Object.keys(o).filter(k => typeof o[k] === 'function'); + return { + named: Object.keys(mod).filter(k => k !== 'default'), + namedFunctions: fn(mod).filter(k => k !== 'default'), + defaultKeys: Object.keys(mod.default), + defaultFunctions: fn(mod.default), + globalIsSameObject: window.documentModule === mod.default, + }; + }); + console.log(JSON.stringify(surface)); + await browser.close(); + """ + result = subprocess.run( + ["node", "--input-type=module", "-e", script], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + env=os.environ.copy(), + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_default_export_surface_is_complete_and_callable(): + surface = _module_surface() + + missing = DEFAULT_EXPORT_KEYS - set(surface["defaultKeys"]) + assert not missing, f"default export lost methods: {sorted(missing)}" + + not_callable = DEFAULT_EXPORT_KEYS - set(surface["defaultFunctions"]) + assert not not_callable, ( + f"default export keys that are not functions: {sorted(not_callable)}" + ) + + +def test_named_exports_survive_and_stay_callable(): + surface = _module_surface() + + missing = NAMED_EXPORTS - set(surface["named"]) + assert not missing, f"named exports lost: {sorted(missing)}" + + not_callable = NAMED_EXPORTS - set(surface["namedFunctions"]) + assert not not_callable, f"named exports that are not functions: {sorted(not_callable)}" + + +def test_window_bridge_is_the_default_export(): + """`window.documentModule` is a compatibility bridge no import graph shows. + + Consumers reach the editor off the global, so it must stay the same object + as the default export rather than a second, partially wired copy. + """ + assert "window.documentModule = documentModule" in DOC_JS + assert _module_surface()["globalIsSameObject"] is True diff --git a/tests/test_document_outline.py b/tests/test_document_outline.py index 4ab98d680..ae5859b20 100644 --- a/tests/test_document_outline.py +++ b/tests/test_document_outline.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 +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +DOC_JS = document_source() +STYLE = app_css() def _run_node(script: str): @@ -41,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"), @@ -71,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'); @@ -126,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 52340f96d..9eaa5708e 100644 --- a/tests/test_document_preview_edit_button.py +++ b/tests/test_document_preview_edit_button.py @@ -1,11 +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 = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") +DOC_JS = document_source() +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 b36bdb67b..0e633e284 100644 --- a/tests/test_document_rich_checklist_enter.py +++ b/tests/test_document_rich_checklist_enter.py @@ -3,10 +3,12 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_checklist_enter_uses_native_edit_commands_and_resets_state(): @@ -26,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'); @@ -78,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 64c237a7b..9a89e5764 100644 --- a/tests/test_document_rich_color_reset_and_contrast.py +++ b/tests/test_document_rich_color_reset_and_contrast.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 +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +DOC_JS = document_source() +STYLE = app_css() def test_color_controls_have_theme_reset_and_split_palettes(): @@ -20,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 }); @@ -27,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()); @@ -58,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 f5b6d5c6b..6635051e7 100644 --- a/tests/test_document_rich_docx_export.py +++ b/tests/test_document_rich_docx_export.py @@ -5,10 +5,12 @@ 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 ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_rich_docx_converter_maps_editor_structure_instead_of_raw_html(): @@ -64,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'); @@ -89,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, @@ -158,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'); @@ -180,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 c4e293ad8..dfc77ef20 100644 --- a/tests/test_document_rich_find_boundaries.py +++ b/tests/test_document_rich_find_boundaries.py @@ -3,16 +3,16 @@ 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 ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_find_index_inserts_boundaries_without_flattening_inline_spans(): - section = DOC_JS.split("function _buildRichFindRanges", 1)[1].split( - "function _renderRichFindRanges", 1 - )[0] + section = function_body("_buildRichFindRanges") assert "const blockSelector = 'p,div,h1,h2,h3,h4,h5,h6,li,blockquote,pre,td,th'" in section assert "block !== previousBlock" in section assert "between.cloneContents().querySelector?.('br')" in section @@ -25,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'); @@ -73,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 cfecf8699..764e6152e 100644 --- a/tests/test_document_rich_font_color_controls.py +++ b/tests/test_document_rich_font_color_controls.py @@ -6,10 +6,12 @@ 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 ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = app_css() @@ -47,8 +49,8 @@ def test_horizontal_rule_is_ordered_after_clear_formatting(): def test_image_options_are_hidden_until_a_rich_image_is_selected(): - clear_fn = DOC_JS.split("function _clearRichImageSelection()", 1)[1].split("function _selectRichImage", 1)[0] - select_fn = DOC_JS.split("function _selectRichImage", 1)[1].split("function _selectedRichImage", 1)[0] + clear_fn = function_body("_clearRichImageSelection") + select_fn = function_body("_selectRichImage") assert "imageButton.style.display = 'none';" in clear_fn assert "imageButton.style.display = '';" in select_fn @@ -94,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'); @@ -190,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 d2df9ca89..0bd4fe317 100644 --- a/tests/test_document_rich_heading_enter.py +++ b/tests/test_document_rich_heading_enter.py @@ -3,16 +3,16 @@ 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 ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_heading_enter_uses_single_native_history_commands(): - helper = DOC_JS.split("function _handleRichHeadingEnter", 1)[1].split( - "let _richInlineCodeTypingArmed", 1 - )[0] + helper = function_body("_handleRichHeadingEnter") assert "selection.isCollapsed" in helper assert "h1, h2, h3, h4, h5, h6" in helper @@ -29,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'); @@ -73,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, @@ -152,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 7c573dd03..551b9da00 100644 --- a/tests/test_document_rich_image_caption.py +++ b/tests/test_document_rich_image_caption.py @@ -5,20 +5,18 @@ 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 ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = app_css() def test_image_caption_uses_semantic_figure_and_structured_export_paths(): - caption = DOC_JS.split("async function _editRichImageCaption", 1)[1].split( - "function _applyRichImageAction", 1 - )[0] - converter = DOC_JS.split("function _docxFigureBlocks", 1)[1].split( - "function _docxBlocksFromNodes", 1 - )[0] + caption = function_body("_editRichImageCaption") + converter = function_body("_docxFigureBlocks") assert "function _promptImageCaption" in DOC_JS assert "function _replaceRichImageFigure" in DOC_JS @@ -36,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'); @@ -116,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 0e93edc35..4d697e6c1 100644 --- a/tests/test_document_rich_input_rules.py +++ b/tests/test_document_rich_input_rules.py @@ -5,10 +5,12 @@ 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 = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = app_css() @@ -28,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'); @@ -101,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 306d45cce..76f15a4f3 100644 --- a/tests/test_document_rich_selection_toolbar.py +++ b/tests/test_document_rich_selection_toolbar.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 +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +DOC_JS = document_source() +STYLE = app_css() def test_selection_toolbar_uses_shared_formatting_path_and_preserves_range(): @@ -26,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'); @@ -102,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 7c191985d..d4e8bd09d 100644 --- a/tests/test_document_rich_slash_menu.py +++ b/tests/test_document_rich_slash_menu.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 +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +DOC_JS = document_source() +STYLE = app_css() def test_slash_menu_reuses_rich_text_actions_and_is_accessible(): @@ -41,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'); @@ -149,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 e6fd58922..1ff6f9a96 100644 --- a/tests/test_document_rich_structure_tools.py +++ b/tests/test_document_rich_structure_tools.py @@ -3,26 +3,22 @@ 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 = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +DOC_JS = document_source() +STYLE = app_css() def test_heading_levels_and_page_break_are_exposed_everywhere(): - slash = DOC_JS.split("const _RICH_SLASH_COMMANDS", 1)[1].split( - "const _RICH_BLOCK_INPUT_RULES", 1 - )[0] - rules = DOC_JS.split("const _RICH_BLOCK_INPUT_RULES", 1)[1].split( - "function _applyRichBlockInputRule", 1 - )[0] + slash = declaration("_RICH_SLASH_COMMANDS") + rules = declaration("_RICH_BLOCK_INPUT_RULES") dropdown = DOC_JS.split("function _showMdDropdown", 1)[1].split( "function initMdToolbar", 1 )[0] - exporter = DOC_JS.split("function _richTextExportCss", 1)[1].split( - "function exportAsHtml", 1 - )[0] + exporter = function_body("_richTextExportCss") for level in (5, 6): assert f"action: 'h{level}'" in slash @@ -44,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; @@ -152,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 a410723e3..ac2b850b2 100644 --- a/tests/test_document_rich_table_cell_alignment.py +++ b/tests/test_document_rich_table_cell_alignment.py @@ -3,19 +3,17 @@ 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 ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_table_cell_alignment_uses_contextual_undoable_table_path(): - actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split( - "function _insertRichPageBreak", 1 - )[0] - state = DOC_JS.split("function _richDropdownCurrentActions", 1)[1].split( - "function _showMdDropdown", 1 - )[0] + actions = function_body("_applyRichTableAction") + state = function_body("_richDropdownCurrentActions") for alignment in ("top", "middle", "bottom"): assert f"table:cell-align:{alignment}" in DOC_JS @@ -31,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'); @@ -122,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 4c7c03626..378d02828 100644 --- a/tests/test_document_rich_table_header_preservation.py +++ b/tests/test_document_rich_table_header_preservation.py @@ -3,19 +3,19 @@ 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 ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_table_structure_uses_central_header_mode_normalization(): helpers = DOC_JS.split("function _richTableHeaderModes", 1)[1].split( "function _replaceRichTable", 1 )[0] - append = DOC_JS.split("function _appendRichTableRow", 1)[1].split( - "function _insertRichTable", 1 - )[0] + append = function_body("_appendRichTableRow") actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split( "function applyMdFormat", 1 )[0] @@ -36,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'); @@ -119,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 cd77ba93c..5d0d67693 100644 --- a/tests/test_document_rich_table_headers.py +++ b/tests/test_document_rich_table_headers.py @@ -3,19 +3,19 @@ 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 ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_table_header_controls_use_tag_replacement_and_native_history(): table_actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split( "function applyMdFormat", 1 )[0] - menu_state = DOC_JS.split("function _richDropdownCurrentActions", 1)[1].split( - "function _showMdDropdown", 1 - )[0] + menu_state = function_body("_richDropdownCurrentActions") assert "function _replaceRichTableCellTag" in DOC_JS assert "table:toggle-header-row" in table_actions @@ -24,9 +24,7 @@ def test_table_header_controls_use_tag_replacement_and_native_history(): assert "headerModes.headerRow = !headerModes.headerRow" in table_actions assert "headerModes.headerColumn = !headerModes.headerColumn" in table_actions assert "_applyRichTableHeaderModes(clone, headerModes)" in table_actions - header_normalizer = DOC_JS.split("function _applyRichTableHeaderModes", 1)[1].split( - "function _replaceRichTable", 1 - )[0] + header_normalizer = function_body("_applyRichTableHeaderModes") assert "_replaceRichTableCellTag" in header_normalizer assert "_replaceRichTable(rich, original, clone" in table_actions assert "current.add('table:toggle-header-row')" in menu_state @@ -40,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'); @@ -117,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 85bb04387..9269c154e 100644 --- a/tests/test_document_rich_table_merge_split.py +++ b/tests/test_document_rich_table_merge_split.py @@ -3,16 +3,16 @@ 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 ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_merge_split_commands_use_contextual_undoable_table_path(): - actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split( - "function _insertRichPageBreak", 1 - )[0] + actions = function_body("_applyRichTableAction") menu = DOC_JS.split("function _showMdDropdown", 1)[1].split( "function initMdToolbar", 1 )[0] @@ -34,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'); @@ -138,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 abca1d02a..cbe116e0b 100644 --- a/tests/test_document_rich_table_tab_history.py +++ b/tests/test_document_rich_table_tab_history.py @@ -3,16 +3,16 @@ 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 ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_last_cell_tab_uses_the_undoable_table_replacement_path(): - helper = DOC_JS.split("function _appendRichTableRow", 1)[1].split( - "function _insertRichTable", 1 - )[0] + helper = function_body("_appendRichTableRow") key_handler = DOC_JS.split("if (e.key === 'Tab')", 1)[1].split( "let inList = false", 1 )[0] @@ -31,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'); @@ -98,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_text_tools.py b/tests/test_document_rich_text_tools.py index 105b05322..d319efd11 100644 --- a/tests/test_document_rich_text_tools.py +++ b/tests/test_document_rich_text_tools.py @@ -3,10 +3,11 @@ from pathlib import Path from tests.helpers.stylesheets import app_css +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = app_css() @@ -84,9 +85,7 @@ def test_rich_text_commands_sync_and_schedule_save(): def test_rich_text_paste_uses_document_allowlist_and_drops_embedded_media(): - paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split( - "function _wireEmailRichbody", 1 - )[0] + paste_cleaner = function_body("_cleanRichTextPasteHtml") assert "const allowedTags = new Set" in paste_cleaner assert "'TABLE'" in paste_cleaner @@ -154,9 +153,7 @@ def test_table_mutations_are_undoable_and_restore_the_caret(): def test_temporary_table_tokens_are_not_persisted(): - sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split( - "function _richTextContentToHtml", 1 - )[0] + sanitizer = function_body("_sanitizedRichTextHtml") assert "data-editor-(?:table|checklist|image|inline-code|link)-token" in sanitizer assert ".replace(" in sanitizer @@ -176,9 +173,7 @@ def test_rich_text_checklists_support_conversion_and_checked_state(): def test_checklist_interactions_cover_pointer_and_keyboard_users(): - rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split( - "function _emailRichbodyActive", 1 - )[0] + rich_wiring = function_body("_wireEmailRichbody") assert "rich.addEventListener('pointerdown'" in rich_wiring assert "mod && key === 'enter'" in rich_wiring @@ -186,9 +181,7 @@ def test_checklist_interactions_cover_pointer_and_keyboard_users(): def test_checklist_markup_survives_paste_save_and_export(): - paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split( - "function _wireEmailRichbody", 1 - )[0] + paste_cleaner = function_body("_cleanRichTextPasteHtml") assert "keepChecklistClass" in paste_cleaner assert "keepChecklistState" in paste_cleaner @@ -239,9 +232,7 @@ def test_rich_text_image_insertion_and_edits_are_undoable(): def test_rich_text_image_selection_markers_are_not_persisted(): - sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split( - "function _richTextContentToHtml", 1 - )[0] + sanitizer = function_body("_sanitizedRichTextHtml") assert "(?:table|checklist|image|inline-code|link)-token" in sanitizer assert "data-editor-image-selected" in sanitizer @@ -258,9 +249,7 @@ def test_rich_text_image_styles_are_available_in_editor_and_export(): def test_existing_figure_wrapped_images_are_normalized_on_load(): - normalizer = DOC_JS.split("function _normalizeRichTextImages", 1)[1].split( - "function _clearRichImageSelection", 1 - )[0] + normalizer = function_body("_normalizeRichTextImages") assert "figure.richtext-image" in normalizer assert "image.classList.add('richtext-image')" in normalizer @@ -293,9 +282,7 @@ def test_inline_code_supports_selection_toggle_and_future_typing(): def test_inline_code_live_marker_is_saved_as_semantic_code(): - sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split( - "function _richTextContentToHtml", 1 - )[0] + sanitizer = function_body("_sanitizedRichTextHtml") assert "_isRichInlineCodeMarker(span)" in sanitizer assert "document.createElement('code')" in sanitizer @@ -303,9 +290,7 @@ def test_inline_code_live_marker_is_saved_as_semantic_code(): def test_rich_code_shortcuts_and_active_state_are_wired(): - rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split( - "function _emailRichbodyActive", 1 - )[0] + rich_wiring = function_body("_wireEmailRichbody") assert "action = 'codeblock'" in rich_wiring assert "action = 'code'" in rich_wiring @@ -339,12 +324,8 @@ def test_rich_links_validate_protocols_during_paste_save_and_editing(): normalizer = DOC_JS.split("function _normalizeRichLinkUrl", 1)[1].split( "function _promptLink", 1 )[0] - sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split( - "function _richTextContentToHtml", 1 - )[0] - paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split( - "function _wireEmailRichbody", 1 - )[0] + sanitizer = function_body("_sanitizedRichTextHtml") + paste_cleaner = function_body("_cleanRichTextPasteHtml") assert "https?:|mailto:|tel:" in normalizer assert "https:${url}" in normalizer @@ -370,9 +351,7 @@ def test_rich_link_edits_and_removal_use_native_undoable_commands(): def test_link_toolbar_toggles_link_when_selection_is_already_linked(): - rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split( - "function _emailRichbodyActive", 1 - )[0] + rich_wiring = function_body("_wireEmailRichbody") assert "const currentLink = _richLinkAtRange(rich, selectionRange)" in rich_wiring assert "set('[data-md=\"link\"]', !!currentLink)" in rich_wiring diff --git a/tests/test_document_rich_toolbar_menus.py b/tests/test_document_rich_toolbar_menus.py index 178820293..b6837d0c3 100644 --- a/tests/test_document_rich_toolbar_menus.py +++ b/tests/test_document_rich_toolbar_menus.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 +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +DOC_JS = document_source() +STYLE = app_css() def test_rich_toolbar_menus_expose_keyboard_and_context_state(): @@ -58,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'); @@ -101,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, @@ -127,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'); @@ -204,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, @@ -242,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'); @@ -322,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_run_format_switch.py b/tests/test_document_run_format_switch.py index ce1b5b87f..7350b83c7 100644 --- a/tests/test_document_run_format_switch.py +++ b/tests/test_document_run_format_switch.py @@ -1,8 +1,9 @@ from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOCUMENT_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOCUMENT_JS = document_source() def test_format_change_clears_stale_run_output() -> None: diff --git a/tests/test_document_save_shortcut.py b/tests/test_document_save_shortcut.py index 0b1fe4f9b..acf833def 100644 --- a/tests/test_document_save_shortcut.py +++ b/tests/test_document_save_shortcut.py @@ -3,10 +3,12 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_save_shortcut_uses_manual_version_path_and_cancels_autosave(): @@ -38,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'); @@ -71,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_source_test_hygiene.py b/tests/test_document_source_test_hygiene.py new file mode 100644 index 000000000..ab68844ac --- /dev/null +++ b/tests/test_document_source_test_hygiene.py @@ -0,0 +1,200 @@ +"""Tests must reason about the document editor's module set, not one file. + +``static/js/document.js`` is being decomposed. It stays the URL the browser +requests, so a browser test that imports ``/static/js/document.js`` keeps +working. What does not survive is reading the file off disk: a test that greps +the entry file alone silently covers less as soon as the behaviour it names +moves into a module, and it keeps passing while doing so. + +``tests/helpers/document_source`` is the way to read it. This fails on the two +habits that break, both of which existed here before the helper did. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SELF = Path(__file__).name + +# The helper itself names the file, because being the one place that does is +# the point. +ALLOWED = {SELF, "document_source.py"} + +# Every test language the assertions can hide in. A Python-only glob is what +# let the JS references to ``static/style.css`` outlive the file they named. +_SUFFIXES = ("*.py", "*.mjs", "*.js", "*.html") + +# Reading the entry file off disk. Not matched: `/static/js/document.js` as a +# request URL or a dynamic `import()`, which stay correct through the wrapper, +# and which `test_frontend_module_version_parity.py` already pins. +_DIRECT_READ = re.compile( + r'["\']static/js/document\.js["\']' # string literal + r'|"static"\s*/\s*"js"\s*/\s*"document\.js"' # pathlib join +) + +# Slices of the form `src.split(A, 1)[1].split(B, 1)[0]` -- "the region between +# A and B". That region is only what the test means while A and B are +# neighbours in one file. Several also hard-code the entry file's two-space +# indentation, which no extracted module reproduces. +_ADJACENCY_SLICE = re.compile( + r'(?P\b[A-Za-z_]\w*)\.split\(\s*\n?\s*(?P["\'])(?P(?:[^"\'\\]|\\.)+?)(?P=q1)' + r'\s*,\s*1\s*\)\[1\]\s*\n?\s*\.split\(\s*\n?\s*(?P["\'])(?P(?:[^"\'\\]|\\.)+?)(?P=q2)' + r'\s*,\s*1\s*\)\[0\]', + re.S, +) + +# The adjacency slices still in the tree, each spanning a whole family of +# functions rather than one construct -- "everything from _docxHexColor to +# exportAsDocx". Collapsing one to its first member drops what the assertions +# look for, so they cannot be rewritten mechanically: each is converted when +# the family it spans becomes a module, and its entry deleted here then. +# +# This list may only shrink. A new entry means a new adjacency-dependent slice +# was written, which is the habit the helper exists to end. +KNOWN_ADJACENCY_SLICES = { + ('test_document_active_restore.py', + 'for (const doc of activeDocs)', + '_syncDocIndicator'), + ('test_document_edit_reference_js.py', + 'function clearSelection() {', + '\\n }'), + ('test_document_rich_checklist_enter.py', + 'function _handleRichChecklistEnter', + 'let _richInlineCodeTypingArmed'), + ('test_document_rich_docx_export.py', + 'async function exportAsDocx', + '/** Delete the active document'), + ('test_document_rich_docx_export.py', + 'function _docxHexColor', + 'async function exportAsDocx'), + ('test_document_rich_structure_tools.py', + 'function _showMdDropdown', + 'function initMdToolbar'), + ('test_document_rich_table_header_preservation.py', + 'function _applyRichTableAction', + 'function applyMdFormat'), + ('test_document_rich_table_header_preservation.py', + 'function _richTableHeaderModes', + 'function _replaceRichTable'), + ('test_document_rich_table_headers.py', + 'function _applyRichTableAction', + 'function applyMdFormat'), + ('test_document_rich_table_merge_split.py', + 'function _showMdDropdown', + 'function initMdToolbar'), + ('test_document_rich_text_tools.py', + '// ---- Selection-based AI editing ----', + '// ── Inline Suggestion Comments'), + ('test_document_rich_text_tools.py', + '// Undo button in header', + '// Diff toggle button'), + ('test_document_rich_text_tools.py', + '// ── In-document find (Ctrl+F) ──', + '// Delete (or Backspace)'), + ('test_document_rich_text_tools.py', + 'const _richSpacingBlockSelector', + 'function _focusRichTextOffset'), + ('test_document_rich_text_tools.py', + 'function _insertRichTextImages', + 'async function _uploadMarkdownImages'), + ('test_document_rich_text_tools.py', + 'function _normalizeRichLinkUrl', + 'function _promptImageAlt'), + ('test_document_rich_text_tools.py', + 'function _normalizeRichLinkUrl', + 'function _promptLink'), + ('test_document_rich_text_tools.py', + 'function _replaceAllLiteral', + 'function _doFind'), + ('test_document_rich_text_tools.py', + 'function _replaceRichTable', + 'function applyMdFormat'), + ('test_document_rich_text_tools.py', + 'function _richLinkAtRange', + 'function _richSelectionCell'), + ('test_document_rich_text_tools.py', + 'function _richSelectionChecklistItem', + 'function _cleanRichTextPasteHtml'), + ('test_document_rich_text_tools.py', + 'function _richSelectionInlineCode', + 'function _cleanRichTextPasteHtml'), + ('test_document_rich_text_tools.py', + 'function _showMdDropdown', + 'function initMdToolbar'), + ('test_document_rich_text_tools.py', + 'function _wireEmailRichbody', + 'function _richSelectionElement'), + ('test_document_rich_toolbar_menus.py', + 'function _showMdDropdown', + 'function initMdToolbar'), + ('test_document_rich_toolbar_menus.py', + 'function initMdToolbar', + 'function _applyDocFont'), + ('test_document_toolbar_order.py', + 'const _DOCUMENT_TOOLBAR_GROUPS', + 'function _orderDocumentToolbar'), + ('test_review_docx_async_identity.py', + ' let _docxPreviewRequest = 0;', + ' /** Parse CSV'), +} + + +def _test_sources() -> list[Path]: + found: list[Path] = [] + for suffix in _SUFFIXES: + found.extend((ROOT / "tests").rglob(suffix)) + return [p for p in sorted(set(found)) if p.name not in ALLOWED] + + +def test_sources_are_discoverable() -> None: + """Guard the guard: a layout change must not make this vacuous.""" + sources = _test_sources() + assert len(sources) > 100 + suffixes = {p.suffix for p in sources} + assert {".py", ".mjs", ".js"} <= suffixes, suffixes + assert any(p.parent != ROOT / "tests" for p in sources), "walk is not recursive" + + +def test_no_test_reads_the_document_entry_file_off_disk() -> None: + offenders = [ + str(p.relative_to(ROOT)) + for p in _test_sources() + if _DIRECT_READ.search(p.read_text(encoding="utf-8")) + ] + + assert not offenders, ( + "read the document editor through tests.helpers.document_source instead " + "of static/js/document.js, which is becoming a re-export wrapper: " + f"{offenders}" + ) + + +def test_adjacency_slices_only_shrink() -> None: + """No new "region between two declarations" slice enters the tree.""" + found = set() + for path in _test_sources(): + if path.suffix != ".py": + continue + source = path.read_text(encoding="utf-8") + if "tests.helpers.document_source" not in source: + continue + bound = { + m.group(1) + for m in re.finditer(r"(\w+)\s*=\s*document_source\(\)", source) + } + for m in _ADJACENCY_SLICE.finditer(source): + if m.group("var") in bound: + found.add((path.name, m.group("a"), m.group("b"))) + + added = found - KNOWN_ADJACENCY_SLICES + assert not added, ( + "these slices depend on two declarations being neighbours in one file, " + "which decomposition breaks; use function_body()/declaration() or " + "assert against the owning module: " + repr(sorted(added)) + ) + + removed = KNOWN_ADJACENCY_SLICES - found + assert not removed, ( + "these adjacency slices are gone -- delete them from " + f"KNOWN_ADJACENCY_SLICES so the list keeps shrinking: {sorted(removed)}" + ) diff --git a/tests/test_document_stats.py b/tests/test_document_stats.py index 96aae9073..7c8bbe012 100644 --- a/tests/test_document_stats.py +++ b/tests/test_document_stats.py @@ -5,10 +5,11 @@ import subprocess 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 = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = app_css() diff --git a/tests/test_document_suggestion_highlight.py b/tests/test_document_suggestion_highlight.py index 3dd819bda..8fe6912ac 100644 --- a/tests/test_document_suggestion_highlight.py +++ b/tests/test_document_suggestion_highlight.py @@ -1,11 +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 = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") +DOC_JS = document_source() +STYLE_CSS = app_css() def test_suggestion_highlight_measures_exact_referenced_range(): diff --git a/tests/test_document_suggestion_reopen_static.py b/tests/test_document_suggestion_reopen_static.py index 12735d8db..da2265289 100644 --- a/tests/test_document_suggestion_reopen_static.py +++ b/tests/test_document_suggestion_reopen_static.py @@ -1,7 +1,8 @@ from pathlib import Path +from tests.helpers.document_source import document_source -SOURCE = Path("static/js/document.js").read_text() +SOURCE = document_source() def test_newly_mounted_document_pane_cannot_save_before_binding(): diff --git a/tests/test_document_toolbar_arrow_offset.py b/tests/test_document_toolbar_arrow_offset.py index 5a145b620..3df2232ea 100644 --- a/tests/test_document_toolbar_arrow_offset.py +++ b/tests/test_document_toolbar_arrow_offset.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_toolbar_arrows_have_real_flex_slots_outside_icon_scroller(): - script = (ROOT / "static/js/document.js").read_text() - styles = (ROOT / "static/style.css").read_text() + script = document_source() + 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 82e883380..218067fbc 100644 --- a/tests/test_document_toolbar_order.py +++ b/tests/test_document_toolbar_order.py @@ -3,10 +3,12 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_toolbar_groups_define_writing_actions_before_view_controls(): @@ -38,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'); @@ -79,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_ai_reply_context_draft_static.py b/tests/test_email_ai_reply_context_draft_static.py index f15ea1ace..6bb370268 100644 --- a/tests/test_email_ai_reply_context_draft_static.py +++ b/tests/test_email_ai_reply_context_draft_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parent.parent @@ -27,7 +28,7 @@ def test_email_ai_reply_context_only_clears_after_draft_opens(): def test_document_ai_reply_does_not_overwrite_an_edited_draft(): - source = (ROOT / "static/js/document.js").read_text(encoding="utf-8") + source = document_source() start = source.index(" async function _aiReply(") end = source.index(" async function _scheduleSend(", start) function = source[start:end] @@ -46,7 +47,7 @@ def test_document_ai_reply_does_not_overwrite_an_edited_draft(): def test_replacing_an_email_reply_checks_the_visible_draft_first(): - source = (ROOT / "static/js/document.js").read_text(encoding="utf-8") + source = document_source() start = source.index(" export async function replaceEmailReplyBody(") end = source.index(" export async function ensureEmailDraftEnvelope(", start) function = source[start:end] diff --git a/tests/test_email_attachment_search.py b/tests/test_email_attachment_search.py index 02470334b..5237dd577 100644 --- a/tests/test_email_attachment_search.py +++ b/tests/test_email_attachment_search.py @@ -1,5 +1,6 @@ import sqlite3 from email.message import EmailMessage +from tests.helpers.document_source import document_source def test_attachment_filename_is_part_of_ui_index_search(tmp_path, monkeypatch): @@ -80,7 +81,7 @@ def test_remote_search_explicitly_checks_mime_filename_headers(): def test_forwarding_filters_signature_assets_and_mobile_export_stops_bubbling(): inbox = open("static/js/emailInbox.js", encoding="utf-8").read() - document = open("static/js/document.js", encoding="utf-8").read() + document = document_source() assert "const forwardedAttachments = mode === 'forward'" in inbox assert "forwardedAttachments.map" in inbox @@ -99,7 +100,7 @@ def test_attachment_open_spins_icon_only(): def test_move_document_creates_destination_before_adopting_it(): - document = open("static/js/document.js", encoding="utf-8").read() + document = document_source() start = document.index("async function moveActiveDocumentToNewChat()") end = document.index("\n function showDocTabMenu", start) handler = document[start:end] @@ -188,7 +189,7 @@ def test_empty_reply_has_two_editable_rows_and_reply_survives_compact_toolbar(): def test_email_toolbar_places_attachment_before_link(): - document = open("static/js/document.js", encoding="utf-8").read() + document = document_source() toolbar_start = document.index('
', 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")