Merge lab into test/document-module-set-helper

Resolve the overlap with #19 by preserving the whole-cascade stylesheet
helpers alongside #31's document-source/module-set helpers.

Maintainer validation:
- document/module contract and composition guards: 15 passed
- all 54 touched Python test modules: 366 passed
- py_compile: clean
- git diff --check: clean
This commit is contained in:
Alexandre Teixeira
2026-09-30 22:56:56 +01:00
97 changed files with 1372 additions and 204 deletions
+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
@@ -33,6 +33,8 @@ the sub-area. The `area_*` names are registered in `pyproject.toml`; the dynamic
`sub_*` names are registered before collection by `pytest_configure` in
`tests/conftest.py`, so unknown-mark warnings still flag genuine typos.
The full suite does not come back clean on every machine. [KNOWN_FAILURES.md](KNOWN_FAILURES.md) lists which failures are expected, which are test bugs worth fixing, and the prerequisites a clean run needs; anything not on that list is a regression until shown otherwise.
For common focused runs, use `tests/run_focus.py`. It validates area and
sub-area names, accepts sub-areas with or without the `sub_` prefix, and passes
extra pytest arguments after `--`:
+40
View File
@@ -159,3 +159,43 @@ def _serve_test_static():
os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = previous_origin
server.shutdown()
server.server_close()
@pytest.fixture(autouse=True)
def _no_leaked_module_stubs():
"""Fail the test that leaves a bare ``src.*``/``core.*`` stub behind.
Several test modules install empty stand-in modules so an import-heavy
production module can be loaded under the mocks above. When one of those
writes is not undone, the stub stays in ``sys.modules`` for the rest of the
session and every later test that imports the real module silently gets an
empty one instead. The suite still passes as a whole, because the victims
usually run before the leak; it only breaks under a different collection
order, which is why this class of bug reaches CI green.
This fixture is declared in the root conftest, so it is set up before any
test-module fixture and torn down after all of them — a stub that a test's
own teardown removes is not reported. The leaked entries are dropped here
as well as reported, so the failure stays attributed to the test that
introduced it instead of cascading into the rest of the run.
Bare stubs present before the test starts are ignored: this guards against
new leaks, it does not police import state the session began with.
"""
from tests.helpers.import_state import bare_module_stubs, clear_module
before = bare_module_stubs()
yield
leaked = sorted(bare_module_stubs() - before)
if not leaked:
return
for name in leaked:
clear_module(name)
pytest.fail(
"test left bare module stub(s) in sys.modules: "
+ ", ".join(leaked)
+ ". Register the stub through monkeypatch.setitem(sys.modules, ...) "
"or tests.helpers.import_state.preserve_import_state so it is undone "
"at teardown.",
pytrace=False,
)
+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)
+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],
+2 -1
View File
@@ -1,13 +1,14 @@
"""Regression guards for document-selection references in chat bubbles."""
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
DOCUMENT = document_source()
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
APP = (ROOT / "static/app.js").read_text(encoding="utf-8")
+2 -2
View File
@@ -9,12 +9,12 @@ document.js is browser-coupled and not importable in pytest.
"""
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE_CSS = app_css()
def test_document_textarea_scrollbar_is_visible():
+3 -1
View File
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -25,7 +26,7 @@ def test_mobile_rich_text_history_state_and_document_switch():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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;
@@ -86,6 +87,7 @@ def test_mobile_rich_text_history_state_and_document_switch():
console.log(JSON.stringify({ initial, typed, undone, redone, switched, overflow }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+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,
+6 -5
View File
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
def _run_node(script: str):
@@ -42,7 +43,7 @@ def test_markdown_outline_parses_structure_and_ignores_fenced_code():
'#### Final `code` section',
].join('\n');
console.log(JSON.stringify(parseMarkdownOutline(source)));
"""
""".replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
)
assert [(entry["level"], entry["text"]) for entry in data] == [
(1, "Overview"),
@@ -72,7 +73,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<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');
@@ -127,7 +128,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile():
console.log(JSON.stringify({ markdownLabels, selected, liveLabels, mobileBox, richLabels, richCaretHeading }));
await browser.close();
"""
""".replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
)
assert data["markdownLabels"] == ["Intro", "Details", "End"]
assert data["selected"] == "## Details"
+2 -2
View File
@@ -1,12 +1,12 @@
"""Regression guards for the Markdown preview hover-to-edit control."""
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE_CSS = app_css()
def test_preview_installs_hover_edit_button():
+3 -1
View File
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -27,7 +28,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<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');
@@ -79,6 +80,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly():
console.log(JSON.stringify({ afterFirstEnter, ...data }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
def test_color_controls_have_theme_reset_and_split_palettes():
@@ -21,6 +22,12 @@ def test_color_controls_have_theme_reset_and_split_palettes():
def test_rich_colors_follow_theme_and_undo_as_one_edit():
# Two input conventions in here are platform-sensitive and must stay that
# way. Palette entries are opened with a plain click: on macOS a
# Control+click is delivered as `contextmenu`, so the menu item's `click`
# handler never runs and nothing is applied. Undo uses Playwright's
# `ControlOrMeta` alias because the editor's undo accelerator is Cmd+Z on
# macOS and Ctrl+Z everywhere else.
script = r"""
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
@@ -28,7 +35,7 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit():
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent(`<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());
@@ -59,28 +66,29 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit():
labels: [...document.querySelectorAll('.rich-color-palette-label')].map(item => item.textContent),
reset: document.querySelector('.rich-color-reset')?.textContent.trim(),
}));
await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Lemon' }).click({ modifiers: ['Control'] });
await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Lemon' }).click();
const highlighted = await page.locator('#doc-email-richbody p').nth(0).locator('span').evaluate(span => ({
color: getComputedStyle(span).color,
background: getComputedStyle(span).backgroundColor,
}));
await page.locator('#doc-email-richbody').press('Control+z');
await page.locator('#doc-email-richbody').press('ControlOrMeta+z');
const highlightUndone = await page.locator('#doc-email-richbody p').nth(0).innerHTML();
await selectParagraph(1);
await openMenu('color');
await page.locator('.rich-color-reset').click({ modifiers: ['Control'] });
await page.locator('.rich-color-reset').click();
const defaultColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => ({
style: span.getAttribute('style'),
color: getComputedStyle(span).color,
}));
await page.evaluate(() => document.documentElement.style.setProperty('--fg', '#88cc44'));
const changedThemeColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => getComputedStyle(span).color);
await page.locator('#doc-email-richbody').press('Control+z');
await page.locator('#doc-email-richbody').press('ControlOrMeta+z');
const colorUndone = await page.locator('#doc-email-richbody p').nth(1).innerHTML();
console.log(JSON.stringify({ palette, highlighted, highlightUndone, defaultColor, changedThemeColor, colorUndone }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+5 -2
View File
@@ -5,6 +5,7 @@ import subprocess
import tempfile
import zipfile
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -65,7 +66,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml():
contentType: 'image/png',
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'),
}}));
await page.setContent('<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');
@@ -90,6 +91,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml():
}}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -159,7 +161,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting():
acceptDownloads: true,
}});
await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -181,6 +183,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting():
console.log(JSON.stringify({{ failure: await download.failure() }}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+3 -1
View File
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -24,7 +25,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<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');
@@ -72,6 +73,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac
console.log(JSON.stringify({ crossParagraph, crossBreak, crossInline, ...data }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -6,6 +6,7 @@ import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -95,7 +96,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile():
async function exercise(viewport, suffix) {
const page = await browser.newPage({ viewport });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -191,6 +192,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile():
console.log(JSON.stringify({ desktop, mobile }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+4 -1
View File
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -28,7 +29,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -72,6 +73,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable():
console.log(JSON.stringify({ entered, undone, redone }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -151,6 +153,7 @@ def test_heading_enter_preserves_shift_middle_and_empty_heading_semantics():
console.log(JSON.stringify(state));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+3 -1
View File
@@ -5,6 +5,7 @@ import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -33,7 +34,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -113,6 +114,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal():
console.log(JSON.stringify({ added, undone, redone, resized, removed, removalUndone, menuRect, overflow }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+3 -1
View File
@@ -5,6 +5,7 @@ import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -29,7 +30,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<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');
@@ -102,6 +103,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text():
console.log(JSON.stringify(data));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -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,12 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
def test_selection_toolbar_uses_shared_formatting_path_and_preserves_range():
@@ -27,7 +28,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<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');
@@ -103,6 +104,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports
console.log(JSON.stringify({ activeBold, desktopGeometry, formattedHtml, selectedAfterFormat, dismissed, mobileGeometry }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+5 -3
View File
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
def test_slash_menu_reuses_rich_text_actions_and_is_accessible():
@@ -42,7 +43,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<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');
@@ -150,6 +151,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
console.log(JSON.stringify({ filtered, heading, noHeadingQuery, tableFiltered, table, noTableQuery, mobile, homeOptions, homeLabel, escaped, slashRemains, cleanedAria }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+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,
+5 -3
View File
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import declaration, document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
def test_heading_levels_and_page_break_are_exposed_everywhere():
@@ -39,7 +40,7 @@ def test_mobile_headings_page_break_history_and_persistence():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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;
@@ -147,6 +148,7 @@ def test_mobile_headings_page_break_history_and_persistence():
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -28,7 +29,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -119,6 +120,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history():
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -35,7 +36,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -118,6 +119,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history():
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+3 -1
View File
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -37,7 +38,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -114,6 +115,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo():
console.log(JSON.stringify({ initial, menuRect, columnOn, undone, redone, bothOn, rowOff, overflow }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -33,7 +34,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history(
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -137,6 +138,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history(
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
@@ -30,7 +31,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -97,6 +98,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history():
console.log(JSON.stringify({ forward, backward, appended, undone, redone, overflow }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+9 -5
View File
@@ -3,12 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
def test_rich_toolbar_menus_expose_keyboard_and_context_state():
@@ -59,7 +60,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -102,6 +103,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens():
console.log(JSON.stringify({ opened, closed, sliderOpened, sliderClosed, pickerOpened, colorClosed, before, scroll }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -128,7 +130,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -205,6 +207,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus():
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -243,7 +246,7 @@ def test_rich_toolbar_menus_track_live_formatting_values():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -323,6 +326,7 @@ def test_rich_toolbar_menus_track_live_formatting_values():
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+3 -1
View File
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -39,7 +40,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status():
});
});
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -72,6 +73,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status():
console.log(JSON.stringify({ dirty, saved, badge, requests }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+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,
+2 -2
View File
@@ -1,12 +1,12 @@
"""Regression guards for exact inline document-suggestion highlighting."""
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE_CSS = app_css()
def test_suggestion_highlight_measures_exact_referenced_range():
+2 -1
View File
@@ -1,4 +1,5 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
@@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_toolbar_arrows_have_real_flex_slots_outside_icon_scroller():
script = document_source()
styles = (ROOT / "static/style.css").read_text()
styles = app_css()
leading = script.index('class="md-toolbar-leading-controls"')
left_arrow = script.index('id="md-scroll-left"')
+3 -1
View File
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
@@ -39,7 +40,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
async function inspect(viewport, suffix) {
const page = await browser.newPage({ viewport });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<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');
@@ -80,6 +81,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
console.log(JSON.stringify({ desktop, mobile }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+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():
+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 -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
@@ -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,
+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()
@@ -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")
+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("}")]
+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)
+178
View File
@@ -0,0 +1,178 @@
"""Every procfs pid scan in the app tree must be guarded by an existence check.
This is the third instance of the same defect: code walks ``/proc`` on a host
that has no procfs, and the resulting ``FileNotFoundError`` breaks a path that
had otherwise succeeded. Two of the three were found by reading source, so
this pins the class rather than the instances.
Deliberate AST assertion under the narrow exception in
``tests/TESTING_STANDARD.md``: the invariant is "no *other* module grows an
unguarded scan", which cannot be driven at runtime without importing and
exercising every procfs-touching code path on both a Linux and a non-Linux
host. ``test_cookbook_stop_without_procfs.py`` covers the behaviour itself.
"""
import ast
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# Trees that ship as the application. Tests and vendored code are excluded.
APP_TREES = (
"app.py",
"core",
"routes",
"src",
"services",
"scripts",
"mcp_servers",
"integrations",
"companion",
)
# Calls that enumerate a directory's entries. Reading one known file under
# /proc is a different shape — it fails per-file and callers already handle
# that — so only the enumerating calls are in scope here.
_SCAN_FUNCS = {"listdir", "scandir"}
_SCAN_METHODS = {"iterdir", "glob", "rglob"}
# Calls that prove the scan is conditional on procfs being present.
_GUARD_FUNCS = {"has_procfs", "isdir", "is_dir", "exists"}
PROCFS_ROOT = "/proc"
def _is_procfs_root(node: ast.AST) -> bool:
"""True if ``node`` evaluates to the procfs root directory."""
if isinstance(node, ast.Constant) and node.value == PROCFS_ROOT:
return True
# Path("/proc")
if isinstance(node, ast.Call):
return any(_is_procfs_root(arg) for arg in node.args)
# PROC_ROOT / _PROC_ROOT / proc_root / platform_compat.PROC_ROOT
name = None
if isinstance(node, ast.Name):
name = node.id
elif isinstance(node, ast.Attribute):
name = node.attr
return bool(name) and name.lower().lstrip("_") == "proc_root"
def _scan_sites(tree: ast.AST) -> list[ast.Call]:
sites = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Attribute) and func.attr in _SCAN_FUNCS:
if node.args and _is_procfs_root(node.args[0]):
sites.append(node)
elif isinstance(func, ast.Name) and func.id in _SCAN_FUNCS:
if node.args and _is_procfs_root(node.args[0]):
sites.append(node)
elif isinstance(func, ast.Attribute) and func.attr in _SCAN_METHODS:
if _is_procfs_root(func.value):
sites.append(node)
return sites
def _guard_lines(tree: ast.AST) -> list[int]:
"""Line numbers of calls that test whether procfs is present."""
lines = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
if name not in _GUARD_FUNCS:
continue
subject_is_procfs = (
name in {"has_procfs", "is_wsl"}
or (isinstance(func, ast.Attribute) and _is_procfs_root(func.value))
or any(_is_procfs_root(arg) for arg in node.args)
)
if subject_is_procfs:
lines.append(node.lineno)
return lines
def _enclosing_scope(tree: ast.AST, node: ast.AST) -> ast.AST:
"""Smallest function/module scope containing ``node``."""
best = tree
for candidate in ast.walk(tree):
if not isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
end = getattr(candidate, "end_lineno", None) or candidate.lineno
if candidate.lineno <= node.lineno <= end:
if best is tree or candidate.lineno > best.lineno:
best = candidate
return best
def _app_python_files() -> list[Path]:
files = []
for entry in APP_TREES:
target = REPO_ROOT / entry
if target.is_file():
files.append(target)
elif target.is_dir():
files.extend(
p for p in target.rglob("*.py") if "__pycache__" not in p.parts
)
return sorted(files)
def _collect_sites() -> tuple[list[str], list[str]]:
"""Return (guarded, unguarded) ``path:line`` labels for procfs scans."""
guarded, unguarded = [], []
for path in _app_python_files():
try:
source = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
# Cheap pre-filter: a scan has to name the root somehow.
if PROCFS_ROOT not in source and "proc_root" not in source.lower():
continue
try:
tree = ast.parse(source)
except SyntaxError:
continue
sites = _scan_sites(tree)
if not sites:
continue
guards = _guard_lines(tree)
for site in sites:
scope = _enclosing_scope(tree, site)
start = getattr(scope, "lineno", 0)
label = f"{path.relative_to(REPO_ROOT)}:{site.lineno}"
if any(start <= g < site.lineno for g in guards):
guarded.append(label)
else:
unguarded.append(label)
return guarded, unguarded
def test_every_procfs_scan_is_guarded_by_an_existence_check():
_guarded, unguarded = _collect_sites()
assert not unguarded, (
"procfs pid scans with no existence check in the enclosing function — "
"these raise FileNotFoundError on macOS and Windows: "
+ ", ".join(unguarded)
)
def test_the_guard_detector_still_sees_the_known_scans():
"""A rename must not silently turn the assertion above into a no-op.
Lower bound, not an exact count: a new *guarded* scan is fine and should
not fail this. What must not happen is the detector going blind, which
shows up as sites disappearing.
"""
guarded, unguarded = _collect_sites()
found = set(guarded) | set(unguarded)
files = {label.rsplit(":", 1)[0] for label in found}
known = {"src/agent_tools/web_tools.py", "src/tools/cookbook.py"}
assert known <= files, (
"the detector no longer sees a known procfs scan — check whether the "
f"root was renamed. Found: {sorted(found)}"
)
assert len(found) >= 3, f"expected at least 3 procfs scans, found {sorted(found)}"
+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_research_settings_use_custom_pickers_and_shared_provider_icons():
panel = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8")
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
style = app_css()
settings = (ROOT / "static/js/settings.js").read_text(encoding="utf-8")
icons = (ROOT / "static/js/searchProviderIcons.js").read_text(encoding="utf-8")
+2 -1
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from types import SimpleNamespace
from routes.history.history_routes import _keep_count_before_message
from tests.helpers.stylesheets import app_css
_REPO = Path(__file__).resolve().parent.parent
@@ -76,7 +77,7 @@ def test_footer_resend_uses_default_replacement_behavior():
def test_footer_resend_uses_round_svg_icon_not_text_glyph():
renderer = _CHAT_RENDERER_JS.read_text(encoding="utf-8")
style = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
style = app_css()
assert "const RESEND_ICON =" in renderer
assert "resend-message-icon" in renderer
+12 -3
View File
@@ -21,12 +21,21 @@ def _test_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _stub_heavy():
def _stub_heavy(monkeypatch):
"""Stub the heavy modules ``task_scheduler`` imports, for this test only.
Registered through ``monkeypatch.setitem`` so every entry is removed at
teardown. A bare ``sys.modules[name] = ...`` leaves an empty module behind
for the rest of the session, and any later test that imports the real one
silently gets the stub instead - a failure that only shows up under a
different collection order.
"""
for name in [
"src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver",
"src.agent_loop", "src.session_manager",
]:
sys.modules.setdefault(name, types.ModuleType(name))
if name not in sys.modules:
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
def _setup_isolated_db():
@@ -74,7 +83,7 @@ def test_scheduler_utcnow_preserves_naive_utc_contract():
def _drive_scheduler(monkeypatch, pre_start_setup=None):
"""Build a TaskScheduler bypassing __init__ and run start() + two polls."""
_stub_heavy()
_stub_heavy(monkeypatch)
cd, ScheduledTask, TaskRun = _setup_isolated_db()
from src.task_scheduler import TaskScheduler
+3 -4
View File
@@ -1,11 +1,10 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
STYLE_CSS = Path(__file__).resolve().parents[1] / "static" / "style.css"
STYLE_CSS_TEXT = app_css()
def _style_text() -> str:
return STYLE_CSS.read_text(encoding="utf-8")
return STYLE_CSS_TEXT
def test_native_select_options_use_theme_tokens():
+2 -1
View File
@@ -1,4 +1,5 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
@@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1]
def test_selection_overlays_have_individual_clear_controls():
js = document_source()
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
css = app_css()
assert "function clearSelectionAt(index)" in js
assert "className = 'doc-selection-overlay-clear'" in js
+3 -4
View File
@@ -14,6 +14,7 @@ import subprocess
from pathlib import Path
import pytest
from tests.helpers.stylesheets import app_css
_REPO = Path(__file__).resolve().parent.parent
@@ -22,11 +23,9 @@ _COORDINATOR_HELPER = (
_REPO / "tests" / "helpers" / "test_settings_shell_coordinator.mjs"
)
_HAS_NODE = shutil.which("node") is not None
_STYLE = _REPO / "static" / "style.css"
_STYLE_TEXT = app_css()
def test_settings_desktop_width_targets_settings_not_cookbook():
source = _STYLE.read_text(encoding="utf-8")
source = _STYLE_TEXT
settings_rule = re.search(
r"(?ms)^\.settings-modal-content\s*\{[^}]*"
+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()
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
CHIP_SCROLL = (ROOT / "static/js/chipScroll.js").read_text(encoding="utf-8")
+6 -11
View File
@@ -5,7 +5,6 @@ import importlib
import importlib.util
import json
import os
import socket
import sys
from pathlib import Path
from types import SimpleNamespace
@@ -28,6 +27,7 @@ from routes.shell_routes import (
_venv_activate_prefix,
DOCKER_IN_CONTAINER_HINT,
)
from tests.helpers.unix_sockets import bound_unix_socket
def test_shell_routes_import_without_posix_pty_modules(monkeypatch):
@@ -294,30 +294,25 @@ class TestHostDockerAccess:
def test_socket_without_explicit_opt_in_is_disabled(
self,
monkeypatch,
tmp_path,
flag,
):
socket_path = tmp_path / "docker.sock"
with socket.socket(socket.AF_UNIX) as unix_socket:
unix_socket.bind(str(socket_path))
# Not tmp_path: binding under $TMPDIR overruns sun_path on macOS.
with bound_unix_socket() as socket_path:
if flag is None:
monkeypatch.delenv("ODYSSEUS_ENABLE_HOST_DOCKER", raising=False)
else:
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", flag)
assert _host_docker_access_enabled(str(socket_path)) is False
assert _host_docker_access_enabled(socket_path) is False
def test_explicit_opt_in_with_unix_socket_is_enabled(
self,
monkeypatch,
tmp_path,
):
socket_path = tmp_path / "docker.sock"
with socket.socket(socket.AF_UNIX) as unix_socket:
unix_socket.bind(str(socket_path))
with bound_unix_socket() as socket_path:
monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true")
assert _host_docker_access_enabled(str(socket_path)) is True
assert _host_docker_access_enabled(socket_path) is True
class TestPackageProbeStatus:
@@ -1,11 +1,12 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parent.parent
def test_sidebar_chat_list_uses_content_height_when_collapsed():
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
css = app_css()
desktop = css.split("#sessions-section #session-list {", 1)[1].split("}", 1)[0]
mobile = css.split("#sessions-section #session-list {", 2)[2].split("}", 1)[0]
+6 -5
View File
@@ -1,5 +1,6 @@
from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -102,7 +103,7 @@ def test_markdown_flattens_legacy_notes_more_details():
def test_markdown_expands_note_and_skill_more_links_without_details():
markdown_src = (ROOT / "static/js/markdown.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
style_src = app_css()
assert "function extractMoreListPayloads" in markdown_src
assert "ody-more-(notes|skills|memories|events|sessions)" in markdown_src
@@ -119,7 +120,7 @@ def test_markdown_expands_note_and_skill_more_links_without_details():
def test_terminal_skill_listing_uses_clickable_bounded_formatter():
agent_src = (ROOT / "src/agent_loop.py").read_text()
style_src = (ROOT / "static/style.css").read_text()
style_src = app_css()
assert "_qwen_skills_terminal_summary = _skills_list_summary_from_tool_output(" in agent_src
assert '.msg-ai .body a[href^="#skill-"]' in style_src
@@ -127,7 +128,7 @@ def test_terminal_skill_listing_uses_clickable_bounded_formatter():
def test_list_links_share_compact_typography():
style_src = (ROOT / "static/style.css").read_text()
style_src = app_css()
assert 'a.chat-link[href^="#events-more-"]' in style_src
assert "font-size: 11px;" in style_src
@@ -145,7 +146,7 @@ def test_open_skills_also_persists_skill_list():
def test_memory_deeplink_scrolls_and_flashes_even_when_filtered():
memory_src = (ROOT / "static/js/memory.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
style_src = app_css()
assert "function _resetMemoryDeepLinkFilters()" in memory_src
assert "activeCategory = 'all';" in memory_src
@@ -158,7 +159,7 @@ def test_memory_deeplink_scrolls_and_flashes_even_when_filtered():
def test_skill_and_memory_links_use_polished_chat_link_style():
style_src = (ROOT / "static/style.css").read_text()
style_src = app_css()
assert '.msg-ai .body a[href^="#skill-"]' in style_src
assert '.msg-ai .body a[href^="#memory-"]' in style_src
@@ -1,11 +1,12 @@
from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
UI = (ROOT / "static/js/ui.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
def test_terminal_and_canonical_renders_preserve_chat_scroll_anchor():
@@ -0,0 +1,52 @@
"""No two @keyframes may share a name across the app's stylesheets.
Duplicate names resolve last-wins across the whole cascade, so an earlier
definition is dead code that still looks live at its call site. Two of them
existed and differed from the definition that actually won, which also blocked
moving either animation during the stylesheet decomposition: relocating one
changes which is last and therefore changes rendered behaviour.
"""
import collections
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STATIC = ROOT / "static"
def _stylesheets() -> list[Path]:
"""App-owned stylesheets, in no particular order. Vendored CSS is excluded."""
sheets = [STATIC / "style.css"]
sheets.extend(sorted((STATIC / "css").glob("*.css")))
return [s for s in sheets if s.exists()]
def _keyframe_names(text: str) -> list[str]:
without_comments = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
return re.findall(
r"@(?:-webkit-)?keyframes\s+([A-Za-z0-9_-]+)\s*\{", without_comments
)
def test_app_stylesheets_exist() -> None:
"""Guard the guard: a rename must not turn this file into a no-op."""
assert _stylesheets(), "no app stylesheets found to check"
def test_no_keyframes_name_is_declared_twice() -> None:
counts: collections.Counter[str] = collections.Counter()
where: dict[str, list[str]] = collections.defaultdict(list)
for sheet in _stylesheets():
for name in _keyframe_names(sheet.read_text(encoding="utf-8")):
counts[name] += 1
where[name].append(sheet.relative_to(ROOT).as_posix())
duplicates = {name: where[name] for name, n in counts.items() if n > 1}
assert duplicates == {}, (
"duplicate @keyframes names resolve last-wins, so every definition but "
f"the last is dead: {duplicates}"
)
+54
View File
@@ -0,0 +1,54 @@
"""Tests must reason about the whole cascade, not one file of it.
``static/style.css`` is being decomposed. A test that reads that file alone,
or builds a synthetic page linking only that file, silently loses every rule
that has moved: it keeps passing while covering less. Both mistakes existed
and are cheap to detect, so this fails on either.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SELF = Path(__file__).name
# The helper module and the manifest/snapshot tests are about the stylesheet
# set itself, so naming the file is the point rather than a mistake.
ALLOWED = {SELF, "test_static_stylesheet_manifest.py", "test_css_computed_style_snapshot.py"}
_DIRECT_READ = re.compile(r'["\']static/style\.css["\']|"static"\s*/\s*"style\.css"')
_LONE_LINK = re.compile(r'<link[^>]*href="/static/style\.css')
def _test_sources():
return [p for p in sorted((ROOT / "tests").glob("*.py")) if p.name not in ALLOWED]
def test_sources_are_discoverable() -> None:
"""Guard the guard: a layout change must not make this vacuous."""
assert len(_test_sources()) > 100
def test_no_test_reads_style_css_as_the_whole_cascade() -> None:
offenders = [
p.name for p in _test_sources()
if _DIRECT_READ.search(p.read_text(encoding="utf-8"))
]
assert offenders == [], (
"read the cascade with tests.helpers.stylesheets.app_css() instead of "
f"static/style.css alone: {offenders}"
)
def test_no_synthetic_page_links_style_css_alone() -> None:
offenders = [
p.name for p in _test_sources()
if _LONE_LINK.search(p.read_text(encoding="utf-8"))
]
assert offenders == [], (
"build synthetic pages with tests.helpers.stylesheets.stylesheet_link_tags() "
f"so they get every stylesheet index.html loads: {offenders}"
)
+69
View File
@@ -0,0 +1,69 @@
"""A successful Tailscale query with no eligible hosts is still cached knowledge.
`discover_tailscale_hosts` gated its cache on the host list being non-empty, so a
valid "nothing to see here" answer looked identical to a cold cache and every
caller paid for another `tailscale status --json` (up to a 5s timeout). Failures
stay uncached so a peer coming online is still picked up promptly.
"""
import pytest
from src import model_discovery
class _Result:
def __init__(self, returncode, stdout):
self.returncode = returncode
self.stdout = stdout
@pytest.fixture
def tailscale(monkeypatch):
"""Count `tailscale status` invocations and start from a cold cache."""
calls = []
def _record(result):
def _run(*_args, **_kwargs):
calls.append(1)
if isinstance(result, Exception):
raise result
return result
monkeypatch.setattr(model_discovery.subprocess, "run", _run)
return calls
monkeypatch.setattr(model_discovery, "_hosts_cache", [])
monkeypatch.setattr(model_discovery, "_hosts_cache_time", 0)
return _record
def test_empty_but_successful_discovery_is_only_run_once(tailscale):
calls = tailscale(_Result(0, '{"Self":{},"Peer":{}}'))
assert model_discovery.discover_tailscale_hosts() == []
assert model_discovery.discover_tailscale_hosts() == []
assert len(calls) == 1
def test_nonempty_discovery_is_still_cached(tailscale):
calls = tailscale(_Result(0, '{"Self":{"TailscaleIPs":["100.1.1.1"]},"Peer":{}}'))
assert model_discovery.discover_tailscale_hosts() == ["100.1.1.1"]
assert model_discovery.discover_tailscale_hosts() == ["100.1.1.1"]
assert len(calls) == 1
@pytest.mark.parametrize(
"result",
[
_Result(1, ""), # tailscale installed but logged out
_Result(0, "not json"), # unparseable output
FileNotFoundError("tailscale"), # not installed
],
ids=["nonzero_exit", "bad_json", "not_installed"],
)
def test_failures_stay_retryable(tailscale, result):
calls = tailscale(result)
assert model_discovery.discover_tailscale_hosts() == []
assert model_discovery.discover_tailscale_hosts() == []
assert len(calls) == 2
+3 -4
View File
@@ -1,11 +1,10 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
STYLE = Path(__file__).resolve().parents[1] / "static" / "style.css"
STYLE_TEXT = app_css()
def test_mobile_sidebar_uses_the_sidebar_theme_surface():
css = STYLE.read_text()
css = STYLE_TEXT
mobile_drawer = css[css.index("/* Sidebar overlays chat on mobile */"):css.index("/* Backdrop behind sidebar */")]
assert "background: var(--sidebar-bg, var(--panel)) !important;" in mobile_drawer
assert "background: var(--panel) !important;" not in mobile_drawer
+3 -2
View File
@@ -1,6 +1,7 @@
"""Regression coverage for transient toast polish and accurate welcome tips."""
from pathlib import Path
from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parent.parent
@@ -9,7 +10,7 @@ ROOT = Path(__file__).resolve().parent.parent
def test_notification_history_does_not_clutter_navigation_or_capture_toasts():
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
ui = (ROOT / "static/js/ui.js").read_text(encoding="utf-8")
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
css = app_css()
assert 'id="rail-notifications"' not in html
assert 'id="sidebar-notifications-btn"' not in html
@@ -32,7 +33,7 @@ def test_action_hint_is_part_of_action_button_and_close_is_grouped_beside_it():
def test_welcome_tips_are_plain_and_brief():
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
css = app_css()
assert "Tip: Ctrl+K searches chats." in html
assert "Tip: Ctrl+Alt+B toggles the sidebar." in html
+3 -2
View File
@@ -1,5 +1,6 @@
from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
def test_tool_approval_bypasses_polymorphic_send_button_actions():
@@ -22,7 +23,7 @@ def test_tool_approval_bypasses_polymorphic_send_button_actions():
def test_ask_user_card_has_no_close_button_and_chat_scale_text():
root = Path(__file__).resolve().parents[1]
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
styles = (root / "static/style.css").read_text(encoding="utf-8")
styles = app_css()
assert "closeBtn.className = 'modal-close ask-user-close';" not in renderer
assert "closeBtn.setAttribute('aria-label', 'Dismiss question');" not in renderer
@@ -64,7 +65,7 @@ def test_ask_user_card_has_no_close_button_and_chat_scale_text():
def test_scroll_bottom_button_uses_dropdown_caret_glyph():
root = Path(__file__).resolve().parents[1]
html = (root / "static/index.html").read_text(encoding="utf-8")
styles = (root / "static/style.css").read_text(encoding="utf-8")
styles = app_css()
assert 'class="scroll-nav-caret"' in html
assert "&#9662;" in html
+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_tool_header_has_semantic_icons_for_live_and_saved_calls():
chat = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
renderer = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
css = app_css()
assert "manage_calendar" in renderer
assert "manage_memory" in renderer
+2 -1
View File
@@ -5,6 +5,7 @@ import pytest
from src.deep_research import CATEGORY_PROMPTS, _infer_research_category
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.visual_report import _standard_visual_variant
from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -20,7 +21,7 @@ def _tool_schema(name: str) -> dict:
def test_visual_research_mode_is_removed_from_ui_prompt_and_tool_schema():
panel_source = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8")
style_source = (ROOT / "static/style.css").read_text(encoding="utf-8")
style_source = app_css()
routes_source = (ROOT / "routes/research/research_routes.py").read_text(encoding="utf-8")
category_schema = _tool_schema("trigger_research")["parameters"]["properties"]["category"]
rounds_schema = _tool_schema("trigger_research")["parameters"]["properties"]["max_rounds"]
+7 -2
View File
@@ -225,8 +225,13 @@ async def test_glob_confined_e2e(ws, admin):
assert ws not in r["output"]
assert "/workspace/found.py" in r["output"]
# a secret outside the workspace must not be discoverable via glob
outside = tempfile.mkdtemp()
# a secret outside the workspace must not be discoverable via glob.
# realpath so this directory and os.path.realpath(ws) below sit in the same
# resolved tree. On macOS /tmp is a symlink to /private/tmp, and mixing a
# resolved workspace with an unresolved secret makes relpath emit
# "../../../../tmp/<abs path>", which trivially contains the absolute path
# the assertion is checking for.
outside = os.path.realpath(tempfile.mkdtemp())
secret = os.path.join(outside, "secret.txt")
with open(secret, "w") as f:
f.write("nope")