Merge lab into audit/ref-parity

This commit is contained in:
Alexandre Teixeira
2026-10-01 02:45:10 +01:00
215 changed files with 75133 additions and 71057 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.
+6 -5
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 `--`:
@@ -140,7 +142,7 @@ swallowed.
## CSS computed-style snapshot
`tests/test_css_computed_style_snapshot.py` pins the rendered result of
`static/style.css` - one 51k-line file whose behavior depends on source order -
the shipped ordered stylesheet cascade, whose behavior depends on source order,
by hashing `getComputedStyle` over a fixed element inventory across pages,
viewports, themes and density modes. Any PR that moves CSS has to produce an
identical digest or explain why it did not.
@@ -178,14 +180,13 @@ Use when a test asserts on a CSS rule.
- Returns every app stylesheet concatenated in the order `static/index.html`
loads them, which is the order the cascade actually has.
- Panel styles no longer all live in `static/style.css`; reading that file
alone ties the test to whichever file a rule sits in today, so it goes red
- App styles live across an ordered cascade; reading one fragment alone ties
the test to whichever file a rule sits in today, so it goes red
when a rule moves without the rendered page changing.
- `stylesheet_paths()` and `stylesheet_urls()` are there when a test needs the
files or the request URLs rather than their contents.
`stylesheet_link_tags()` returns the `<link>` markup for a synthetic page
driven through Playwright, so it gets the whole cascade instead of only
`style.css`.
driven through Playwright, so it gets the whole shipped cascade.
- All of them fail loudly if `index.html` links a stylesheet that is missing.
- Not for vendored CSS under `static/lib/`, which they deliberately skip.
+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,
)
+2 -2
View File
@@ -1,6 +1,6 @@
# Computed-style snapshot harness
`static/style.css` is 51,425 lines in one file. Hundreds of selectors are
The app CSS is an ordered multi-file cascade. Hundreds of selectors are
declared more than once and `!important` appears throughout, so the rendered
result is a function of **source order**. Extracting a block into its own file,
reordering `<link>` tags, or moving an `@media` rule can silently change which
@@ -29,7 +29,7 @@ so the measurements stay independent.
The **bench** page measures one synthesised element per selector, built from
the selector itself. Its selector list is evidence-driven: every selector
declared **more than once** in `style.css` that can be expressed as a static
declared **more than once** in the app cascade that can be expressed as a static
compound chain (551 of them), plus a curated set covering chat, documents,
email, notes, calendar, settings, cookbook and gallery. Redeclared selectors
are the ones a reorder can actually flip, so they are the ones worth benching.
+21 -1
View File
@@ -9,7 +9,27 @@
for opening the page by hand; it is deliberately unversioned so the
cache-bust string has one home (tests/test_static_stylesheet_manifest.py
pins the shipped ones). -->
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/css/00-tokens.css">
<link rel="stylesheet" href="/static/css/01-agent-chat.css">
<link rel="stylesheet" href="/static/css/02-compare.css">
<link rel="stylesheet" href="/static/css/03-agent-chat.css">
<link rel="stylesheet" href="/static/css/04-memory.css">
<link rel="stylesheet" href="/static/css/05-documents.css">
<link rel="stylesheet" href="/static/css/06-admin-settings.css">
<link rel="stylesheet" href="/static/css/07-documents.css">
<link rel="stylesheet" href="/static/css/08-skills.css">
<link rel="stylesheet" href="/static/css/09-gallery.css">
<link rel="stylesheet" href="/static/css/10-cookbook.css">
<link rel="stylesheet" href="/static/css/11-tasks.css">
<link rel="stylesheet" href="/static/css/12-gallery.css">
<link rel="stylesheet" href="/static/css/13-image-editor.css">
<link rel="stylesheet" href="/static/css/14-email.css">
<link rel="stylesheet" href="/static/css/15-notes.css">
<link rel="stylesheet" href="/static/css/16-calendar.css">
<link rel="stylesheet" href="/static/css/17-research.css">
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css">
</head>
<body>
<!-- Intentionally empty. tests/css_snapshot/capture.mjs mounts one subtree
+16 -3
View File
@@ -55,7 +55,10 @@ function swapRuleOccurrences(css, selector) {
}
const matches = blocks.filter(b => b.selector === selector && b.end !== undefined);
if (matches.length < 2) {
throw new Error(`swap-rule: need two top-level blocks for "${selector}", found ${matches.length}`);
// The selector is not in this sheet, or appears once. The stylesheet is
// split across several files, so that is expected for most of them: the
// caller decides whether any sheet matched at all.
return null;
}
const [a, b] = matches;
const textA = css.slice(a.start, a.end);
@@ -186,6 +189,7 @@ async function main() {
const missing = {};
try {
let swapped = 0;
for (const page of job.pages) {
snapshot[page.name] = {};
let shippedStylesheets = null;
@@ -217,9 +221,15 @@ async function main() {
});
if (job.swapRule) {
await tab.route('**/static/style.css*', async route => {
// The cascade is spread over several files, so find the one that
// actually holds two top-level blocks of the selector and rewrite
// only that one. Every other sheet passes through untouched.
await tab.route('**/static/**/*.css*', async route => {
const response = await route.fetch();
const body = swapRuleOccurrences(await response.text(), job.swapRule);
const original = await response.text();
const body = swapRuleOccurrences(original, job.swapRule);
if (body === null) return route.fulfill({ response, body: original });
swapped += 1;
await route.fulfill({ response, body, headers: { ...response.headers(), 'content-type': 'text/css; charset=utf-8' } });
});
}
@@ -253,6 +263,9 @@ async function main() {
await context.close();
}
}
if (job.swapRule && swapped === 0) {
throw new Error(`swap-rule: no stylesheet had two top-level blocks for "${job.swapRule}"`);
}
} finally {
await browser.close();
}
@@ -1,10 +1,17 @@
const { test, expect } = require('@playwright/test');
async function addAppStyles(page) {
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
for (const url of await stylesheetUrls()) {
await page.addStyleTag({ url });
}
}
test('mobile compare uses tabs to show one mounted pane at a time', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/login');
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
await addAppStyles(page);
await page.evaluate(async () => {
const { default: state } = await import('/static/js/compare/state.js');
@@ -58,7 +65,7 @@ test('mobile compare uses tabs to show one mounted pane at a time', async ({ pag
test('mobile compare probe keeps feedback below models and actions split', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/login');
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
await addAppStyles(page);
await page.evaluate(() => {
document.body.innerHTML = `
<div class="compare-probe-overlay">
+288
View File
@@ -0,0 +1,288 @@
"""Read the document editor's JavaScript the way the browser loads it.
``static/js/document.js`` is being decomposed. It stays the entry point the
browser requests -- ``static/index.html`` names it, ``static/sw.js`` precaches
it, and five modules import it -- but the implementation moves into modules
under ``static/js/document/``. The implementation set is the entry plus that
directory.
Two habits in the existing tests do not survive that move, and this module
exists to replace both.
**Reading the entry file alone.** A membership assertion against
``document.js`` silently covers less the moment the behaviour it names moves
out. Use :func:`document_source` for those: it is the whole implementation set,
so a test keeps finding what it asserts on wherever the code lands.
**Slicing between two adjacent functions.** ``function_body("a")`` means "the region between a and b", which is only
the body of ``a`` while ``a`` and ``b`` happen to be neighbours in one file.
After a split they may sit in different modules, and then the slice runs to the
end of the concatenation and quietly grows: an ``assert "x" in region`` passes
against code it was never meant to see. Several of these also hard-code the
entry file's two-space indentation (``"\\n function showDocTabMenu"``), which
no extracted module reproduces. Use :func:`function_body` or
:func:`declaration` instead -- they find the construct by name, in whichever
module defines it, and end at its real closing brace.
"""
from __future__ import annotations
import re
from pathlib import Path
_STATIC = Path(__file__).resolve().parents[2] / "static"
_ENTRY = _STATIC / "js" / "document.js"
# Extracted implementation modules get one home, so the set is discoverable
# without a manifest anyone has to remember to update.
_IMPL_DIR = _STATIC / "js" / "document"
def document_source_paths() -> list[Path]:
"""Every file holding document-editor implementation, entry first.
The entry comes first so a concatenation reads in the order the browser
evaluates the graph's root; the rest are sorted for determinism.
"""
if not _ENTRY.is_file():
raise AssertionError(f"document editor entry point is missing: {_ENTRY}")
extracted = sorted(_IMPL_DIR.rglob("*.js")) if _IMPL_DIR.is_dir() else []
return [_ENTRY, *extracted]
def document_source() -> str:
"""The whole implementation set as one string, entry first.
For membership assertions (``assert "..." in document_source()``). For
anything positional use :func:`function_body` or :func:`declaration`.
"""
return "\n".join(p.read_text(encoding="utf-8") for p in document_source_paths())
# --- Locating a construct by name, not by what follows it ------------------
def _defining_source(pattern: re.Pattern[str], what: str) -> tuple[str, int]:
"""The source text that defines ``what``, and the offset of the match."""
hits = []
for path in document_source_paths():
src = path.read_text(encoding="utf-8")
for m in pattern.finditer(src):
hits.append((path, src, m.start()))
if not hits:
raise AssertionError(f"{what} is not defined anywhere in {_describe_set()}")
if len(hits) > 1:
where = ", ".join(
f"{p.relative_to(_STATIC.parent)}:{s.count(chr(10), 0, o) + 1}"
for p, s, o in hits
)
raise AssertionError(f"{what} is defined more than once ({where})")
_path, src, offset = hits[0]
return src, offset
def _describe_set() -> str:
return ", ".join(str(p.relative_to(_STATIC.parent)) for p in document_source_paths())
def function_body(name: str) -> str:
"""The full text of function ``name``, signature through closing brace.
Matches ``function name``, optionally prefixed by ``export`` and/or
``async``, at any indentation, in whichever module of the implementation
set defines it. The end is found by matching braces rather than by naming
whatever declaration follows, so moving the function -- or the one after
it -- does not change the region a test sees.
"""
pattern = re.compile(
r"^[ \t]*(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
re.M,
)
src, offset = _defining_source(pattern, f"function {name}")
# Skip the parameter list before looking for the body. A destructured
# parameter -- `function f(table, { headerRow, headerColumn })` -- opens a
# brace that is not the body, and matching it would return the signature
# alone.
body_start = _end_of_params(src, src.index("(", offset))
return src[offset : _end_of_block(src, body_start)]
def declaration(name: str) -> str:
"""The full text of a top-level ``const``/``let``/``var`` named ``name``.
For the array and object tables the tests assert on (toolbar groups, slash
commands, input rules). Ends at the declaration's closing bracket or brace,
or at the end of the statement for a simple initialiser.
"""
pattern = re.compile(
r"^[ \t]*(?:export\s+)?(?:const|let|var)\s+" + re.escape(name) + r"\b",
re.M,
)
src, offset = _defining_source(pattern, f"declaration {name}")
return src[offset : _end_of_statement(src, offset)]
# --- A brace matcher that is not fooled by braces inside literals ----------
#
# `document.js` is full of template literals building DOM, regexes containing
# braces, and apostrophes inside comments. Counting raw `{`/`}` mis-slices on
# all three, so the scan tracks what kind of text it is inside.
# After one of these, `/` starts a regex literal; after a value it is division.
_REGEX_OK_BEFORE = re.compile(r"[({\[,;:=!&|?+\-*~^%<>]\s*$|\b(?:return|typeof|case|in|of|new|delete|void|do|else|yield|await)\s*$")
def _scan(src: str, start: int, stop):
"""Walk ``src`` from ``start``, skipping literals and comments.
Calls ``stop(index, depth_delta_applied)``-free: instead it yields
``(index, char)`` for code positions only, so callers can track nesting.
"""
i, n = start, len(src)
# Stack of template-literal depths: entering `${` pushes brace depth.
template_stack: list[int] = []
while i < n:
c = src[i]
two = src[i : i + 2]
if two == "//":
j = src.find("\n", i)
i = n if j == -1 else j + 1
continue
if two == "/*":
j = src.find("*/", i + 2)
i = n if j == -1 else j + 2
continue
if c in "'\"":
i = _skip_quoted(src, i, c)
continue
if c == "`":
i += 1
i, entered = _skip_template(src, i)
if entered:
template_stack.append(0)
continue
if c == "/" and _REGEX_OK_BEFORE.search(src[max(0, i - 24) : i]):
j = _skip_regex(src, i)
if j is not None:
i = j
continue
if template_stack:
# Inside `${ ... }`: a `}` that closes it returns to template text.
if c == "{":
template_stack[-1] += 1
elif c == "}":
if template_stack[-1] == 0:
template_stack.pop()
i += 1
i, entered = _skip_template(src, i)
if entered:
template_stack.append(0)
continue
template_stack[-1] -= 1
yield i, c
i += 1
def _skip_quoted(src: str, i: int, quote: str) -> int:
i += 1
n = len(src)
while i < n:
if src[i] == "\\":
i += 2
continue
if src[i] == quote:
return i + 1
if src[i] == "\n": # unterminated; do not run away
return i
i += 1
return n
def _skip_template(src: str, i: int) -> tuple[int, bool]:
"""From inside template text, advance to the backtick end or a ``${``.
Returns the new index and whether an interpolation was entered.
"""
n = len(src)
while i < n:
if src[i] == "\\":
i += 2
continue
if src[i] == "`":
return i + 1, False
if src[i : i + 2] == "${":
return i + 2, True
i += 1
return n, False
def _skip_regex(src: str, i: int) -> int | None:
"""Past a regex literal starting at ``i``, or None if it is not one."""
i += 1
n = len(src)
in_class = False
while i < n:
c = src[i]
if c == "\\":
i += 2
continue
if c == "\n":
return None
if in_class:
if c == "]":
in_class = False
elif c == "[":
in_class = True
elif c == "/":
i += 1
while i < n and src[i].isalpha(): # flags
i += 1
return i
i += 1
return None
def _end_of_block(src: str, start: int) -> int:
"""Index just past the ``}`` closing the first ``{`` at or after ``start``."""
depth = 0
seen = False
for i, c in _scan(src, start, None):
if c == "{":
depth += 1
seen = True
elif c == "}":
depth -= 1
if seen and depth == 0:
return i + 1
raise AssertionError(f"unbalanced braces from offset {start}")
def _end_of_statement(src: str, start: int) -> int:
"""Index just past the end of the declaration statement at ``start``.
Ends on the ``;`` or newline that closes it at nesting depth zero, so an
array or object initialiser is returned whole.
"""
depth = 0
for i, c in _scan(src, start, None):
if c in "{[(":
depth += 1
elif c in "}])":
depth -= 1
elif depth == 0 and c == ";":
return i + 1
elif depth == 0 and c == "\n" and i > start:
return i
return len(src)
def _end_of_params(src: str, open_paren: int) -> int:
"""Index just past the ``)`` closing the parameter list at ``open_paren``."""
depth = 0
for i, c in _scan(src, open_paren, None):
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return i + 1
raise AssertionError(f"unbalanced parameter list at offset {open_paren}")
+31
View File
@@ -31,6 +31,7 @@ safe for callers that pass both a parent package and a child module.
"""
import sys
import types
from contextlib import contextmanager
_ABSENT = object()
@@ -167,3 +168,33 @@ def preserve_import_state(*module_names):
# Phase 2: restore all parent-package attributes.
for name, (_, saved_attr) in saved.items():
_restore_parent_attr(name, saved_attr)
# Names under these prefixes are the ones a leaked stub actually breaks: a
# later test doing ``import src.x`` or ``import core.x`` silently gets the
# empty stub instead of the real module.
_GUARDED_PREFIXES = ("src.", "core.")
def bare_module_stubs():
"""Return the ``src.*``/``core.*`` names currently bound to a bare stub.
A bare stub is a plain :class:`types.ModuleType` with no on-disk
``__file__`` — the object ``types.ModuleType(name)`` produces. That is the
same "is this a fake?" test the ``clear_fake_*`` helpers above use, so a
module imported from disk is never reported.
``MagicMock`` stand-ins are deliberately out of scope: they answer every
attribute, so they fail loudly at use rather than silently, and several
test modules install them on purpose.
"""
found = set()
for name, mod in list(sys.modules.items()):
if not name.startswith(_GUARDED_PREFIXES):
continue
if type(mod) is not types.ModuleType:
continue
if getattr(mod, "__file__", None):
continue
found.add(name)
return found
+83
View File
@@ -0,0 +1,83 @@
"""Read a split JS subpackage the way the module graph does.
``static/js/emailLibrary.js`` is a re-export wrapper; the implementation lives
in ``static/js/emailLibrary/``. A test that asserts on email-library behaviour
has to look at every module in that package, because reading one file ties the
test to whichever module a function happens to sit in today — it goes red the
next time something moves without any behaviour changing.
That is the mistake the stylesheet split made, which is why
``tests/helpers/stylesheets.py`` exists. This is the same helper for JS.
Order is deterministic: the entry module first, then the rest alphabetically.
Tests that assert "A appears before B" are asserting about one module's source,
not about the package, so the concatenation order only has to be stable.
"""
from __future__ import annotations
import re
from pathlib import Path
_STATIC_JS = Path(__file__).resolve().parents[2] / "static" / "js"
EMAIL_LIBRARY_WRAPPER = _STATIC_JS / "emailLibrary.js"
EMAIL_LIBRARY_PACKAGE = _STATIC_JS / "emailLibrary"
EMAIL_LIBRARY_ENTRY = EMAIL_LIBRARY_PACKAGE / "index.js"
def _package_paths(package: Path, entry: Path) -> list[Path]:
if not entry.is_file():
raise AssertionError(f"missing package entry module: {entry}")
rest = sorted(p for p in package.glob("*.js") if p != entry)
return [entry, *rest]
def email_library_paths(include_wrapper: bool = False) -> list[Path]:
"""Every module of the email-library package, entry module first.
``include_wrapper`` adds the compatibility file at the old top-level path.
Leave it off for assertions about implementation code: the wrapper holds
only an ``export … from`` list.
"""
paths = _package_paths(EMAIL_LIBRARY_PACKAGE, EMAIL_LIBRARY_ENTRY)
return [EMAIL_LIBRARY_WRAPPER, *paths] if include_wrapper else paths
def email_library_source(include_wrapper: bool = False) -> str:
"""The whole email-library package as one string."""
return "\n".join(
p.read_text(encoding="utf-8") for p in email_library_paths(include_wrapper)
)
def js_function_source(name: str, source: str | None = None) -> str:
"""One top-level JS function, from its signature to its closing brace.
Two things this does not do, on purpose.
It does not slice between a signature and a marker further down ("from
``_toggleCardPreview`` to the ``Wrap a probable signature`` comment"). That
is what a split breaks: the marker ends up in another module, the slice runs
past the end of the function without failing, and the assertions keep
passing against the wrong text.
It does not balance braces by walking characters either. The obvious version
of that walker treats the apostrophe in a ``// that's a scroll`` comment as
an open quote and swallows every brace until the next one, which ends the
function early — silently, again.
Instead it uses the invariant the file actually holds: a top-level
declaration starts at column 0, so its closing brace is the next lone ``}``
at column 0.
"""
text = email_library_source() if source is None else source
signature = re.compile(
r"^(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
re.M,
)
match = signature.search(text)
assert match, f"no top-level declaration of {name}"
closing = re.compile(r"^\}", re.M).search(text, match.end())
assert closing, f"unterminated function {name}"
return text[match.start():closing.end()]
+67
View File
@@ -0,0 +1,67 @@
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const STATIC = join(HERE, '..', '..', 'static');
const INDEX = join(STATIC, 'index.html');
const LINK = /<link\b[^>]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']\/static\/([^"'?]+)([^"']*)["']/gi;
async function entries() {
const html = await readFile(INDEX, 'utf8');
const out = [];
for (const match of html.matchAll(LINK)) {
const rel = match[1];
const query = match[2];
if (rel.startsWith('lib/')) continue;
out.push({
path: join(STATIC, rel),
url: `/static/${rel}${query}`,
});
}
if (!out.length) {
throw new Error(`no app stylesheet <link> tags found in ${INDEX}`);
}
for (const entry of out) {
try {
await readFile(entry.path);
} catch {
throw new Error(
`index.html links a stylesheet that does not exist: ${entry.path}`,
);
}
}
return out;
}
export async function stylesheetPaths() {
return (await entries()).map(entry => entry.path);
}
export async function stylesheetUrls() {
return (await entries()).map(entry => entry.url);
}
export async function stylesheetLinkTags() {
return (await stylesheetUrls())
.map(url => `<link rel="stylesheet" href="${url}">`)
.join('');
}
export async function appCss() {
const paths = await stylesheetPaths();
const parts = [];
for (const path of paths) {
parts.push(await readFile(path, 'utf8'));
}
return parts.join('\n');
}
+28 -7
View File
@@ -1,11 +1,11 @@
"""Read the app's CSS the way the browser does.
``static/style.css`` no longer holds every rule: panel styles live in separate
files that ``static/index.html`` loads eagerly, in a fixed order, right after
it. The cascade is the concatenation of those files in that order.
The former ``static/style.css`` is now an ordered set of numbered fragments,
followed by the existing panel stylesheets. ``static/index.html`` loads the
complete cascade eagerly in the order the browser must apply it.
A test that asserts on a rule must therefore look at all of them. Reading
``static/style.css`` alone ties the test to whichever file a rule happens to
A test that asserts on a rule must therefore look at the complete cascade.
Reading one fragment alone ties the test to whichever file a rule happens to
sit in today, so it goes red the next time a rule moves without anything about
the rendered page having changed.
"""
@@ -53,11 +53,32 @@ def stylesheet_urls() -> list[str]:
def stylesheet_link_tags() -> str:
"""The <link> tags to drop into a synthetic page so it gets the whole
cascade, not just style.css."""
"""The <link> tags for a synthetic page that needs the whole cascade."""
return "".join(f'<link rel="stylesheet" href="{u}">' for u in stylesheet_urls())
def app_css() -> str:
"""The whole cascade as one string, in load order."""
return "\n".join(p.read_text(encoding="utf-8") for p in stylesheet_paths())
def stylesheet_cache_version() -> str:
"""The single ``?v=`` token every app stylesheet link carries.
The stylesheet is split across several files that must be busted together:
shipping one fragment under a stale token serves a browser half of an old
cascade and half of a new one. Tests ask for the shared version here instead of deriving it from one
stylesheet filename, so they keep checking the invariant
rather than a filename.
"""
versions = set()
for url in stylesheet_urls():
m = re.search(r"\?v=([^&]+)$", url)
if not m:
raise AssertionError(f"app stylesheet has no cache-bust token: {url}")
versions.add(m.group(1))
if len(versions) != 1:
raise AssertionError(
f"app stylesheets disagree on their cache-bust token: {sorted(versions)}"
)
return versions.pop()
@@ -20,6 +20,14 @@ const REAL_MODULES = new Set([
path.join(JS, 'settings/sidebar.js'),
path.join(JS, 'settings/navigation.js'),
path.join(JS, 'settings/lifecycle.js'),
path.join(JS, 'settings/api.js'),
path.join(JS, 'settings/speech.js'),
path.join(JS, 'settings/writingStyle.js'),
path.join(JS, 'settings/imageModels.js'),
path.join(JS, 'settings/agent.js'),
path.join(JS, 'settings/shell.js'),
path.join(JS, 'settings/peek.js'),
path.join(JS, 'settings/oauthReturn.js'),
path.join(JS, 'searchProviderIcons.js'),
]);
@@ -497,6 +505,11 @@ function buildFixture(document) {
header.className = 'modal-header';
modal.appendChild(header);
const peekToggle = document.createElement('button');
peekToggle.id = 'settings-opacity-wrap';
peekToggle.className = 'theme-opacity-wrap theme-opacity-toggle hidden';
header.appendChild(peekToggle);
const close = document.createElement('button');
close.className = 'close-btn';
header.appendChild(close);
@@ -539,6 +552,14 @@ function buildFixture(document) {
panels.className = 'settings-panels';
content.appendChild(panels);
const adminCard = document.createElement('div');
adminCard.className = 'admin-card';
panels.appendChild(adminCard);
const adminOnly = document.createElement('div');
adminOnly.className = 'admin-only';
adminCard.appendChild(adminOnly);
const panelIds = [
'services',
'added-models',
@@ -590,6 +611,9 @@ function buildFixture(document) {
sidebarHandle,
searchInput,
searchResults,
peekToggle,
adminCard,
adminOnly,
services: settingsPanels.services,
appearance: settingsPanels.appearance,
ai: settingsPanels.ai,
@@ -994,6 +1018,14 @@ assert(
);
// shell.js owns admin-only visibility. A non-admin must not merely see an
// unpopulated admin control — the element has to be hidden on every open().
assert(
fixture.adminOnly.style.display === 'none',
'open() did not hide .admin-only for a non-admin',
);
// #6040 coordinator integration: initAll() must bind the real finder and
// sidebar controllers, not merely make their modules link successfully.
assert(
@@ -1050,6 +1082,28 @@ assert(
'navigation callback did not apply Appearance coordinator state',
);
assert(
!fixture.peekToggle.classList.contains('hidden'),
'Appearance activation did not reveal the Peek toggle',
);
// peek.js fades the window background via color-mix, never element opacity, so
// the controls stay readable while the user previews the page behind Settings.
fixture.peekToggle.click();
assert(
fixture.content.style.values.background
=== 'color-mix(in srgb, var(--bg) 55%, transparent)',
'Peek toggle did not fade the Settings window background',
);
assert(
fixture.adminCard.style.values.background
=== 'color-mix(in srgb, var(--panel) 55%, transparent)',
'Peek toggle did not fade the Settings cards',
);
// Direct public open() after initialization must still coordinate activation.
settings.open('ai');
@@ -1069,6 +1123,19 @@ assert(
'direct open("ai") did not clear Appearance coordinator state',
);
// Leaving Appearance with Peek still toggled on must not leave the rest of
// Settings faded — this is the bug the sync exists to prevent.
assert(
fixture.content.style.values.background === undefined
&& fixture.adminCard.style.values.background === undefined,
'leaving Appearance left the Peek fade applied',
);
assert(
fixture.peekToggle.classList.contains('hidden'),
'leaving Appearance left the Peek toggle visible',
);
// Public close() must route through the real lifecycle module.
settings.close();
@@ -1084,6 +1151,44 @@ assert(
);
// shell.js hands an admin-managed tab to admin.js and must not then perform a
// second local activation. Nothing before this point installs an admin module,
// so the earlier assertions covered the no-admin-module fallback.
const adminCalls = [];
sandbox.adminModule = {
open(tab) {
adminCalls.push(tab);
return true;
},
_initData() {
adminCalls.push('_initData');
},
};
fixture.settingsPanels.users.button.click();
assert(
adminCalls.length === 1 && adminCalls[0] === 'users',
`admin tab click did not hand "users" to the admin module: ${adminCalls}`,
);
assert(
!fixture.settingsPanels.users.button.classList.contains('active'),
'shell activated an admin tab locally after the admin module claimed it',
);
// Admin status is read per open(), not cached at initialization.
sandbox._isAdmin = true;
settings.open('services');
assert(
fixture.adminOnly.style.display === '',
'open() did not reveal .admin-only for an admin',
);
// initAll() starts some existing async panel initializers without awaiting
// them. Give already-ready continuations a chance to run before declaring the
// smoke successful, so late coordinator/setup exceptions still fail the test.
@@ -1098,4 +1203,7 @@ console.log(JSON.stringify({
navigationCallback: true,
directOpen: true,
directClose: true,
peekChrome: true,
adminVisibility: true,
adminTabHandoff: true,
}));
+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)
+10 -7
View File
@@ -1,10 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { appCss } from './helpers/stylesheets.mjs';
import { chromium } from 'playwright';
test('research primary actions use compact mobile sizing and retain desktop sizing', async () => {
const css = await readFile(new URL('../static/style.css', import.meta.url), 'utf8');
test('research primary actions retain shipped cascade sizing across mobile and desktop', async () => {
const css = await appCss();
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
@@ -25,10 +25,13 @@ test('research primary actions use compact mobile sizing and retain desktop sizi
}));
for (const button of buttons) {
if (width <= 600) {
assert.equal(button.width, 24);
assert.equal(button.height, 22);
assert.equal(button.icon, 10);
assert.equal(button.labelHidden, true);
// Assert the complete shipped cascade, not the historical
// style.css-only result. Later app styles keep the action labels
// visible and use the larger mobile control geometry.
assert.ok(button.width > 24);
assert.equal(button.height, 28);
assert.equal(button.icon, 13);
assert.equal(button.labelHidden, false);
} else {
assert.equal(button.height, 20);
assert.equal(button.icon, 10);
+31 -26
View File
@@ -2,6 +2,7 @@ from pathlib import Path
import subprocess
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parents[1]
@@ -13,13 +14,17 @@ def test_shared_action_menu_order_is_used_by_item_menus() -> None:
"static/js/tasks.js": "orderActionMenuItems",
"static/js/sessions.js": "orderActionMenuItems",
"static/js/research/panel.js": "orderActionMenuItems",
"static/js/emailLibrary.js": "orderActionMenuItems",
"static/js/memory.js": "orderActionMenuItems",
}
for relative_path, helper in expected_imports.items():
source = (ROOT / relative_path).read_text(encoding="utf-8")
assert "actionMenuOrder.js" in source
assert helper in source
# The email library is a package, so the import and the call can sit in
# different modules of it.
email = email_library_source()
assert "actionMenuOrder.js" in email
assert "orderActionMenuItems" in email
def test_common_action_order_matches_product_convention() -> None:
@@ -68,20 +73,20 @@ def test_dropdown_select_actions_use_the_canonical_icon() -> None:
"static/js/sessions.js",
"static/js/skills.js",
"static/js/tasks.js",
"static/js/emailLibrary.js",
"static/js/research/panel.js",
):
module = (ROOT / relative_path).read_text(encoding="utf-8")
assert "SELECT_MENU_ICON" in module
assert "SELECT_MENU_ICON" in email_library_source()
def test_email_filter_menu_has_context_title() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert 'email-filter-menu-title">Filter by...</div>' in source
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
style = app_css()
assert 'email-settings-auto-reply-section' in source
assert 'email-settings-display-enabled-state' in source
@@ -90,14 +95,14 @@ def test_email_setting_toggles_render_neutral_disabled_state() -> None:
def test_email_search_options_menu_has_context_title() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
menu_start = source.index('id="email-search-options-menu"')
menu_end = source.index("</div>", menu_start) + len("</div>")
assert 'email-search-options-title">Filter by...</div>' in source[menu_start:menu_end]
def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert "function _emailTimelineGapThreshold(items)" in source
assert "email-date-gap-break" in source
assert "gapDays > 90 && gapDays > timelineGapThreshold" in source
@@ -107,7 +112,7 @@ def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert '<option value="tag:action-needed">' not in source
assert "filter:tag:action-needed" not in source
assert "email-card-favorite" in source
@@ -132,7 +137,7 @@ def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
def test_email_auto_reply_start_date_seeds_today_when_picker_opens() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert "function _todayDateInputValue()" in source
assert "if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();" in source
assert "autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);" in source
@@ -140,7 +145,7 @@ def test_email_auto_reply_start_date_seeds_today_when_picker_opens() -> None:
def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert "function _syncAutoReplyCalendarEvent(cfg)" in source
assert "summary: 'Email Auto Reply (away)'" in source
assert "function _findAutoReplyCalendarEventUids(cfg, accountId)" in source
@@ -155,7 +160,7 @@ def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
def test_email_settings_show_away_account_and_compact_display_controls() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
style = app_css()
assert 'email-account-away-label">(AWAY)</span>' in source
assert 'id="email-lib-auto-reply-badge"' in source
@@ -175,14 +180,14 @@ def test_email_settings_show_away_account_and_compact_display_controls() -> None
def test_email_cleanup_uses_the_memory_tidy_star_icon() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
cleanup = source[source.index("function _emailCleanupSettingsHtml"):source.index("function _emailDisplaySettingsHtml")]
assert "email-settings-clean-btn" in cleanup
assert "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" in cleanup
def test_email_settings_escape_returns_to_email_list() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
settings_guard = "if (modal.classList.contains('email-settings-mode'))"
assert settings_guard in source
assert source.index(settings_guard) < source.index("closeEmailLibrary();", source.index(settings_guard))
@@ -190,7 +195,7 @@ def test_email_settings_escape_returns_to_email_list() -> None:
def test_email_select_escape_cancels_selection_without_closing_library() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
select_guard = "if (state._selectMode) {"
select_start = source.index(select_guard, source.index("if (e.key === 'Escape')"))
assert "_setSelectBtnState(false);" in source[select_start:select_start + 260]
@@ -206,7 +211,7 @@ def test_chat_delete_actions_use_the_shared_trash_bin_icon() -> None:
def test_agent_unsubscribe_uses_the_reviewed_target_without_rescanning() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
start = source.index("function _askAgentToUnsubscribe")
end = source.index("function _unsubscribeCandidateUids", start)
prompt = source[start:end]
@@ -219,7 +224,7 @@ def test_agent_unsubscribe_uses_the_reviewed_target_without_rescanning() -> None
def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
start = source.index("function _bindEmailSettingsPageControls")
end = source.index("function _setUnsubButtonBusy", start)
controls = source[start:end]
@@ -228,7 +233,7 @@ def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
def test_unsubscribe_duplicate_badge_is_lowered() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
frontend = email_library_source()
stylesheet = app_css()
assert "email-unsub-duplicate-badge" in frontend
start = stylesheet.index(".email-unsub-duplicate-badge {")
@@ -236,7 +241,7 @@ def test_unsubscribe_duplicate_badge_is_lowered() -> None:
def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
frontend = email_library_source()
stylesheet = app_css()
start = frontend.index("function _emailCleanupSettingsHtml")
end = frontend.index("function _emailDisplaySettingsHtml", start)
@@ -257,8 +262,8 @@ def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
backend = (ROOT / "routes/email_routes.py").read_text(encoding="utf-8")
frontend = email_library_source()
backend = (ROOT / "routes/email/email_routes.py").read_text(encoding="utf-8")
mcp = (ROOT / "mcp_servers/email_server.py").read_text(encoding="utf-8")
assert "async function _deleteAfterUnsubscribe" in frontend
assert "action: 'delete'" in frontend[frontend.index("async function _deleteAfterUnsubscribe"):]
@@ -271,7 +276,7 @@ def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
def test_agent_email_mutations_reconcile_bulk_single_and_mailto_results() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
start = source.index("function _agentDeletedEmailUids")
end = source.index("function _handleAgentEmailToolOutput", start)
resolver = source[start:end]
@@ -282,7 +287,7 @@ def test_agent_email_mutations_reconcile_bulk_single_and_mailto_results() -> Non
def test_browser_agent_unsubscribe_cleans_sender_after_positive_confirmation() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
start = source.index("function _agentBrowserUnsubscribeSucceeded")
end = source.index("function _agentDeletedEmailUids", start)
browser_flow = source[start:end]
@@ -311,7 +316,7 @@ def test_email_mutation_tool_events_include_exact_arguments() -> None:
def test_unsubscribe_cleanup_can_remove_same_sender_unsubscribe_messages() -> None:
source = (ROOT / "routes" / "email_routes.py").read_text()
source = (ROOT / "routes" / "email" / "email_routes.py").read_text()
cleanup = source[source.index('@router.post("/unsubscribe/cleanup")'):source.index('@router.get("/contacts")')]
assert 'scope == "sender_unsubscribe"' in cleanup
assert "_unsubscribe_sender_uids_sync" in cleanup
@@ -321,7 +326,7 @@ def test_unsubscribe_cleanup_can_remove_same_sender_unsubscribe_messages() -> No
def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
source = email_library_source()
start = source.index("function _markUnsubscribeCardDone")
end = source.index("async function _runUnsubscribeCleanup", start)
card = source[start:end]
@@ -331,7 +336,7 @@ def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> Non
def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
source = email_library_source()
styles = app_css()
assert "email-unsub-ignore-btn" in source
assert "_rememberUnsubscribeIgnored(c)" in source
@@ -340,7 +345,7 @@ def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None
def test_email_settings_sections_use_static_headers() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
source = email_library_source()
styles = app_css()
assert 'class="email-unsub-accent-icon"' in source
assert 'M12 0L14.59 8.41' in source
@@ -372,7 +377,7 @@ def test_email_settings_sections_use_static_headers() -> None:
def test_unsubscribe_scan_defaults_to_bounded_page_in_api_and_tool_prompt() -> None:
backend = (ROOT / "routes" / "email_routes.py").read_text()
backend = (ROOT / "routes" / "email" / "email_routes.py").read_text()
schema = (ROOT / "src" / "tool_schemas.py").read_text()
agent = (ROOT / "src" / "agent_loop.py").read_text()
scan_start = backend.index('@router.get("/unsubscribe/scan")')
@@ -1,20 +1,25 @@
from pathlib import Path
import re
from tests.helpers.document_source import document_source, function_body
from tests.helpers.js_modules import email_library_paths
ROOT = Path(__file__).resolve().parents[1]
DOCUMENT_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOCUMENT_JS = document_source()
CHAT_JS = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
APP_JS = (ROOT / "static/app.js").read_text(encoding="utf-8")
SETTINGS_JS = (ROOT / "static/js/settings.js").read_text(encoding="utf-8")
# The writing-style panel moved into static/js/settings/writingStyle.js; read
# the whole settings surface so this pins behaviour rather than a filename.
SETTINGS_JS = "\n".join(
p.read_text(encoding="utf-8")
for p in [ROOT / "static/js/settings.js", *sorted((ROOT / "static/js/settings").glob("*.js"))]
)
INDEX_HTML = (ROOT / "static/index.html").read_text(encoding="utf-8")
CHAT_ROUTE = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
def test_visible_or_minimized_linked_document_is_sent_as_chat_context():
function = DOCUMENT_JS.split("export function getChatDocumentId()", 1)[1].split(
"export function getActiveEmailComposerContext()", 1
)[0]
function = function_body("getChatDocumentId")
assert "pane?.isConnected" in function
assert "document.body.classList.contains('doc-view')" not in function
assert "style?.display !== 'none'" in function
@@ -42,8 +47,8 @@ def test_all_runtime_document_imports_share_one_module_url():
ROOT / "static/js/chat.js",
ROOT / "static/js/chatStream.js",
ROOT / "static/js/chatRenderer.js",
ROOT / "static/js/emailLibrary.js",
ROOT / "static/js/slashCommands.js",
*email_library_paths(include_wrapper=True),
]
versions = {
match
+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
@@ -2,6 +2,7 @@ from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parents[1]
@@ -32,7 +33,7 @@ def test_calendar_chat_event_links_fetch_uid_and_show_title_time():
app_src = (ROOT / "static/app.js").read_text()
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
inbox_src = (ROOT / "static/js/emailInbox.js").read_text()
library_src = (ROOT / "static/js/emailLibrary.js").read_text()
library_src = email_library_source()
assert '@router.get("/events/{uid}")' in routes_src
assert "async function _fetchEventByUid" in calendar_src
+5 -7
View File
@@ -2,6 +2,7 @@ from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parents[1]
@@ -285,13 +286,10 @@ def test_calendar_tool_guidance_preserves_manual_tags_on_unrelated_updates():
def test_calendar_visual_asset_versions_are_bumped():
versions = []
for rel in (
"static/app.js",
"static/js/chatRenderer.js",
"static/js/emailInbox.js",
"static/js/emailLibrary.js",
):
src = (ROOT / rel).read_text()
for src in [
(ROOT / rel).read_text()
for rel in ("static/app.js", "static/js/chatRenderer.js", "static/js/emailInbox.js")
] + [email_library_source()]:
match = re.search(r"calendar\.js\?v=([A-Za-z0-9_-]+)", src)
assert match
versions.append(match.group(1))
+17 -12
View File
@@ -1,8 +1,10 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_paths
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")
@@ -31,14 +33,17 @@ def test_library_chat_card_menu_uses_standard_anchor_gap():
def test_card_menus_use_the_same_anchor_gap():
for relative_path in (
"static/js/sessions.js",
"static/js/documentLibrary.js",
"static/js/emailLibrary.js",
"static/js/memory.js",
"static/js/tasks.js",
"static/js/skills.js",
):
source = (ROOT / relative_path).read_text(encoding="utf-8")
assert "rect.bottom + 2" not in source, relative_path
assert "r.bottom + 2" not in source, relative_path
modules = [
ROOT / relative_path
for relative_path in (
"static/js/sessions.js",
"static/js/documentLibrary.js",
"static/js/memory.js",
"static/js/tasks.js",
"static/js/skills.js",
)
] + email_library_paths(include_wrapper=True)
for module in modules:
source = module.read_text(encoding="utf-8")
assert "rect.bottom + 2" not in source, module
assert "r.bottom + 2" not in source, module
+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")
+3 -2
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]
@@ -99,9 +100,9 @@ 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."""
"""Verify static/index.html and the app stylesheet cascade 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
+6 -6
View File
@@ -1,5 +1,6 @@
from pathlib import Path
import re
from tests.helpers.stylesheets import app_css, stylesheet_cache_version
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
@@ -64,9 +65,8 @@ def test_compare_cache_key_bumped_for_shuffle_notice():
index = _read("static/js/compare/index.js")
app_versions = re.findall(r"/static/app\.js\?v=([A-Za-z0-9_-]+)", html)
style_version = re.search(r"/static/style\.css\?v=([A-Za-z0-9_-]+)", html)
assert app_versions and len(set(app_versions)) == 1
assert style_version and style_version.group(1) == app_versions[0]
assert stylesheet_cache_version() == app_versions[0]
assert re.search(r"compare/index\.js\?v=[A-Za-z0-9_-]+", app)
assert re.search(r"vote\.js\?v=[A-Za-z0-9_-]+", index)
assert re.search(r"panes\.js\?v=[A-Za-z0-9_-]+", index)
@@ -75,7 +75,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() == []
+4 -4
View File
@@ -1,7 +1,7 @@
"""Computed-style snapshot regression for ``static/style.css``.
"""Computed-style snapshot regression for the shipped app CSS cascade.
The stylesheet is one 51k-line file whose result depends on source order, so an
extraction that "looks fine" can still change which declaration wins. These
The stylesheet is an ordered multi-file cascade whose result depends on source
order, so a move that "looks fine" can still change which declaration wins. These
tests capture ``getComputedStyle`` over a fixed element inventory across pages,
viewports, themes and density modes, and compare the hash against
``tests/css_snapshot/baseline.json``.
@@ -38,7 +38,7 @@ def static_origin() -> str:
# One conflicting selector used to prove the harness is actually sensitive to
# source order. `.attach-strip` is declared three times at the top level of
# style.css with different margin, min-height and padding, so swapping the
# cascade with different margin, min-height and padding, so swapping the
# first two changes which declaration wins without changing a single byte of
# any individual rule.
CONFLICTING_SELECTOR = ".attach-strip"
+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
+1 -1
View File
@@ -50,7 +50,7 @@ def test_direct_upload_routes_use_bounded_reads():
"routes/calendar_routes.py": [
"read_upload_limited(file, ICS_MAX_BYTES",
],
"routes/email_routes.py": [
"routes/email/email_routes.py": [
"read_upload_limited(file, EMAIL_COMPOSE_UPLOAD_MAX_BYTES",
],
}
+3 -1
View File
@@ -18,6 +18,7 @@ import re
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
SRC = Path(__file__).resolve().parent.parent / "static/js/documentLibrary.js"
@@ -96,7 +97,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
const state = await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&mobile-library-open-test=1');
mod.init('/api');
@@ -134,6 +135,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock():
console.log(JSON.stringify(state));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=SRC.parents[1],
+4 -9
View File
@@ -1,11 +1,10 @@
"""Regression guards for restoring a chat's exact active document."""
from pathlib import Path
from tests.helpers.document_source import document_source, function_body
DOC_JS = (
Path(__file__).resolve().parents[1] / "static/js/document.js"
).read_text(encoding="utf-8")
DOC_JS = document_source()
def test_active_document_is_persisted_per_session():
@@ -32,9 +31,7 @@ def test_closing_active_document_clears_stale_restore_pointer():
def test_explicit_document_open_clears_minimized_dock_state():
ensure_mounted = DOC_JS.split("function _ensureDocPaneMounted()", 1)[1].split(
"export async function loadDocument", 1
)[0]
ensure_mounted = function_body("_ensureDocPaneMounted")
assert "Modals.isMinimized('doc-panel')" in ensure_mounted
assert "Modals.unregister('doc-panel');" in ensure_mounted
@@ -42,9 +39,7 @@ def test_explicit_document_open_clears_minimized_dock_state():
def test_library_open_intent_is_persisted_before_delayed_session_restore():
body = DOC_JS.split("export function prepareDocumentOpen(sessionId)", 1)[1].split(
"/** Switch chat", 1
)[0]
body = function_body("prepareDocumentOpen")
assert "_markDocVisibleState(sessionId, 'open');" in body
assert "Modals.isMinimized('doc-panel')" in body
+2 -2
View File
@@ -2,13 +2,13 @@
import re
from pathlib import Path
from tests.helpers.document_source import document_source
SRC = Path(__file__).resolve().parent.parent / "static/js/document.js"
def _function_body(name: str) -> str:
text = SRC.read_text(encoding="utf-8")
text = document_source()
match = re.search(rf"\n\s*(?:export\s+)?(?:async\s+)?function\s+{name}\([^)]*\)\s*\{{", text)
assert match, f"{name} not found"
+3 -2
View File
@@ -6,6 +6,7 @@ no JS unit harness for it — these pin the source-level invariants that the
"""
from pathlib import Path
from tests.helpers.document_source import document_source
_REPO = Path(__file__).resolve().parents[1]
@@ -21,13 +22,13 @@ def test_chat_document_links_use_the_document_id():
def test_document_deeplink_handled_on_hashchange_and_load():
"""#document-<id> in the URL must open the doc on refresh / URL-bar nav,
not just on click."""
js = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
js = document_source()
assert "addEventListener('hashchange', _maybeOpenDocFromHash)" in js
assert "#document-" in js
def test_failed_document_load_surfaces_user_error():
"""A missing/failed document must tell the user, not fail silently."""
js = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
js = document_source()
assert "uiModule.showError" in js
assert "Document not found" in js
@@ -19,9 +19,10 @@ browser-coupled and not importable in pytest.
"""
from pathlib import Path
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text()
DOC_JS = document_source()
GUARD = "if (_diffModeActive) exitDiffMode(true);"
+4 -2
View File
@@ -1,12 +1,14 @@
"""Regression guards for document-selection references in chat bubbles."""
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
DOCUMENT = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
DOCUMENT = document_source()
STYLE = app_css()
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
APP = (ROOT / "static/app.js").read_text(encoding="utf-8")
+4 -3
View File
@@ -9,11 +9,12 @@ document.js is browser-coupled and not importable in pytest.
"""
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE_CSS = app_css()
def test_document_textarea_scrollbar_is_visible():
+5 -2
View File
@@ -3,10 +3,12 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_history_buttons_start_disabled_and_follow_native_history():
@@ -24,7 +26,7 @@ def test_mobile_rich_text_history_state_and_document_switch():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&history-controls-test=1');
window.documentModuleForTest = mod;
@@ -85,6 +87,7 @@ def test_mobile_rich_text_history_state_and_document_switch():
console.log(JSON.stringify({ initial, typed, undone, redone, switched, overflow }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+5 -2
View File
@@ -3,11 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
ROOT = Path(__file__).resolve().parents[1]
SOURCE = (ROOT / "static/js/documentLibrary.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
def test_mobile_footer_exposes_delete_open_and_more():
@@ -53,7 +55,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
const state = await page.evaluate(async () => {
let currentSession = 'current-chat';
let createDirectCalls = 0;
@@ -106,6 +108,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session():
console.log(JSON.stringify(state));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+166
View File
@@ -0,0 +1,166 @@
"""The document editor's public surface, pinned against the running module.
``static/js/document.js`` is becoming a re-export wrapper. Five modules import
its default export, ``static/index.html`` loads it, and ``documentLibrary.js``
is handed named functions through its config object -- so the surface is the
contract that decomposition must not change, and a method that quietly stops
being re-exported is a runtime ``TypeError`` in whichever panel used it.
This loads the module in a browser and reads what it actually exports, rather
than grepping the source for the literal object: after extraction the object
may be assembled from imports, and a source-shape assertion would pass while
the export was broken.
"""
import json
import os
import subprocess
from pathlib import Path
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = document_source()
# Every key on the default export. Consumers reach the editor through this
# object, so removing one is a breaking change; adding one is not.
DEFAULT_EXPORT_KEYS = {
"clearAll",
"clearSelection",
"closeLibrary",
"closePanel",
"createDocument",
"ensureDocPanel",
"ensureEmailDraftEnvelope",
"ensurePaneMounted",
"enterDiffMode",
"exitDiffMode",
"findEmailDocId",
"focusEmailReplyBody",
"getActiveEmailComposerContext",
"getChatDocumentId",
"getCurrentDocId",
"getSelectionContext",
"handleDocSuggestions",
"handleDocUpdate",
"init",
"injectFreshDoc",
"isDiffModeActive",
"isLibraryOpen",
"isPanelOpen",
"loadDocument",
"loadSessionDocs",
"moveActiveDocumentToCurrentChat",
"moveActiveDocumentToNewChat",
"newDocument",
"openEmailDraft",
"openLibrary",
"openPanel",
"replaceEmailReplyBody",
"restoreSelectionReference",
"saveDocument",
"streamDocDelta",
"streamDocFinalize",
"streamDocOpen",
"swapSide",
}
# Named exports. `prepareDocumentOpen` is deliberately in this set and not on
# the default export: `documentLibrary.js` receives it through `initLibrary`'s
# config, and a browser test calls it off the module namespace.
NAMED_EXPORTS = {
"clearAll",
"closePanel",
"createDocument",
"ensureDocPanel",
"ensureEmailDraftEnvelope",
"findEmailDocId",
"focusEmailReplyBody",
"getActiveEmailComposerContext",
"getChatDocumentId",
"getCurrentDocId",
"getSelectionContext",
"handleDocSuggestions",
"handleDocUpdate",
"init",
"injectFreshDoc",
"isPanelOpen",
"loadDocument",
"loadSessionDocs",
"newDocument",
"openEmailDraft",
"openPanel",
"prepareDocumentOpen",
"replaceEmailReplyBody",
"restoreSelectionReference",
"saveDocument",
"streamDocDelta",
"streamDocFinalize",
"streamDocOpen",
"swapSide",
}
def _module_surface():
script = r"""
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
const surface = await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=module-api-surface-1');
const fn = (o) => Object.keys(o).filter(k => typeof o[k] === 'function');
return {
named: Object.keys(mod).filter(k => k !== 'default'),
namedFunctions: fn(mod).filter(k => k !== 'default'),
defaultKeys: Object.keys(mod.default),
defaultFunctions: fn(mod.default),
globalIsSameObject: window.documentModule === mod.default,
};
});
console.log(JSON.stringify(surface));
await browser.close();
"""
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
env=os.environ.copy(),
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_default_export_surface_is_complete_and_callable():
surface = _module_surface()
missing = DEFAULT_EXPORT_KEYS - set(surface["defaultKeys"])
assert not missing, f"default export lost methods: {sorted(missing)}"
not_callable = DEFAULT_EXPORT_KEYS - set(surface["defaultFunctions"])
assert not not_callable, (
f"default export keys that are not functions: {sorted(not_callable)}"
)
def test_named_exports_survive_and_stay_callable():
surface = _module_surface()
missing = NAMED_EXPORTS - set(surface["named"])
assert not missing, f"named exports lost: {sorted(missing)}"
not_callable = NAMED_EXPORTS - set(surface["namedFunctions"])
assert not not_callable, f"named exports that are not functions: {sorted(not_callable)}"
def test_window_bridge_is_the_default_export():
"""`window.documentModule` is a compatibility bridge no import graph shows.
Consumers reach the editor off the global, so it must stay the same object
as the default export rather than a second, partially wired copy.
"""
assert "window.documentModule = documentModule" in DOC_JS
assert _module_surface()["globalIsSameObject"] is True
+8 -6
View File
@@ -3,11 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
def _run_node(script: str):
@@ -41,7 +43,7 @@ def test_markdown_outline_parses_structure_and_ignores_fenced_code():
'#### Final `code` section',
].join('\n');
console.log(JSON.stringify(parseMarkdownOutline(source)));
"""
""".replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
)
assert [(entry["level"], entry["text"]) for entry in data] == [
(1, "Overview"),
@@ -71,7 +73,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&outline-test=1');
mod.init('/api');
@@ -126,7 +128,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile():
console.log(JSON.stringify({ markdownLabels, selected, liveLabels, mobileBox, richLabels, richCaretHeading }));
await browser.close();
"""
""".replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
)
assert data["markdownLabels"] == ["Intro", "Details", "End"]
assert data["selected"] == "## Details"
+4 -3
View File
@@ -1,11 +1,12 @@
"""Regression guards for the Markdown preview hover-to-edit control."""
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE_CSS = app_css()
def test_preview_installs_hover_edit_button():
+5 -2
View File
@@ -3,10 +3,12 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_checklist_enter_uses_native_edit_commands_and_resets_state():
@@ -26,7 +28,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&checklist-enter-test=1');
mod.init('/api');
@@ -78,6 +80,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly():
console.log(JSON.stringify({ afterFirstEnter, ...data }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,11 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
def test_color_controls_have_theme_reset_and_split_palettes():
@@ -20,6 +22,12 @@ def test_color_controls_have_theme_reset_and_split_palettes():
def test_rich_colors_follow_theme_and_undo_as_one_edit():
# Two input conventions in here are platform-sensitive and must stay that
# way. Palette entries are opened with a plain click: on macOS a
# Control+click is delivered as `contextmenu`, so the menu item's `click`
# handler never runs and nothing is applied. Undo uses Playwright's
# `ControlOrMeta` alias because the editor's undo accelerator is Cmd+Z on
# macOS and Ctrl+Z everywhere else.
script = r"""
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
@@ -27,7 +35,7 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit():
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent(`<style>
:root { --fg:#d8dee9; --bg:#17191f; --panel:#20232b; --border:#444; --red:#e45b6c; --accent-primary:#e45b6c; }
</style><link rel="stylesheet" href="/static/style.css?rich-color-test=1">
</style>__ODY_STYLESHEETS__
<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>`);
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?rich-color-test=' + Date.now());
@@ -58,28 +66,29 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit():
labels: [...document.querySelectorAll('.rich-color-palette-label')].map(item => item.textContent),
reset: document.querySelector('.rich-color-reset')?.textContent.trim(),
}));
await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Lemon' }).click({ modifiers: ['Control'] });
await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Lemon' }).click();
const highlighted = await page.locator('#doc-email-richbody p').nth(0).locator('span').evaluate(span => ({
color: getComputedStyle(span).color,
background: getComputedStyle(span).backgroundColor,
}));
await page.locator('#doc-email-richbody').press('Control+z');
await page.locator('#doc-email-richbody').press('ControlOrMeta+z');
const highlightUndone = await page.locator('#doc-email-richbody p').nth(0).innerHTML();
await selectParagraph(1);
await openMenu('color');
await page.locator('.rich-color-reset').click({ modifiers: ['Control'] });
await page.locator('.rich-color-reset').click();
const defaultColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => ({
style: span.getAttribute('style'),
color: getComputedStyle(span).color,
}));
await page.evaluate(() => document.documentElement.style.setProperty('--fg', '#88cc44'));
const changedThemeColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => getComputedStyle(span).color);
await page.locator('#doc-email-richbody').press('Control+z');
await page.locator('#doc-email-richbody').press('ControlOrMeta+z');
const colorUndone = await page.locator('#doc-email-richbody p').nth(1).innerHTML();
console.log(JSON.stringify({ palette, highlighted, highlightUndone, defaultColor, changedThemeColor, colorUndone }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+7 -3
View File
@@ -5,10 +5,12 @@ import subprocess
import tempfile
import zipfile
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_rich_docx_converter_maps_editor_structure_instead_of_raw_html():
@@ -64,7 +66,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml():
contentType: 'image/png',
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'),
}}));
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {{
const mod = await import('/static/js/document.js?v=20260831richtexttools91&docx-export-test=1');
mod.init('/api');
@@ -89,6 +91,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml():
}}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -158,7 +161,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting():
acceptDownloads: true,
}});
await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {{
const mod = await import('/static/js/document.js?v=20260831richtexttools91&markdown-docx-export-test=1');
mod.init('/api');
@@ -180,6 +183,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting():
console.log(JSON.stringify({{ failure: await download.failure() }}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+6 -5
View File
@@ -3,16 +3,16 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_find_index_inserts_boundaries_without_flattening_inline_spans():
section = DOC_JS.split("function _buildRichFindRanges", 1)[1].split(
"function _renderRichFindRanges", 1
)[0]
section = function_body("_buildRichFindRanges")
assert "const blockSelector = 'p,div,h1,h2,h3,h4,h5,h6,li,blockquote,pre,td,th'" in section
assert "block !== previousBlock" in section
assert "between.cloneContents().querySelector?.('br')" in section
@@ -25,7 +25,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&find-boundaries-test=1');
mod.init('/api');
@@ -73,6 +73,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac
console.log(JSON.stringify({ crossParagraph, crossBreak, crossInline, ...data }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -6,10 +6,12 @@ import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
@@ -47,8 +49,8 @@ def test_horizontal_rule_is_ordered_after_clear_formatting():
def test_image_options_are_hidden_until_a_rich_image_is_selected():
clear_fn = DOC_JS.split("function _clearRichImageSelection()", 1)[1].split("function _selectRichImage", 1)[0]
select_fn = DOC_JS.split("function _selectRichImage", 1)[1].split("function _selectedRichImage", 1)[0]
clear_fn = function_body("_clearRichImageSelection")
select_fn = function_body("_selectRichImage")
assert "imageButton.style.display = 'none';" in clear_fn
assert "imageButton.style.display = '';" in select_fn
@@ -94,7 +96,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile():
async function exercise(viewport, suffix) {
const page = await browser.newPage({ viewport });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async suffix => {
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&font-color=${suffix}`);
mod.init('/api');
@@ -190,6 +192,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile():
console.log(JSON.stringify({ desktop, mobile }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+7 -5
View File
@@ -3,16 +3,16 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_heading_enter_uses_single_native_history_commands():
helper = DOC_JS.split("function _handleRichHeadingEnter", 1)[1].split(
"let _richInlineCodeTypingArmed", 1
)[0]
helper = function_body("_handleRichHeadingEnter")
assert "selection.isCollapsed" in helper
assert "h1, h2, h3, h4, h5, h6" in helper
@@ -29,7 +29,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&heading-enter=1');
mod.init('/api');
@@ -73,6 +73,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable():
console.log(JSON.stringify({ entered, undone, redone }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -152,6 +153,7 @@ def test_heading_enter_preserves_shift_middle_and_empty_heading_semantics():
console.log(JSON.stringify(state));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+7 -8
View File
@@ -5,20 +5,18 @@ import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
def test_image_caption_uses_semantic_figure_and_structured_export_paths():
caption = DOC_JS.split("async function _editRichImageCaption", 1)[1].split(
"function _applyRichImageAction", 1
)[0]
converter = DOC_JS.split("function _docxFigureBlocks", 1)[1].split(
"function _docxBlocksFromNodes", 1
)[0]
caption = function_body("_editRichImageCaption")
converter = function_body("_docxFigureBlocks")
assert "function _promptImageCaption" in DOC_JS
assert "function _replaceRichImageFigure" in DOC_JS
@@ -36,7 +34,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&image-caption=1');
mod.init('/api');
@@ -116,6 +114,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal():
console.log(JSON.stringify({ added, undone, redone, resized, removed, removalUndone, menuRect, overflow }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+5 -2
View File
@@ -5,10 +5,12 @@ import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
@@ -28,7 +30,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&input-rules-test=1');
mod.init('/api');
@@ -101,6 +103,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text():
console.log(JSON.stringify(data));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
ROOT = Path(__file__).resolve().parents[1]
@@ -16,7 +17,7 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths():
async function exercise(viewport, suffix) {
const page = await browser.newPage({ viewport });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async suffix => {
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&keyboard-shortcuts=${suffix}`);
mod.init('/api');
@@ -94,6 +95,7 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths():
console.log(JSON.stringify({ desktop, mobile }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,11 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
def test_selection_toolbar_uses_shared_formatting_path_and_preserves_range():
@@ -26,7 +28,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&selection-toolbar-test=1');
mod.init('/api');
@@ -102,6 +104,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports
console.log(JSON.stringify({ activeBold, desktopGeometry, formattedHtml, selectedAfterFormat, dismissed, mobileGeometry }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+7 -4
View File
@@ -3,11 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
def test_slash_menu_reuses_rich_text_actions_and_is_accessible():
@@ -41,7 +43,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&slash-menu-test=1');
mod.init('/api');
@@ -149,6 +151,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
console.log(JSON.stringify({ filtered, heading, noHeadingQuery, tableFiltered, table, noTableQuery, mobile, homeOptions, homeLabel, escaped, slashRemains, cleanedAria }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+3 -1
View File
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
ROOT = Path(__file__).resolve().parents[1]
@@ -14,7 +15,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&smart-link-paste=1');
mod.init('/api');
@@ -133,6 +134,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks
console.log(JSON.stringify({ selectedLink, storedAfterPaste, afterUndo, afterRedoLinks, caretLink, plainResults, emailLink, crossBlock }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+10 -13
View File
@@ -3,26 +3,22 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import declaration, document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
def test_heading_levels_and_page_break_are_exposed_everywhere():
slash = DOC_JS.split("const _RICH_SLASH_COMMANDS", 1)[1].split(
"const _RICH_BLOCK_INPUT_RULES", 1
)[0]
rules = DOC_JS.split("const _RICH_BLOCK_INPUT_RULES", 1)[1].split(
"function _applyRichBlockInputRule", 1
)[0]
slash = declaration("_RICH_SLASH_COMMANDS")
rules = declaration("_RICH_BLOCK_INPUT_RULES")
dropdown = DOC_JS.split("function _showMdDropdown", 1)[1].split(
"function initMdToolbar", 1
)[0]
exporter = DOC_JS.split("function _richTextExportCss", 1)[1].split(
"function exportAsHtml", 1
)[0]
exporter = function_body("_richTextExportCss")
for level in (5, 6):
assert f"action: 'h{level}'" in slash
@@ -44,7 +40,7 @@ def test_mobile_headings_page_break_history_and_persistence():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&structure-tools-test=1');
window.__structureToolsDocModule = mod;
@@ -152,6 +148,7 @@ def test_mobile_headings_page_break_history_and_persistence():
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,19 +3,17 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_table_cell_alignment_uses_contextual_undoable_table_path():
actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
"function _insertRichPageBreak", 1
)[0]
state = DOC_JS.split("function _richDropdownCurrentActions", 1)[1].split(
"function _showMdDropdown", 1
)[0]
actions = function_body("_applyRichTableAction")
state = function_body("_richDropdownCurrentActions")
for alignment in ("top", "middle", "bottom"):
assert f"table:cell-align:{alignment}" in DOC_JS
@@ -31,7 +29,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-cell-alignment=1');
mod.init('/api');
@@ -122,6 +120,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history():
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,19 +3,19 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_table_structure_uses_central_header_mode_normalization():
helpers = DOC_JS.split("function _richTableHeaderModes", 1)[1].split(
"function _replaceRichTable", 1
)[0]
append = DOC_JS.split("function _appendRichTableRow", 1)[1].split(
"function _insertRichTable", 1
)[0]
append = function_body("_appendRichTableRow")
actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
"function applyMdFormat", 1
)[0]
@@ -36,7 +36,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-preservation-test=1');
mod.init('/api');
@@ -119,6 +119,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history():
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+7 -8
View File
@@ -3,19 +3,19 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_table_header_controls_use_tag_replacement_and_native_history():
table_actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
"function applyMdFormat", 1
)[0]
menu_state = DOC_JS.split("function _richDropdownCurrentActions", 1)[1].split(
"function _showMdDropdown", 1
)[0]
menu_state = function_body("_richDropdownCurrentActions")
assert "function _replaceRichTableCellTag" in DOC_JS
assert "table:toggle-header-row" in table_actions
@@ -24,9 +24,7 @@ def test_table_header_controls_use_tag_replacement_and_native_history():
assert "headerModes.headerRow = !headerModes.headerRow" in table_actions
assert "headerModes.headerColumn = !headerModes.headerColumn" in table_actions
assert "_applyRichTableHeaderModes(clone, headerModes)" in table_actions
header_normalizer = DOC_JS.split("function _applyRichTableHeaderModes", 1)[1].split(
"function _replaceRichTable", 1
)[0]
header_normalizer = function_body("_applyRichTableHeaderModes")
assert "_replaceRichTableCellTag" in header_normalizer
assert "_replaceRichTable(rich, original, clone" in table_actions
assert "current.add('table:toggle-header-row')" in menu_state
@@ -40,7 +38,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-test=1');
mod.init('/api');
@@ -117,6 +115,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo():
console.log(JSON.stringify({ initial, menuRect, columnOn, undone, redone, bothOn, rowOff, overflow }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,16 +3,16 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_merge_split_commands_use_contextual_undoable_table_path():
actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
"function _insertRichPageBreak", 1
)[0]
actions = function_body("_applyRichTableAction")
menu = DOC_JS.split("function _showMdDropdown", 1)[1].split(
"function initMdToolbar", 1
)[0]
@@ -34,7 +34,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history(
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-merge-split=1');
mod.init('/api');
@@ -138,6 +138,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history(
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -3,16 +3,16 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_last_cell_tab_uses_the_undoable_table_replacement_path():
helper = DOC_JS.split("function _appendRichTableRow", 1)[1].split(
"function _insertRichTable", 1
)[0]
helper = function_body("_appendRichTableRow")
key_handler = DOC_JS.split("if (e.key === 'Tab')", 1)[1].split(
"let inList = false", 1
)[0]
@@ -31,7 +31,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-tab-history-test=1');
mod.init('/api');
@@ -98,6 +98,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history():
console.log(JSON.stringify({ forward, backward, appended, undone, redone, overflow }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+13 -34
View File
@@ -3,10 +3,11 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source, function_body
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
@@ -84,9 +85,7 @@ def test_rich_text_commands_sync_and_schedule_save():
def test_rich_text_paste_uses_document_allowlist_and_drops_embedded_media():
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
"function _wireEmailRichbody", 1
)[0]
paste_cleaner = function_body("_cleanRichTextPasteHtml")
assert "const allowedTags = new Set" in paste_cleaner
assert "'TABLE'" in paste_cleaner
@@ -154,9 +153,7 @@ def test_table_mutations_are_undoable_and_restore_the_caret():
def test_temporary_table_tokens_are_not_persisted():
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
"function _richTextContentToHtml", 1
)[0]
sanitizer = function_body("_sanitizedRichTextHtml")
assert "data-editor-(?:table|checklist|image|inline-code|link)-token" in sanitizer
assert ".replace(" in sanitizer
@@ -176,9 +173,7 @@ def test_rich_text_checklists_support_conversion_and_checked_state():
def test_checklist_interactions_cover_pointer_and_keyboard_users():
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
"function _emailRichbodyActive", 1
)[0]
rich_wiring = function_body("_wireEmailRichbody")
assert "rich.addEventListener('pointerdown'" in rich_wiring
assert "mod && key === 'enter'" in rich_wiring
@@ -186,9 +181,7 @@ def test_checklist_interactions_cover_pointer_and_keyboard_users():
def test_checklist_markup_survives_paste_save_and_export():
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
"function _wireEmailRichbody", 1
)[0]
paste_cleaner = function_body("_cleanRichTextPasteHtml")
assert "keepChecklistClass" in paste_cleaner
assert "keepChecklistState" in paste_cleaner
@@ -239,9 +232,7 @@ def test_rich_text_image_insertion_and_edits_are_undoable():
def test_rich_text_image_selection_markers_are_not_persisted():
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
"function _richTextContentToHtml", 1
)[0]
sanitizer = function_body("_sanitizedRichTextHtml")
assert "(?:table|checklist|image|inline-code|link)-token" in sanitizer
assert "data-editor-image-selected" in sanitizer
@@ -258,9 +249,7 @@ def test_rich_text_image_styles_are_available_in_editor_and_export():
def test_existing_figure_wrapped_images_are_normalized_on_load():
normalizer = DOC_JS.split("function _normalizeRichTextImages", 1)[1].split(
"function _clearRichImageSelection", 1
)[0]
normalizer = function_body("_normalizeRichTextImages")
assert "figure.richtext-image" in normalizer
assert "image.classList.add('richtext-image')" in normalizer
@@ -293,9 +282,7 @@ def test_inline_code_supports_selection_toggle_and_future_typing():
def test_inline_code_live_marker_is_saved_as_semantic_code():
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
"function _richTextContentToHtml", 1
)[0]
sanitizer = function_body("_sanitizedRichTextHtml")
assert "_isRichInlineCodeMarker(span)" in sanitizer
assert "document.createElement('code')" in sanitizer
@@ -303,9 +290,7 @@ def test_inline_code_live_marker_is_saved_as_semantic_code():
def test_rich_code_shortcuts_and_active_state_are_wired():
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
"function _emailRichbodyActive", 1
)[0]
rich_wiring = function_body("_wireEmailRichbody")
assert "action = 'codeblock'" in rich_wiring
assert "action = 'code'" in rich_wiring
@@ -339,12 +324,8 @@ def test_rich_links_validate_protocols_during_paste_save_and_editing():
normalizer = DOC_JS.split("function _normalizeRichLinkUrl", 1)[1].split(
"function _promptLink", 1
)[0]
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
"function _richTextContentToHtml", 1
)[0]
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
"function _wireEmailRichbody", 1
)[0]
sanitizer = function_body("_sanitizedRichTextHtml")
paste_cleaner = function_body("_cleanRichTextPasteHtml")
assert "https?:|mailto:|tel:" in normalizer
assert "https:${url}" in normalizer
@@ -370,9 +351,7 @@ def test_rich_link_edits_and_removal_use_native_undoable_commands():
def test_link_toolbar_toggles_link_when_selection_is_already_linked():
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
"function _emailRichbodyActive", 1
)[0]
rich_wiring = function_body("_wireEmailRichbody")
assert "const currentLink = _richLinkAtRange(rich, selectionRange)" in rich_wiring
assert "set('[data-md=\"link\"]', !!currentLink)" in rich_wiring
+11 -6
View File
@@ -3,11 +3,13 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
def test_rich_toolbar_menus_expose_keyboard_and_context_state():
@@ -58,7 +60,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toggle-test=1');
mod.init('/api');
@@ -101,6 +103,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens():
console.log(JSON.stringify({ opened, closed, sliderOpened, sliderClosed, pickerOpened, colorClosed, before, scroll }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -127,7 +130,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-menu-test=1');
mod.init('/api');
@@ -204,6 +207,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus():
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
@@ -242,7 +246,7 @@ def test_rich_toolbar_menus_track_live_formatting_values():
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-state-test=1');
mod.init('/api');
@@ -322,6 +326,7 @@ def test_rich_toolbar_menus_track_live_formatting_values():
}));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+2 -1
View File
@@ -1,8 +1,9 @@
from pathlib import Path
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOCUMENT_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOCUMENT_JS = document_source()
def test_format_change_clears_stale_run_output() -> None:
+5 -2
View File
@@ -3,10 +3,12 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_save_shortcut_uses_manual_version_path_and_cancels_autosave():
@@ -38,7 +40,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status():
});
});
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-shortcut-test=1');
mod.init('/api');
@@ -71,6 +73,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status():
console.log(JSON.stringify({ dirty, saved, badge, requests }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+3 -1
View File
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
ROOT = Path(__file__).resolve().parents[1]
@@ -32,7 +33,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures():
active -= 1;
});
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-status-test=1');
mod.init('/api');
@@ -81,6 +82,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures():
console.log(JSON.stringify({ initial, dirty, duringSave, afterSave, afterFailure, bodies, maxActive, footer, mobile }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+200
View File
@@ -0,0 +1,200 @@
"""Tests must reason about the document editor's module set, not one file.
``static/js/document.js`` is being decomposed. It stays the URL the browser
requests, so a browser test that imports ``/static/js/document.js`` keeps
working. What does not survive is reading the file off disk: a test that greps
the entry file alone silently covers less as soon as the behaviour it names
moves into a module, and it keeps passing while doing so.
``tests/helpers/document_source`` is the way to read it. This fails on the two
habits that break, both of which existed here before the helper did.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SELF = Path(__file__).name
# The helper itself names the file, because being the one place that does is
# the point.
ALLOWED = {SELF, "document_source.py"}
# Every test language the assertions can hide in. A Python-only glob is what
# let the JS references to ``static/style.css`` outlive the file they named.
_SUFFIXES = ("*.py", "*.mjs", "*.js", "*.html")
# Reading the entry file off disk. Not matched: `/static/js/document.js` as a
# request URL or a dynamic `import()`, which stay correct through the wrapper,
# and which `test_frontend_module_version_parity.py` already pins.
_DIRECT_READ = re.compile(
r'["\']static/js/document\.js["\']' # string literal
r'|"static"\s*/\s*"js"\s*/\s*"document\.js"' # pathlib join
)
# Slices of the form `src.split(A, 1)[1].split(B, 1)[0]` -- "the region between
# A and B". That region is only what the test means while A and B are
# neighbours in one file. Several also hard-code the entry file's two-space
# indentation, which no extracted module reproduces.
_ADJACENCY_SLICE = re.compile(
r'(?P<var>\b[A-Za-z_]\w*)\.split\(\s*\n?\s*(?P<q1>["\'])(?P<a>(?:[^"\'\\]|\\.)+?)(?P=q1)'
r'\s*,\s*1\s*\)\[1\]\s*\n?\s*\.split\(\s*\n?\s*(?P<q2>["\'])(?P<b>(?:[^"\'\\]|\\.)+?)(?P=q2)'
r'\s*,\s*1\s*\)\[0\]',
re.S,
)
# The adjacency slices still in the tree, each spanning a whole family of
# functions rather than one construct -- "everything from _docxHexColor to
# exportAsDocx". Collapsing one to its first member drops what the assertions
# look for, so they cannot be rewritten mechanically: each is converted when
# the family it spans becomes a module, and its entry deleted here then.
#
# This list may only shrink. A new entry means a new adjacency-dependent slice
# was written, which is the habit the helper exists to end.
KNOWN_ADJACENCY_SLICES = {
('test_document_active_restore.py',
'for (const doc of activeDocs)',
'_syncDocIndicator'),
('test_document_edit_reference_js.py',
'function clearSelection() {',
'\\n }'),
('test_document_rich_checklist_enter.py',
'function _handleRichChecklistEnter',
'let _richInlineCodeTypingArmed'),
('test_document_rich_docx_export.py',
'async function exportAsDocx',
'/** Delete the active document'),
('test_document_rich_docx_export.py',
'function _docxHexColor',
'async function exportAsDocx'),
('test_document_rich_structure_tools.py',
'function _showMdDropdown',
'function initMdToolbar'),
('test_document_rich_table_header_preservation.py',
'function _applyRichTableAction',
'function applyMdFormat'),
('test_document_rich_table_header_preservation.py',
'function _richTableHeaderModes',
'function _replaceRichTable'),
('test_document_rich_table_headers.py',
'function _applyRichTableAction',
'function applyMdFormat'),
('test_document_rich_table_merge_split.py',
'function _showMdDropdown',
'function initMdToolbar'),
('test_document_rich_text_tools.py',
'// ---- Selection-based AI editing ----',
'// ── Inline Suggestion Comments'),
('test_document_rich_text_tools.py',
'// Undo button in header',
'// Diff toggle button'),
('test_document_rich_text_tools.py',
'// ── In-document find (Ctrl+F) ──',
'// Delete (or Backspace)'),
('test_document_rich_text_tools.py',
'const _richSpacingBlockSelector',
'function _focusRichTextOffset'),
('test_document_rich_text_tools.py',
'function _insertRichTextImages',
'async function _uploadMarkdownImages'),
('test_document_rich_text_tools.py',
'function _normalizeRichLinkUrl',
'function _promptImageAlt'),
('test_document_rich_text_tools.py',
'function _normalizeRichLinkUrl',
'function _promptLink'),
('test_document_rich_text_tools.py',
'function _replaceAllLiteral',
'function _doFind'),
('test_document_rich_text_tools.py',
'function _replaceRichTable',
'function applyMdFormat'),
('test_document_rich_text_tools.py',
'function _richLinkAtRange',
'function _richSelectionCell'),
('test_document_rich_text_tools.py',
'function _richSelectionChecklistItem',
'function _cleanRichTextPasteHtml'),
('test_document_rich_text_tools.py',
'function _richSelectionInlineCode',
'function _cleanRichTextPasteHtml'),
('test_document_rich_text_tools.py',
'function _showMdDropdown',
'function initMdToolbar'),
('test_document_rich_text_tools.py',
'function _wireEmailRichbody',
'function _richSelectionElement'),
('test_document_rich_toolbar_menus.py',
'function _showMdDropdown',
'function initMdToolbar'),
('test_document_rich_toolbar_menus.py',
'function initMdToolbar',
'function _applyDocFont'),
('test_document_toolbar_order.py',
'const _DOCUMENT_TOOLBAR_GROUPS',
'function _orderDocumentToolbar'),
('test_review_docx_async_identity.py',
' let _docxPreviewRequest = 0;',
' /** Parse CSV'),
}
def _test_sources() -> list[Path]:
found: list[Path] = []
for suffix in _SUFFIXES:
found.extend((ROOT / "tests").rglob(suffix))
return [p for p in sorted(set(found)) if p.name not in ALLOWED]
def test_sources_are_discoverable() -> None:
"""Guard the guard: a layout change must not make this vacuous."""
sources = _test_sources()
assert len(sources) > 100
suffixes = {p.suffix for p in sources}
assert {".py", ".mjs", ".js"} <= suffixes, suffixes
assert any(p.parent != ROOT / "tests" for p in sources), "walk is not recursive"
def test_no_test_reads_the_document_entry_file_off_disk() -> None:
offenders = [
str(p.relative_to(ROOT))
for p in _test_sources()
if _DIRECT_READ.search(p.read_text(encoding="utf-8"))
]
assert not offenders, (
"read the document editor through tests.helpers.document_source instead "
"of static/js/document.js, which is becoming a re-export wrapper: "
f"{offenders}"
)
def test_adjacency_slices_only_shrink() -> None:
"""No new "region between two declarations" slice enters the tree."""
found = set()
for path in _test_sources():
if path.suffix != ".py":
continue
source = path.read_text(encoding="utf-8")
if "tests.helpers.document_source" not in source:
continue
bound = {
m.group(1)
for m in re.finditer(r"(\w+)\s*=\s*document_source\(\)", source)
}
for m in _ADJACENCY_SLICE.finditer(source):
if m.group("var") in bound:
found.add((path.name, m.group("a"), m.group("b")))
added = found - KNOWN_ADJACENCY_SLICES
assert not added, (
"these slices depend on two declarations being neighbours in one file, "
"which decomposition breaks; use function_body()/declaration() or "
"assert against the owning module: " + repr(sorted(added))
)
removed = KNOWN_ADJACENCY_SLICES - found
assert not removed, (
"these adjacency slices are gone -- delete them from "
f"KNOWN_ADJACENCY_SLICES so the list keeps shrinking: {sorted(removed)}"
)
+2 -1
View File
@@ -5,10 +5,11 @@ import subprocess
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE = app_css()
+4 -3
View File
@@ -1,11 +1,12 @@
"""Regression guards for exact inline document-suggestion highlighting."""
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
DOC_JS = document_source()
STYLE_CSS = app_css()
def test_suggestion_highlight_measures_exact_referenced_range():
@@ -1,7 +1,8 @@
from pathlib import Path
from tests.helpers.document_source import document_source
SOURCE = Path("static/js/document.js").read_text()
SOURCE = document_source()
def test_newly_mounted_document_pane_cannot_save_before_binding():
+4 -2
View File
@@ -1,12 +1,14 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
def test_toolbar_arrows_have_real_flex_slots_outside_icon_scroller():
script = (ROOT / "static/js/document.js").read_text()
styles = (ROOT / "static/style.css").read_text()
script = document_source()
styles = app_css()
leading = script.index('class="md-toolbar-leading-controls"')
left_arrow = script.index('id="md-scroll-left"')
+5 -2
View File
@@ -3,10 +3,12 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
from tests.helpers.document_source import document_source
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
DOC_JS = document_source()
def test_toolbar_groups_define_writing_actions_before_view_controls():
@@ -38,7 +40,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
async function inspect(viewport, suffix) {
const page = await browser.newPage({ viewport });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css?v=20260831richtexttools91"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async suffix => {
const mod = await import(`/static/js/document.js?v=20260831richtexttools91&toolbar-order=${suffix}`);
mod.init('/api');
@@ -79,6 +81,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
console.log(JSON.stringify({ desktop, mobile }));
await browser.close();
"""
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
+2 -1
View File
@@ -1,4 +1,5 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
@@ -49,7 +50,7 @@ def test_group_rows_support_keyboard_selection():
def test_keyboard_focus_is_visible_for_layer_and_group_rows():
styles = (ROOT / "static/style.css").read_text()
styles = app_css()
assert ".ge-layer-item:focus-visible," in styles
assert ".ge-layer-group-row:focus-visible" in styles
+2 -1
View File
@@ -1,9 +1,10 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
def test_quick_edit_has_persistent_expanded_identity_and_labeled_input():
+2 -1
View File
@@ -1,11 +1,12 @@
from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
TOPBAR = (ROOT / "static/js/editor/build/topbar.js").read_text(encoding="utf-8")
OVERFLOW = (ROOT / "static/js/editor/wire-topbar-overflow.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
STYLE = app_css()
def test_primary_editor_text_actions_use_stacked_toolbar_contract():
@@ -1,11 +1,13 @@
from pathlib import Path
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parent.parent
def test_email_ai_reply_context_is_saved_and_restored_per_message():
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert "_AI_REPLY_CONTEXT_DRAFT_PREFIX" in source
assert "data?.account_id || em?.account_id || state._libAccountId" in source
@@ -17,7 +19,7 @@ def test_email_ai_reply_context_is_saved_and_restored_per_message():
def test_email_ai_reply_context_only_clears_after_draft_opens():
library = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
library = email_library_source()
inbox = (ROOT / "static/js/emailInbox.js").read_text(encoding="utf-8")
assert "const draftOpened = await _runAiReplyFromButton" in library
@@ -27,7 +29,7 @@ def test_email_ai_reply_context_only_clears_after_draft_opens():
def test_document_ai_reply_does_not_overwrite_an_edited_draft():
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
source = document_source()
start = source.index(" async function _aiReply(")
end = source.index(" async function _scheduleSend(", start)
function = source[start:end]
@@ -46,7 +48,7 @@ def test_document_ai_reply_does_not_overwrite_an_edited_draft():
def test_replacing_an_email_reply_checks_the_visible_draft_first():
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
source = document_source()
start = source.index(" export async function replaceEmailReplyBody(")
end = source.index(" export async function ensureEmailDraftEnvelope(", start)
function = source[start:end]
+10 -8
View File
@@ -1,5 +1,7 @@
import sqlite3
from email.message import EmailMessage
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_source
def test_attachment_filename_is_part_of_ui_index_search(tmp_path, monkeypatch):
@@ -80,7 +82,7 @@ def test_remote_search_explicitly_checks_mime_filename_headers():
def test_forwarding_filters_signature_assets_and_mobile_export_stops_bubbling():
inbox = open("static/js/emailInbox.js", encoding="utf-8").read()
document = open("static/js/document.js", encoding="utf-8").read()
document = document_source()
assert "const forwardedAttachments = mode === 'forward'" in inbox
assert "forwardedAttachments.map" in inbox
@@ -89,7 +91,7 @@ def test_forwarding_filters_signature_assets_and_mobile_export_stops_bubbling():
def test_attachment_open_spins_icon_only():
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
library = email_library_source()
start = library.index("reader.querySelectorAll('.email-attachment-open')")
end = library.index("reader.querySelectorAll('.email-attachment-download')", start)
handler = library[start:end]
@@ -99,7 +101,7 @@ def test_attachment_open_spins_icon_only():
def test_move_document_creates_destination_before_adopting_it():
document = open("static/js/document.js", encoding="utf-8").read()
document = document_source()
start = document.index("async function moveActiveDocumentToNewChat()")
end = document.index("\n function showDocTabMenu", start)
handler = document[start:end]
@@ -109,7 +111,7 @@ def test_move_document_creates_destination_before_adopting_it():
def test_deferred_attachment_check_shows_feedback_and_repairs_stale_card_icon():
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
library = email_library_source()
start = library.index("function _loadDeferredAttachmentsIntoReader")
end = library.index('\n// "Open in new tab"', start)
loader = library[start:end]
@@ -159,7 +161,7 @@ def test_attachment_cache_backfill_preserves_message_id(tmp_path, monkeypatch):
def test_single_email_tag_has_no_more_control():
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
library = email_library_source()
group = library[library.index("function _emailTagGroupHtml("):library.index("function _fitEmailCardTags(")]
assert "if (visible.length === 1) return visible[0];" in group
assert "if (visible.length === 2) return visible.join('');" in group
@@ -167,7 +169,7 @@ def test_single_email_tag_has_no_more_control():
def test_email_folder_and_filter_pickers_treat_their_buttons_as_inside_clicks():
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
library = email_library_source()
assert library.count(
"bindMenuDismiss(menu, finishClose, e => !picker.contains(e.target))"
@@ -177,7 +179,7 @@ def test_email_folder_and_filter_pickers_treat_their_buttons_as_inside_clicks():
def test_empty_reply_has_two_editable_rows_and_reply_survives_compact_toolbar():
inbox = open("static/js/emailInbox.js", encoding="utf-8").read()
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
library = email_library_source()
assert "<p><br></p><p><br></p>\\n" in inbox
fit_start = library.index("function _fitReaderActions")
@@ -188,7 +190,7 @@ def test_empty_reply_has_two_editable_rows_and_reply_survives_compact_toolbar():
def test_email_toolbar_places_attachment_before_link():
document = open("static/js/document.js", encoding="utf-8").read()
document = document_source()
toolbar_start = document.index('<div class="md-toolbar-items"')
toolbar_end = document.index('</div>', toolbar_start)
toolbar = document[toolbar_start:toolbar_end]
+4 -2
View File
@@ -1,12 +1,14 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_source
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()
source = email_library_source()
css = app_css()
assert 'class="email-meta-date-group"' in source
group_markup = source[source.index('class="email-meta-date-group"'):][:180]
+10 -19
View File
@@ -1,28 +1,20 @@
from pathlib import Path
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_source, js_function_source
_REPO = Path(__file__).resolve().parents[1]
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
_EMAIL_ROUTES = _REPO / "routes" / "email_routes.py"
_EMAIL_ROUTES = _REPO / "routes" / "email" / "email_routes.py"
_EMAIL_MCP_SERVER = _REPO / "mcp_servers" / "email_server.py"
_EMAIL_FIXTURE_HELPER = _REPO / "scripts" / "ody_eval_email_fixture.py"
def _bulk_action_source() -> str:
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
start = text.index("async function _bulkAction(action)")
end = text.index("\n}\n\n// _extractName", start) + 3
return text[start:end]
return js_function_source("_bulkAction")
def _function_source(name: str) -> str:
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
start = text.index(f"function {name}")
next_function = text.find("\nfunction ", start + 1)
next_async = text.find("\nasync function ", start + 1)
candidates = [idx for idx in (next_function, next_async) if idx != -1]
end = min(candidates) if candidates else len(text)
return text[start:end]
return js_function_source(name)
def test_email_bulk_read_unread_calls_provider_write_routes():
@@ -50,7 +42,7 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
def test_email_bulk_export_attachments_is_ui_only_selected_context():
frontend = _EMAIL_LIBRARY.read_text(encoding="utf-8")
frontend = email_library_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
export_src = frontend[
frontend.index("async function _exportSelectedAttachments()"):
@@ -86,7 +78,7 @@ def test_email_context_changes_clear_bulk_selection_state():
Folder, account, filter, quick-filter, attachment, and search basis changes
must exit select mode before the next list/search view can run bulk actions.
"""
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
text = email_library_source()
reset_src = _function_source("_resetBulkSelectionForContextChange")
fresh_src = _function_source("_resetEmailListForFreshLoad")
add_pill_src = _function_source("_addSearchPill")
@@ -118,7 +110,7 @@ def test_email_refresh_uses_explicit_server_refresh_contract():
refresh button should keep the old rows visible while asking the server to
evict those fast paths and refetch the visible mailbox slice.
"""
frontend = _EMAIL_LIBRARY.read_text(encoding="utf-8")
frontend = email_library_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "refresh=1&_=${Date.now()}" in frontend
@@ -150,7 +142,7 @@ def test_fixture_email_requires_explicit_eval_flag():
def test_email_client_cache_drops_fixture_rows():
"""Old fixture rows in browser storage must not keep rendering."""
frontend = _EMAIL_LIBRARY.read_text(encoding="utf-8")
frontend = email_library_source()
assert "function _looksLikeFixtureEmailRow(row)" in frontend
assert "function _libCacheHasFixtureRows(value)" in frontend
@@ -164,8 +156,7 @@ def test_email_client_cache_drops_fixture_rows():
def test_email_compose_can_attach_gallery_images():
"""Compose attachments should support local files, documents, and Gallery images."""
frontend = _REPO / "static" / "js" / "document.js"
frontend_text = frontend.read_text(encoding="utf-8")
frontend_text = document_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "Upload from computer" in frontend_text
+315
View File
@@ -0,0 +1,315 @@
"""Module-graph coverage for the split email library.
``specs/frontend.md`` records that this frontend has "no build-time type
checking, module graph validation, or script-order validation". That is
tolerable for a single 11k-line file and not tolerable for a package: splitting
``emailLibrary.js`` into modules that import each other adds three failure modes
nothing else here would catch.
1. The compatibility wrapper at ``static/js/emailLibrary.js`` drops an export.
Five call sites import that path; a missing name is ``undefined`` at call
time, not an error at load time, so the panel just stops responding.
2. A module in the package fails to evaluate — a stale relative specifier after
a move, or a temporal-dead-zone read across an import cycle. The package has
cycles by construction: extracted modules call back into ``index.js`` from
event handlers. That is safe for hoisted function declarations and *not* safe
for a ``const`` read while the graph is still evaluating, and which one you
wrote is invisible in a diff.
3. A new module is missing from the ``sw.js`` precache, so the panel that works
online cannot open offline.
The first and third are read off the source. The second needs a real module
loader, so it runs in a browser: each module is imported *on its own*, in a
fresh page, because entering the cycle at a submodule rather than at the entry
module is the order that exposes a dead-zone read.
"""
import json
import re
import subprocess
from pathlib import Path
from tests.helpers.js_modules import (
EMAIL_LIBRARY_ENTRY,
EMAIL_LIBRARY_PACKAGE,
EMAIL_LIBRARY_WRAPPER,
email_library_paths,
)
ROOT = Path(__file__).resolve().parents[1]
_SW = ROOT / "static" / "sw.js"
# `export function foo`, `export async function foo`, `export const foo`.
_EXPORT_DECL = re.compile(
r"^export\s+(?:async\s+)?(?:function|const|let|class)\s+([A-Za-z_$][\w$]*)",
re.M,
)
# `export { a, b } from '...'` and `export { a, b }`.
_EXPORT_LIST = re.compile(r"export\s*\{([^}]*)\}", re.S)
def _declared_exports(path: Path) -> set[str]:
return set(_EXPORT_DECL.findall(path.read_text(encoding="utf-8")))
def _listed_exports(path: Path) -> set[str]:
names: set[str] = set()
for block in _EXPORT_LIST.findall(path.read_text(encoding="utf-8")):
for raw in block.split(","):
name = raw.strip().split(" as ")[-1].strip()
if name:
names.add(name)
return names
# The email library's public surface. The entry module exports more than this —
# siblings in the package import helpers back out of it — so the wrapper is what
# declares which names are API and which are package-internal.
#
# Written out rather than derived because three of the five callers reach these
# through a dynamic import and a property read (`mod.openEmailLibrary` in
# chatStream.js and chatRenderer.js, `mod.refreshEmailLibrary` and
# `mod.openEmailLibrary` in document.js, `mod.mountEmailSettings` in
# settings.js), which no import scan can see. Only emailInbox.js imports names
# statically, and `test_wrapper_exposes_every_statically_imported_name` covers
# that half exactly.
_PUBLIC_SURFACE = {
"closeEmailLibrary",
"initEmailLibrary",
"isOpen",
"mountEmailSettings",
"openEmailLibrary",
"openEmailLibrarySettings",
"prewarmEmailLibrary",
"prewarmUnreadEmails",
"refreshEmailLibrary",
}
_STATIC_JS = ROOT / "static" / "js"
_WRAPPER_IMPORT = re.compile(
r"import\s*\{([^}]*)\}\s*from\s*'\./emailLibrary\.js(?:\?[^']*)?'", re.S
)
def test_wrapper_declares_the_public_surface():
assert _listed_exports(EMAIL_LIBRARY_WRAPPER) == _PUBLIC_SURFACE
def test_wrapper_re_exports_only_names_the_entry_module_has():
"""A name in the wrapper that the entry module does not export is a
SyntaxError at load time, and it takes the whole email panel with it."""
entry = _declared_exports(EMAIL_LIBRARY_ENTRY) | _listed_exports(EMAIL_LIBRARY_ENTRY)
wrapper = _listed_exports(EMAIL_LIBRARY_WRAPPER)
assert wrapper, f"{EMAIL_LIBRARY_WRAPPER} re-exports nothing"
assert wrapper <= entry, (
"static/js/emailLibrary.js re-exports names static/js/emailLibrary/"
f"index.js does not export: {sorted(wrapper - entry)}"
)
def test_wrapper_exposes_every_statically_imported_name():
"""Whatever a module outside the package imports by name must be there."""
wrapper = _listed_exports(EMAIL_LIBRARY_WRAPPER)
checked = 0
for path in sorted(_STATIC_JS.rglob("*.js")):
if path == EMAIL_LIBRARY_WRAPPER or EMAIL_LIBRARY_PACKAGE in path.parents:
continue
for block in _WRAPPER_IMPORT.findall(path.read_text(encoding="utf-8")):
for raw in block.split(","):
name = raw.strip().split(" as ")[0].strip()
if not name:
continue
checked += 1
assert name in wrapper, (
f"{path.relative_to(ROOT)} imports {name} from "
"static/js/emailLibrary.js, which does not export it"
)
assert checked, "no module imports names from static/js/emailLibrary.js"
_IMPORT_STATEMENT = re.compile(
r"^import\s+(\{[^}]*\}|\*\s+as\s+[\w$]+|[\w$]+)\s+from\s+'[^']+';", re.M | re.S
)
_TOP_LEVEL_DECL = re.compile(
r"^(?:export\s+)?(?:async\s+)?(?:function|const|let|var)\s+([A-Za-z_$][\w$]*)", re.M
)
def _bound_names(source: str) -> set[str]:
"""Local names a module binds: imports plus top-level declarations."""
names = set(_TOP_LEVEL_DECL.findall(source))
for clause in _IMPORT_STATEMENT.findall(source):
clause = clause.strip()
if clause.startswith("{"):
names |= {
part.strip().split(" as ")[-1].strip()
for part in clause.strip("{}").split(",")
if part.strip()
}
else:
names.add(clause.split(" as ")[-1].strip())
return names
_REEXPORT = re.compile(r"^export\s*\{[^}]*\}\s*from\s*'[^']+';", re.M | re.S)
_WHOLE_LINE_COMMENT = re.compile(r"^\s*(?://|/\*|\*/|\*(?!/)).*$", re.M)
def _executable_body(source: str) -> str:
"""The part of a module that actually runs a name.
Imports are dropped because a name in an import clause is the binding, not a
use. Re-export lists go for the same reason. Whole-line comments go because
prose says things like "no shared state" and `state` is a real binding here;
only full lines are stripped, so a `//` inside a URL literal is left alone.
"""
ends = [m.end() for m in _IMPORT_STATEMENT.finditer(source)]
body = source[max(ends):] if ends else source
body = _REEXPORT.sub("", body)
return _WHOLE_LINE_COMMENT.sub("", body)
def test_no_module_uses_a_package_name_it_never_bound():
"""A name used but never imported is a runtime ReferenceError, nothing less.
It is invisible to `node --check`, which parses without resolving scope, and
invisible to loading the module, because the throw happens inside a function
body the loader never calls. It is also the single most likely mistake when
code moves between modules — the declaration stays behind and the use comes
along.
The vocabulary checked is the package's own: every name any module here
binds. That is narrow on purpose. It is not a general no-undef pass, so it
never has to model the browser's globals and never produces a false
positive; it catches exactly the shape a split produces.
"""
sources = {p.name: p.read_text(encoding="utf-8") for p in email_library_paths(True)}
vocabulary: set[str] = set()
for source in sources.values():
vocabulary |= _bound_names(source)
unbound: dict[str, list[str]] = {}
for name, source in sources.items():
bound = _bound_names(source)
body = _executable_body(source)
missing = sorted(
word
for word in vocabulary - bound
if re.search(r"(?<![\w$.])" + re.escape(word) + r"(?![\w$])", body)
)
if missing:
unbound[name] = missing
assert not unbound, (
"modules use names they neither declare nor import, which throws at call "
f"time and nowhere earlier: {unbound}"
)
def test_every_package_module_is_precached():
sw = _SW.read_text(encoding="utf-8")
for path in email_library_paths():
url = "/static/js/emailLibrary/" + path.name
assert f"'{url}'" in sw, (
f"{url} is not in the static/sw.js precache, so a panel that works "
"online will not open offline"
)
def test_every_package_module_evaluates_on_its_own_in_a_browser():
"""Import each module first, alone, and require it to evaluate.
Entering the package at a submodule is what turns an import cycle from
harmless into a `ReferenceError: cannot access '…' before initialization`.
Importing the entry module first would hide exactly that.
"""
urls = ["/static/js/emailLibrary.js"] + [
"/static/js/emailLibrary/" + p.name for p in email_library_paths()
]
script = r'''
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const urls = JSON.parse(process.env.ODYSSEUS_EMAIL_MODULE_URLS);
const browser = await chromium.launch({ headless: true });
const results = {};
for (const url of urls) {
const page = await browser.newPage();
// Same synthetic shell the other email browser tests use: these
// modules touch #toast and #sidebar while evaluating.
await page.goto(`${origin}/static/js/documentStats.js`);
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
results[url] = await page.evaluate(async (target) => {
try {
const mod = await import(target);
return { ok: true, exports: Object.keys(mod).sort() };
} catch (err) {
return { ok: false, error: String(err && err.message || err) };
}
}, url);
await page.close();
}
console.log(JSON.stringify(results));
await browser.close();
'''
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
env={
**__import__("os").environ,
"ODYSSEUS_EMAIL_MODULE_URLS": json.dumps(urls),
},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
broken = {url: info["error"] for url, info in data.items() if not info["ok"]}
assert not broken, f"modules that failed to evaluate on their own: {broken}"
assert set(data) == set(urls)
def test_wrapper_and_entry_module_hand_out_the_same_functions():
"""Importing either path must give one live module instance.
Two instances would mean two copies of the panel's module state, and the
panel is a singleton keyed on DOM ids — the second copy would fight the
first over `#email-lib-modal`.
"""
script = r'''
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(`${origin}/static/js/documentStats.js`);
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
const out = await page.evaluate(async () => {
const wrapper = await import('/static/js/emailLibrary.js');
const entry = await import('/static/js/emailLibrary/index.js');
const names = Object.keys(wrapper).sort();
return {
names,
identical: names.filter((n) => wrapper[n] === entry[n]),
callable: names.filter((n) => typeof wrapper[n] === 'function'),
};
});
console.log(JSON.stringify(out));
await browser.close();
'''
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["names"], "the wrapper exported nothing at runtime"
assert data["identical"] == data["names"], (
"the wrapper and the entry module handed out different objects for "
f"{sorted(set(data['names']) - set(data['identical']))}"
)
assert data["callable"] == data["names"], (
"not every export is callable: "
f"{sorted(set(data['names']) - set(data['callable']))}"
)
+3 -62
View File
@@ -4,76 +4,17 @@ import shutil
import subprocess
import pytest
from tests.helpers.js_modules import email_library_source, js_function_source
_REPO = Path(__file__).resolve().parents[1]
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
def _source() -> str:
return _EMAIL_LIBRARY.read_text(encoding="utf-8")
return email_library_source()
def _function_source(name: str) -> str:
"""Return one top-level JS function using balanced braces."""
text = _source()
markers = (f"function {name}", f"async function {name}", f"export function {name}", f"export async function {name}")
starts = [text.find(marker) for marker in markers]
starts = [start for start in starts if start >= 0]
assert starts, f"missing function {name}"
start = min(starts)
paren = text.index("(", start)
paren_depth = 0
quote = None
escaped = False
for index in range(paren, len(text)):
char = text[index]
if quote:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == quote:
quote = None
continue
if char in ("'", '"', "`"):
quote = char
elif char == "(":
paren_depth += 1
elif char == ")":
paren_depth -= 1
if paren_depth == 0:
brace = text.index("{", index)
break
else:
raise AssertionError(f"unterminated signature {name}")
depth = 0
quote = None
escaped = False
template_depth = 0
for index in range(brace, len(text)):
char = text[index]
if quote:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == quote and template_depth == 0:
quote = None
elif quote == "`" and char == "$" and index + 1 < len(text) and text[index + 1] == "{":
template_depth += 1
elif quote == "`" and char == "}" and template_depth:
template_depth -= 1
continue
if char in ("'", '"', "`"):
quote = char
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return text[start:index + 1]
raise AssertionError(f"unterminated function {name}")
return js_function_source(name, _source())
def _run_scheduler_scenario(scenario: str):
+14 -6
View File
@@ -4,16 +4,24 @@ from pathlib import Path
_REPO = Path(__file__).resolve().parents[1]
_HANDLER = _REPO / "static" / "js" / "settings" / "oauthReturn.js"
_COORDINATOR = _REPO / "static" / "js" / "settings.js"
def test_oauth_redirect_uses_the_module_local_settings_api():
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
handler = source[
source.index("(function _handleOauthRedirect"):
source.index("const settingsModule =")
]
handler = _HANDLER.read_text(encoding="utf-8")
assert "open('integrations');" in handler
assert "openSettings('integrations');" in handler
assert "window.settingsModule" not in handler
assert "window.__odysseusAppStarted" not in handler
assert "document.addEventListener('DOMContentLoaded', _showResult, { once: true })" in handler
def test_settings_coordinator_hands_the_handler_its_own_open():
source = _COORDINATOR.read_text(encoding="utf-8")
# The handler only stays window-free if the coordinator passes its own
# export in. A stray `window.settingsModule` here would reintroduce the
# load-order dependency the handler was written to avoid.
assert "handleSettingsOauthReturn({ openSettings: open });" in source
assert "from './settings/oauthReturn.js'" in source
+9 -8
View File
@@ -6,11 +6,12 @@ import subprocess
from pathlib import Path
import pytest
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_source, js_function_source
_REPO = Path(__file__).resolve().parent.parent
_INBOX_JS = _REPO / "static" / "js" / "emailInbox.js"
_LIBRARY_JS = _REPO / "static" / "js" / "emailLibrary.js"
_HAS_NODE = shutil.which("node") is not None
@@ -21,8 +22,8 @@ def _extract_between(source: str, signature: str, next_marker: str) -> str:
def test_library_unread_preview_has_one_authoritative_request_and_rollback():
source = _LIBRARY_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
source = email_library_source()
function = js_function_source("_toggleCardPreview", source)
assert function.count("/api/email/read/") == 1
assert "/api/email/mark-read/" not in function
@@ -38,8 +39,8 @@ def test_library_unread_preview_has_one_authoritative_request_and_rollback():
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_library_authoritative_success_defeats_newer_rollback_in_either_order():
source = _LIBRARY_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
source = email_library_source()
function = js_function_source("_toggleCardPreview", source)
settlements = _extract_between(
function,
" const restoreUnreadState = () => {",
@@ -101,7 +102,7 @@ console.log(JSON.stringify({{
def test_library_reply_open_carries_immutable_mailbox_context():
library_source = _LIBRARY_JS.read_text(encoding="utf-8")
library_source = email_library_source()
inbox_source = _INBOX_JS.read_text(encoding="utf-8")
assert "const mailboxGeneration = _emailMailboxGeneration;" in library_source
@@ -120,7 +121,7 @@ def test_library_reply_open_carries_immutable_mailbox_context():
def test_attachment_warning_only_checks_authored_reply_text():
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
source = document_source()
helper = source[source.index("function _bodyMentionsAttachment"):source.index("\n\n function _clearMissingAttachmentWarnings", source.index("function _bodyMentionsAttachment"))]
assert "_emailReplyOwnText(text)" in helper
@@ -128,7 +129,7 @@ def test_attachment_warning_only_checks_authored_reply_text():
def test_email_send_saves_recovery_draft_before_send_and_retains_it_on_failure():
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
source = document_source()
send = source[source.index("async function _sendEmail"):source.index("\n\n async function _saveDraft", source.index("async function _sendEmail"))]
assert "async function _saveEmailDraftForRecovery" in source
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import ast
import os
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
CANONICAL = (
ROOT / "routes" / "email" / "email_helpers.py",
ROOT / "routes" / "email" / "email_pollers.py",
ROOT / "routes" / "email" / "email_routes.py",
)
LEGACY_NAMES = {
"routes.email_helpers",
"routes.email_pollers",
"routes.email_routes",
}
def _imported_module_names(path: Path) -> set[str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
names.add(node.module)
return names
def test_canonical_email_package_does_not_import_legacy_shims() -> None:
offenders: list[str] = []
for path in CANONICAL:
imports = _imported_module_names(path)
legacy = sorted(imports & LEGACY_NAMES)
if legacy:
offenders.append(f"{path.relative_to(ROOT)}: {legacy}")
assert not offenders, "\n".join(offenders)
def test_legacy_email_modules_alias_canonical_module_objects() -> None:
code = r'''
import importlib
import sys
pairs = (
("routes.email_helpers", "routes.email.email_helpers"),
("routes.email_pollers", "routes.email.email_pollers"),
("routes.email_routes", "routes.email.email_routes"),
)
for legacy_name, canonical_name in pairs:
legacy = importlib.import_module(legacy_name)
canonical = importlib.import_module(canonical_name)
assert legacy is canonical, (legacy_name, canonical_name)
assert sys.modules[legacy_name] is canonical
assert sys.modules[canonical_name] is canonical
marker = object()
legacy._compat_identity_probe = marker
assert canonical._compat_identity_probe is marker
del canonical._compat_identity_probe
'''
env = os.environ.copy()
env["PYTHONDONTWRITEBYTECODE"] = "1"
env["PYTHON_DOTENV_DISABLED"] = "1"
env["ODYSSEUS_INPROCESS_POLLERS"] = "0"
result = subprocess.run(
[sys.executable, "-c", code],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
timeout=30,
)
assert result.returncode == 0, (
f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}"
)
@@ -1,12 +1,13 @@
from pathlib import Path
import re
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parent.parent
def test_email_folder_and_filter_pickers_are_exclusive_and_escape_safe():
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert source.count("const filterMenu = document.getElementById('email-filter-menu');") == 1
assert source.count("const folderMenu = document.getElementById('email-folder-menu');") == 1
+2 -3
View File
@@ -1,9 +1,8 @@
from pathlib import Path
from tests.helpers.document_source import document_source
SCRIPT = (
Path(__file__).resolve().parents[1] / "static/js/document.js"
).read_text(encoding="utf-8")
SCRIPT = document_source()
def test_email_send_requires_actual_send_button_event_target():
+2 -1
View File
@@ -1,7 +1,8 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
CSS = (Path(__file__).parents[1] / "static" / "style.css").read_text(encoding="utf-8")
CSS = app_css()
def _rule(selector: str) -> str:
+4 -2
View File
@@ -1,11 +1,13 @@
from pathlib import Path
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parents[1]
def test_card_delete_waits_for_durable_success_and_uses_email_identity():
source = (ROOT / "static/js/emailLibrary.js").read_text()
source = email_library_source()
assert "function _emailMutationQuery(em" in source
assert "em?.folder || fallbackFolder" in source
assert "em?.account_id || state._libAccountId" in source
@@ -14,7 +16,7 @@ def test_card_delete_waits_for_durable_success_and_uses_email_identity():
def test_finished_send_does_not_close_whichever_library_opened_later():
source = (ROOT / "static/js/document.js").read_text()
source = document_source()
start = source.index("async function _sendEmail()")
end = source.index("async function _saveDraft()", start)
send = source[start:end]
+2 -1
View File
@@ -5,11 +5,12 @@ import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers.stylesheets import app_css
_REPO = Path(__file__).resolve().parent.parent
_ADMIN_JS = _REPO / "static" / "js" / "admin.js"
_ADMIN = _ADMIN_JS.read_text(encoding="utf-8")
_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
_STYLE = app_css()
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
+4 -1
View File
@@ -3,6 +3,7 @@
import json
import subprocess
from pathlib import Path
from tests.helpers.stylesheets import stylesheet_link_tags
ROOT = Path(__file__).resolve().parents[1]
@@ -14,7 +15,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/style.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.setContent('__ODY_STYLESHEETS__<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?v=escape-regression-1');
mod.init('/api');
@@ -55,6 +56,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
console.log(JSON.stringify({ before, afterOne, afterTwo }));
await browser.close();
'''
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
['node', '--input-type=module', '-e', script],
cwd=ROOT,
@@ -114,6 +116,7 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None:
console.log(JSON.stringify({ settings, select, reading }));
await browser.close();
'''
script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags())
result = subprocess.run(
['node', '--input-type=module', '-e', script],
cwd=ROOT,
+8 -2
View File
@@ -8,6 +8,8 @@ from pathlib import Path
import pytest
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_paths
import src.tool_capabilities as tool_capabilities
from src.tool_capabilities import (
KNOWN_CAPABILITY_TOOLS,
@@ -1403,13 +1405,17 @@ def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
"static/js/chat.js",
"static/js/chatRenderer.js",
"static/js/chatStream.js",
"static/js/document.js",
"static/js/emailInbox.js",
"static/js/emailLibrary.js",
"static/js/settings.js",
"static/js/slashCommands.js",
)
]
# Both the document editor and email library are module sets.
approval_module_sources.append(document_source())
approval_module_sources.extend(
p.read_text()
for p in email_library_paths(include_wrapper=True)
)
assert all(
"20260722emailfastindex1" not in source
for source in approval_module_sources
+102
View File
@@ -0,0 +1,102 @@
"""Every module the frontend imports must exist, and the document set must be cached.
There is no bundler here, so nothing resolves the import graph before a browser
does. A specifier that names a file which is not there is valid JavaScript:
``node --check`` passes, and ``test_frontend_module_version_parity.py`` checks
that a module is loaded under one URL identity without checking that the URL
leads anywhere. The failure surfaces as a blank panel at runtime, and in the
test suite as a scatter of unrelated browser tests going red at once with no
mention of the missing file.
That is affordable to close statically, so this closes it.
"""
import re
from pathlib import Path
from tests.helpers.document_source import document_source_paths
ROOT = Path(__file__).resolve().parents[1]
STATIC = ROOT / "static"
# Vendored third-party builds and the committed editor build output are not
# ours to reason about.
_SKIP_DIRS = ("lib/", "js/editor/build/")
# `import x from '...'`, `export ... from '...'`, and `import('...')`. Only
# quoted specifiers: a template literal is not statically resolvable, and the
# app does not use one.
_SPECIFIER = re.compile(
r"""(?:^|[^\w.$])(?:import|export)\s*(?:[\w*{},\s$]*?\s*from\s*)?['"]([^'"]+)['"]"""
r"""|\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)""",
re.M,
)
def _own_scripts() -> list[Path]:
out = []
for path in sorted(STATIC.rglob("*.js")):
rel = path.relative_to(STATIC).as_posix()
if any(rel.startswith(d) or f"/{d}" in rel for d in _SKIP_DIRS):
continue
out.append(path)
return out
def _imports(path: Path):
"""(line, specifier, resolved path) for each relative/app-absolute import."""
source = path.read_text(encoding="utf-8")
for match in _SPECIFIER.finditer(source):
specifier = match.group(1) or match.group(2)
if not specifier:
continue
if not (specifier.startswith(".") or specifier.startswith("/static/")):
continue # bare specifier: not a file in this tree
bare = specifier.split("?")[0].split("#")[0]
if bare.startswith("/static/"):
target = STATIC / bare.removeprefix("/static/")
else:
target = path.parent / bare
line = source.count("\n", 0, match.start()) + 1
yield line, specifier, target
def test_sources_are_discoverable() -> None:
"""Guard the guard: this must not pass by scanning nothing."""
scripts = _own_scripts()
assert len(scripts) > 100, len(scripts)
total = sum(1 for p in scripts for _ in _imports(p))
assert total > 300, total
def test_every_frontend_import_resolves_to_a_file() -> None:
broken = [
f"{path.relative_to(ROOT)}:{line} -> {specifier}"
for path in _own_scripts()
for line, specifier, target in _imports(path)
if not target.is_file()
]
assert not broken, "imports naming files that do not exist: " + repr(broken)
def test_document_implementation_set_is_precached() -> None:
"""A module extracted out of document.js must join the offline manifest.
``static/sw.js`` fetches the URLs it lists, nothing they in turn import, so
a new module under ``static/js/document/`` is not cached just because the
entry point that imports it is. Without it the editor breaks offline for
anyone whose cache predates the split.
"""
service_worker = (STATIC / "sw.js").read_text(encoding="utf-8")
missing = []
for path in document_source_paths():
url = "/static/" + path.relative_to(STATIC).as_posix()
if not re.search(rf"['\"]{re.escape(url)}(?:\?[^'\"]*)?['\"]", service_worker):
missing.append(url)
assert not missing, (
"document editor modules absent from the sw.js precache lists: "
f"{missing}"
)
+2 -2
View File
@@ -1,7 +1,7 @@
from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import app_css, stylesheet_cache_version
ROOT = Path(__file__).resolve().parent.parent
@@ -49,7 +49,7 @@ def test_gallery_static_cache_key_bumped():
app = _read("static/app.js")
service_worker = _read("static/sw.js")
assert re.search(r"/static/style\.css\?v=[A-Za-z0-9_-]+", html)
assert stylesheet_cache_version()
html_version = re.search(r"/static/js/gallery\.js\?v=([A-Za-z0-9_-]+)", html)
app_version = re.search(r"gallery\.js\?v=([A-Za-z0-9_-]+)", app)
sw_version = re.search(r"/static/js/gallery\.js\?v=([A-Za-z0-9_-]+)", service_worker)
+8 -2
View File
@@ -1,11 +1,17 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
def test_image_settings_only_list_online_served_image_models():
settings = (ROOT / "static/js/settings.js").read_text(encoding="utf-8")
# The image panel moved into static/js/settings/imageModels.js; read the
# whole settings surface so this pins behaviour rather than a filename.
settings = "\n".join(
p.read_text(encoding="utf-8")
for p in [ROOT / "static/js/settings.js", *sorted((ROOT / "static/js/settings").glob("*.js"))]
)
assert "fetch('/api/model-endpoints'" in settings
assert "!endpoint.is_enabled || !endpoint.online" in settings
@@ -15,7 +21,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

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