merge: reconcile reviewed lab baseline for runtime wave 1.1

This commit is contained in:
Alexandre Teixeira
2026-10-01 00:25:42 +01:00
125 changed files with 2267 additions and 352 deletions
+13
View File
@@ -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.
+11 -4
View File
@@ -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 {}
+4 -1
View File
@@ -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 = []
+12 -7
View File
@@ -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
@@ -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);
+5
View File
@@ -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)} — <a href="/api/mcp/oauth/authorize/${data.id}" target="_blank" style="color:var(--red);font-weight:600;">Authorize with Google</a> to connect`;
msg.className = 'admin-success';
+11 -2
View File
@@ -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; }
-18
View File
@@ -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); }
+133
View File
@@ -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/<absolute path>` 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-<user>/pytest-<n>/` 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.
+2
View File
@@ -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 `--`:
+40
View File
@@ -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,
)
+288
View File
@@ -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}")
+31
View File
@@ -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
+46
View File
@@ -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-<user>/pytest-<n>/`` 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)
@@ -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
+2 -1
View File
@@ -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"></span>' in src
+2 -3
View File
@@ -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]*\{"
+19 -14
View File
@@ -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 = []
@@ -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():
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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")
+2 -1
View File
@@ -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")
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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
+4 -1
View File
@@ -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")
+5 -4
View File
@@ -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
+4 -3
View File
@@ -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
+5 -4
View File
@@ -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 '<span class="compare-check-label">Probe</span>' 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 '<span class="compare-score-label">Score</span>' in vote
assert ".compare-score-label" in css
+5 -6
View File
@@ -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
+166
View File
@@ -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() == []
+2 -1
View File
@@ -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
+3 -1
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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],
+4 -9
View File
@@ -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
+2 -2
View File
@@ -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"
+3 -2
View File
@@ -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-<id> 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
@@ -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);"
+4 -2
View File
@@ -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")
+4 -3
View File
@@ -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():
+5 -2
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+5 -2
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+166
View File
@@ -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('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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
+8 -6
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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"
+4 -3
View File
@@ -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():
+5 -2
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
@@ -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(`<style>
:root { --fg:#d8dee9; --bg:#17191f; --panel:#20232b; --border:#444; --red:#e45b6c; --accent-primary:#e45b6c; }
</style><link rel="stylesheet" href="/static/style.css?rich-color-test=1">
</style>__ODY_STYLESHEETS__
<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>`);
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,
+7 -3
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+6 -5
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+7 -5
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+7 -8
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+5 -2
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+7 -4
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+3 -1
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+10 -13
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+7 -8
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+13 -34
View File
@@ -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
+11 -6
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+2 -1
View File
@@ -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:
+5 -2
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+3 -1
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+200
View File
@@ -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<var>\b[A-Za-z_]\w*)\.split\(\s*\n?\s*(?P<q1>["\'])(?P<a>(?:[^"\'\\]|\\.)+?)(?P=q1)'
r'\s*,\s*1\s*\)\[1\]\s*\n?\s*\.split\(\s*\n?\s*(?P<q2>["\'])(?P<b>(?:[^"\'\\]|\\.)+?)(?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)}"
)
+2 -1
View File
@@ -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()
+4 -3
View File
@@ -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():
@@ -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():
+4 -2
View File
@@ -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"')
+5 -2
View File
@@ -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('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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():
+2 -1
View File
@@ -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():
@@ -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]
+4 -3
View File
@@ -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('<div class="md-toolbar-items"')
toolbar_end = document.index('</div>', toolbar_start)
toolbar = document[toolbar_start:toolbar_end]
+2 -1
View File
@@ -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]
+2 -2
View File
@@ -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
+3 -2
View File
@@ -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
+2 -3
View File
@@ -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():
+2 -1
View File
@@ -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:
+2 -1
View File
@@ -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]
+2 -1
View File
@@ -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")
+4 -1
View File
@@ -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('<link rel="stylesheet" href="/static/style.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
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,
+4 -1
View File
@@ -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
+102
View File
@@ -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}"
)
+2 -1
View File
@@ -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
+89 -2
View File
@@ -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"
@@ -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()
+2 -1
View File
@@ -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]
+2 -1
View File
@@ -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)
@@ -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"
+2 -1
View File
@@ -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)
+3 -2
View File
@@ -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]
+2 -1
View File
@@ -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")
+2 -1
View File
@@ -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
+6 -5
View File
@@ -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 '<span class="list-item-plus-label">new</span>' 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("}")]
@@ -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
+5 -2
View File
@@ -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)

Some files were not shown because too many files have changed in this diff Show More