mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 00:12:21 +02:00
Merge lab into feat/admin-build-provenance
This commit is contained in:
+29
-5
@@ -142,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.
|
||||
@@ -158,6 +158,31 @@ The inventory, the baseline and the capture live in `tests/css_snapshot/`;
|
||||
not, and how to find the property that moved when it fails. The run takes about
|
||||
21 seconds and skips when `npm ci` has not been run.
|
||||
|
||||
## Release smoke suite
|
||||
|
||||
`tests/smoke/` drives every advertised feature area once, end to end,
|
||||
against a real instance - the safety net the unit suite does not provide
|
||||
for a route move or a module split. One command boots the worktree and
|
||||
runs it:
|
||||
|
||||
```bash
|
||||
scripts/odysseus-smoke # boot, run every area, stop again
|
||||
scripts/odysseus-smoke --keep-up # leave the instance running
|
||||
scripts/odysseus-smoke --areas # the coverage table, without booting
|
||||
```
|
||||
|
||||
It reads its target instance out of the environment (`APP_PORT` through
|
||||
`internal_api_base()`, plus the dev admin account), so under a plain
|
||||
`pytest` with nothing booted every scenario skips with the reason and
|
||||
the full suite stays green. Models are served by a deterministic
|
||||
loopback stub, never a live endpoint; email uses the repo's existing
|
||||
`ODYSSEUS_EMAIL_FIXTURE` path.
|
||||
|
||||
The report is a per-area table that also prints the areas the suite
|
||||
deliberately does not cover, so it cannot be read as coverage of
|
||||
everything it omits. `tests/smoke/README.md` documents what is in each
|
||||
list and why.
|
||||
|
||||
## Core principles
|
||||
|
||||
- Keep PRs small and homogeneous: one kind of change per PR.
|
||||
@@ -180,14 +205,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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}")
|
||||
@@ -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()]
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Release smoke suite
|
||||
|
||||
One command that boots this worktree and drives every advertised feature
|
||||
area once, end to end, against a real instance.
|
||||
|
||||
```bash
|
||||
scripts/odysseus-smoke # boot, run every area, stop again
|
||||
scripts/odysseus-smoke --keep-up # leave the instance running afterwards
|
||||
scripts/odysseus-smoke --no-boot # drive whatever is already up here
|
||||
scripts/odysseus-smoke --areas # print the coverage table without booting
|
||||
scripts/odysseus-smoke -- -k notes
|
||||
```
|
||||
|
||||
## Why it exists
|
||||
|
||||
The decomposition work had two safety nets and neither covered the
|
||||
product. The checkpoint benchmark measures the agent runtime. The
|
||||
computed-style snapshot in `tests/test_css_computed_style_snapshot.py`
|
||||
pins the rendered CSS. Nothing checked that Notes, Calendar, Documents,
|
||||
Email, Memory, Cookbook or Settings still worked after a route package
|
||||
moved or a 17,000-line module was split, and the unit suite does not:
|
||||
`StressTestor`'s review of #5898 is the worked proof that a
|
||||
byte-identical file-for-file move can break eleven tests that pass on
|
||||
the base branch, with CI green throughout.
|
||||
|
||||
## Where it lives and why
|
||||
|
||||
pytest, not Playwright. Both are in the repo, so this adds no third
|
||||
harness, and the choice went to pytest because every scenario here is a
|
||||
request/response round trip rather than a rendering assertion -
|
||||
rendering is already covered by the computed-style snapshot, and the
|
||||
28 Playwright specs under `tests/e2e/photo-editor/` are the one area
|
||||
with browser coverage. A browser would have added flake and start-up
|
||||
cost for no extra signal.
|
||||
|
||||
It owns no instance logic. `scripts/odysseus-dev` already derives ports
|
||||
per worktree, keeps the data dir and ChromaDB out of `data/`, and waits
|
||||
on `/api/ready` rather than a TCP accept, so `scripts/odysseus-smoke`
|
||||
boots through it and only adds the scenarios and the report.
|
||||
|
||||
## The contract with the runner
|
||||
|
||||
Four environment values, which are what `odysseus dev env` prints plus
|
||||
the dev admin account:
|
||||
|
||||
| Variable | Read through | Used for |
|
||||
|---|---|---|
|
||||
| `APP_PORT` | `src.constants.internal_api_base()` | which instance to drive |
|
||||
| `ODYSSEUS_ADMIN_USER` | - | who to authenticate as |
|
||||
| `ODYSSEUS_ADMIN_PASSWORD` | - | " |
|
||||
| `ODYSSEUS_DATA_DIR` | `src.constants.DATA_DIR` | where the email fixture file goes |
|
||||
|
||||
Run under a plain `pytest` with none of them set, every scenario skips
|
||||
with the reason and the full suite stays green. `APP_PORT` pointing at
|
||||
one of `odysseus dev`'s reserved ports - a normal launch of this
|
||||
checkout, the machine's own instance - is refused rather than driven,
|
||||
because the scenarios create and delete real records.
|
||||
|
||||
## The deterministic provider
|
||||
|
||||
`stub_provider.py` is an OpenAI-compatible server on an ephemeral
|
||||
loopback port: `GET /v1/models` and `POST /v1/chat/completions`, both
|
||||
buffered and streamed. No scenario touches a live model endpoint or the
|
||||
network. It serves two model ids so the Compare area has something to
|
||||
reveal, and it records every request so a scenario can assert the user's
|
||||
message actually reached the provider rather than only that some text
|
||||
came back.
|
||||
|
||||
Email uses the repo's own deterministic path rather than a second
|
||||
mechanism: `routes/email_routes.py` serves a fixture inbox when
|
||||
`ODYSSEUS_EMAIL_FIXTURE=1` and a fixture file is in the data dir. The
|
||||
suite writes the file and restores whatever was there; the flag is read
|
||||
inside the app's process, which is why the runner owns the boot.
|
||||
|
||||
## What is covered
|
||||
|
||||
One scenario per area, each asserting a user-visible outcome rather than
|
||||
a status code. `scripts/odysseus-smoke --areas` prints the current list.
|
||||
|
||||
| Area | What it asserts |
|
||||
|---|---|
|
||||
| Chat | a turn against the stub comes back rendered, on both the buffered and the streamed path, and is in the session history |
|
||||
| Compare | a blind comparison streams both sides and the vote reveals which model produced which reply |
|
||||
| Notes | a note is listed, read back, edited, and 404s after delete |
|
||||
| Calendar | an event appears in the window the UI queries and is gone after delete |
|
||||
| Tasks | a daily task is accepted with a computed next run, is listed, and pauses |
|
||||
| Documents (editor) | an edit adds a version, both versions read back, and a restore returns the first |
|
||||
| Documents (RAG) | an uploaded file is chunked, indexed and listed |
|
||||
| Email | the fixture inbox lists, opens with its body, and the unread count drops on mark-read |
|
||||
| Memory | a fact is listed, found by search, and gone after delete |
|
||||
| Uploads | an attachment reads back byte for byte |
|
||||
| Cookbook | hardware is detected and recommendations come back sized against it; state persists |
|
||||
| Settings | a preference written on one session is still there after a new login |
|
||||
|
||||
## What is not covered, and why
|
||||
|
||||
Printed next to the results on every run, so a reader cannot mistake the
|
||||
table for coverage of everything it does not mention. `DECLARED_GAPS` in
|
||||
`areas.py` is the list; the short version:
|
||||
|
||||
- **Deep Research** and **Web Search** need live egress. A deterministic
|
||||
stub for the crawler would be an application change, which this is
|
||||
not.
|
||||
- **Email over IMAP/SMTP** is covered only as far as the fixture path
|
||||
goes. There is no local mail server, so real account sync and send are
|
||||
untested.
|
||||
- **Cookbook download and serve** needs tmux, a GPU runtime and a
|
||||
multi-gigabyte download.
|
||||
- **Gallery and the photo editor** already have the repo's only
|
||||
Playwright specs.
|
||||
- **The agent tool loop** is what the checkpoint benchmark measures.
|
||||
- **MCP servers** are stdio subprocesses outside the app's readiness
|
||||
contract.
|
||||
- **Rendering and layout** are pinned by the computed-style snapshot.
|
||||
|
||||
`Documents (RAG)` is the one covered area that can report `SKIP` on a
|
||||
clean checkout: `requirements.txt` pins `chromadb-client`, the HTTP
|
||||
client, and the ChromaDB *server* is a separate install. Without one
|
||||
reachable, the upload route returns a deliberate 503 and the row reads
|
||||
`SKIP` with that reason. Install `chromadb` in the venv and it goes
|
||||
green.
|
||||
|
||||
## Reading the report
|
||||
|
||||
The table has one row per area in `areas.COVERED`, built from what
|
||||
pytest reported rather than from anything a scenario asserts about
|
||||
itself. An area whose module never ran shows as `NOT RUN`, so deleting
|
||||
or renaming a file cannot make a row disappear -
|
||||
`tests/test_smoke_area_table.py` pins that, and that a module on disk
|
||||
must be registered.
|
||||
|
||||
## What a run leaves behind
|
||||
|
||||
Every scenario deletes what it created, with two exceptions on the
|
||||
scratch instance: the preference key `odysseus_smoke_preference`, which
|
||||
has no delete route, and the uploaded attachment, which the app's own
|
||||
upload cleanup owns. Both live in `.odysseus-dev/data/`, never in
|
||||
`data/`.
|
||||
@@ -0,0 +1,195 @@
|
||||
"""The area registry and the result table for the release smoke suite.
|
||||
|
||||
Pure stdlib on purpose: this module is the one part of the suite that has
|
||||
to be readable and testable without a running instance, because it is
|
||||
what decides whether the suite's output is honest.
|
||||
|
||||
Two lists matter here and they are both deliberate:
|
||||
|
||||
``COVERED`` names every feature area the suite drives, and the test
|
||||
module that drives it. A row appears in the table whether or not its
|
||||
module ran, so an area cannot quietly vanish from the report by having
|
||||
its file deleted or renamed - it shows up as ``NOT RUN`` instead.
|
||||
|
||||
``DECLARED_GAPS`` names the areas the suite does *not* cover, with the
|
||||
reason. They are printed alongside the results rather than left out,
|
||||
because a smoke report that lists only what it checked reads as
|
||||
coverage of everything it does not mention.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Result labels. ASCII only - no Unicode status glyphs anywhere in the
|
||||
# table (repo convention: no emoji in UI or code).
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
SKIP = "SKIP"
|
||||
NOT_RUN = "NOT RUN"
|
||||
NOT_COVERED = "NOT COVERED"
|
||||
|
||||
# Precedence when one area's module produces several outcomes: a single
|
||||
# failure decides the row, then a skip, then pass.
|
||||
_PRECEDENCE = (FAIL, SKIP, PASS)
|
||||
|
||||
# Table geometry. Wide enough for the longest gap reason to read as a
|
||||
# sentence, narrow enough to survive a normal terminal.
|
||||
TABLE_WIDTH = 100
|
||||
MIN_DETAIL_WIDTH = 30
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Area:
|
||||
"""One advertised feature area and the module that exercises it."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
module: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gap:
|
||||
"""An area this suite does not cover, and why it does not."""
|
||||
|
||||
label: str
|
||||
reason: str
|
||||
|
||||
|
||||
# Order is the order the table prints in: the chat surface first, then
|
||||
# the feature areas README.md advertises, then the setup surface.
|
||||
COVERED = (
|
||||
Area("chat", "Chat", "test_chat_smoke.py"),
|
||||
Area("compare", "Compare", "test_compare_smoke.py"),
|
||||
Area("notes", "Notes", "test_notes_smoke.py"),
|
||||
Area("calendar", "Calendar", "test_calendar_smoke.py"),
|
||||
Area("tasks", "Tasks (scheduled)", "test_tasks_smoke.py"),
|
||||
Area("documents", "Documents (editor)", "test_documents_smoke.py"),
|
||||
Area("documents_rag", "Documents (RAG)", "test_documents_rag_smoke.py"),
|
||||
Area("email", "Email", "test_email_smoke.py"),
|
||||
Area("memory", "Memory", "test_memory_smoke.py"),
|
||||
Area("uploads", "Uploads", "test_uploads_smoke.py"),
|
||||
Area("cookbook", "Cookbook", "test_cookbook_smoke.py"),
|
||||
Area("settings", "Settings", "test_settings_smoke.py"),
|
||||
)
|
||||
|
||||
DECLARED_GAPS = (
|
||||
Gap(
|
||||
"Deep Research",
|
||||
"needs live web egress; the crawler has no deterministic stub and adding "
|
||||
"one would be an application change",
|
||||
),
|
||||
Gap(
|
||||
"Web Search",
|
||||
"needs a reachable SearXNG or an external provider, so the result is not "
|
||||
"reproducible from a clean checkout",
|
||||
),
|
||||
Gap(
|
||||
"Email over IMAP/SMTP",
|
||||
"covered through the existing ODYSSEUS_EMAIL_FIXTURE path only; no local "
|
||||
"mail server, so real account sync and send are untested",
|
||||
),
|
||||
Gap(
|
||||
"Cookbook download and serve",
|
||||
"needs tmux, a GPU runtime and a multi-GB model download; only hardware "
|
||||
"fit and state sync are checked",
|
||||
),
|
||||
Gap(
|
||||
"Gallery and photo editor",
|
||||
"already the one area with Playwright specs under tests/e2e/photo-editor/",
|
||||
),
|
||||
Gap(
|
||||
"Agent tool loop",
|
||||
"measured by the checkpoint benchmark, which is the safety net that does "
|
||||
"cover the agent runtime",
|
||||
),
|
||||
Gap(
|
||||
"MCP servers",
|
||||
"the built-in servers are stdio subprocesses whose readiness is not part "
|
||||
"of the app's own readiness contract",
|
||||
),
|
||||
Gap(
|
||||
"Rendering and layout",
|
||||
"pinned by the computed-style snapshot in "
|
||||
"tests/test_css_computed_style_snapshot.py",
|
||||
),
|
||||
)
|
||||
|
||||
_MODULE_TO_KEY = {area.module: area.key for area in COVERED}
|
||||
|
||||
|
||||
def area_for_module(module_name: str) -> str | None:
|
||||
"""Map a test module filename to its area key, or None."""
|
||||
return _MODULE_TO_KEY.get(module_name)
|
||||
|
||||
|
||||
def resolve(outcomes: list[str]) -> str:
|
||||
"""Collapse one module's outcomes into the row's single result."""
|
||||
if not outcomes:
|
||||
return NOT_RUN
|
||||
for label in _PRECEDENCE:
|
||||
if label in outcomes:
|
||||
return label
|
||||
return NOT_RUN
|
||||
|
||||
|
||||
def render_table(results, *, header="", areas=COVERED, gaps=DECLARED_GAPS,
|
||||
width=TABLE_WIDTH) -> str:
|
||||
"""Render the per-area table.
|
||||
|
||||
``results`` maps an area key to a mapping with ``result`` and,
|
||||
optionally, ``checks`` and ``detail``. Unknown keys are ignored and
|
||||
missing keys render as ``NOT RUN`` - the registry, not the run,
|
||||
decides which rows exist.
|
||||
"""
|
||||
rows = []
|
||||
for area in areas:
|
||||
entry = results.get(area.key) or {}
|
||||
result = entry.get("result") or NOT_RUN
|
||||
checks = entry.get("checks")
|
||||
detail = entry.get("detail") or ""
|
||||
if result == NOT_RUN and not detail:
|
||||
detail = "no test ran for this area"
|
||||
rows.append((area.label, result,
|
||||
"" if checks is None else str(checks), detail))
|
||||
|
||||
labels = [row[0] for row in rows] + [gap.label for gap in gaps] + ["AREA"]
|
||||
label_width = max(len(label) for label in labels)
|
||||
result_width = max([len(row[1]) for row in rows] + [len(NOT_COVERED), len("RESULT")])
|
||||
checks_width = max([len(row[2]) for row in rows] + [len("CHECKS")])
|
||||
# Indent + label + gap + result + gap + checks + gap, then the detail.
|
||||
detail_indent = 2 + label_width + 2 + result_width + 2 + checks_width + 2
|
||||
detail_width = max(width - detail_indent, MIN_DETAIL_WIDTH)
|
||||
|
||||
def row_lines(label, result, checks, detail):
|
||||
first = (f" {label.ljust(label_width)} {result.ljust(result_width)} "
|
||||
f"{checks.rjust(checks_width)} ")
|
||||
wrapped = textwrap.wrap(detail, detail_width) or [""]
|
||||
out = [(first + wrapped[0]).rstrip()]
|
||||
out += [(" " * detail_indent + line).rstrip() for line in wrapped[1:]]
|
||||
return out
|
||||
|
||||
lines = []
|
||||
if header:
|
||||
lines.extend([header, ""])
|
||||
lines.append(
|
||||
f" {'AREA'.ljust(label_width)} {'RESULT'.ljust(result_width)} "
|
||||
f"{'CHECKS'.rjust(checks_width)} DETAIL"
|
||||
)
|
||||
for row in rows:
|
||||
lines.extend(row_lines(*row))
|
||||
|
||||
if gaps:
|
||||
lines.extend(["", " Not covered, deliberately:"])
|
||||
for gap in gaps:
|
||||
lines.extend(row_lines(gap.label, NOT_COVERED, "", gap.reason))
|
||||
|
||||
failed = [row for row in rows if row[1] == FAIL]
|
||||
skipped = [row for row in rows if row[1] == SKIP]
|
||||
not_run = [row for row in rows if row[1] == NOT_RUN]
|
||||
passed = len(rows) - len(failed) - len(skipped) - len(not_run)
|
||||
lines.extend(["", (
|
||||
f" {passed} pass, {len(failed)} fail, {len(skipped)} skip, "
|
||||
f"{len(not_run)} not run, {len(gaps)} declared gaps"
|
||||
)])
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Session wiring for the release smoke suite.
|
||||
|
||||
The suite drives a real instance over HTTP. It never starts one: that is
|
||||
`scripts/odysseus-smoke`'s job, which boots the worktree through
|
||||
`scripts/odysseus-dev` and hands the details over in the environment.
|
||||
Run under a plain `pytest` with no instance up, every scenario skips
|
||||
with the reason rather than failing, so the full suite stays green.
|
||||
|
||||
Three environment values form the contract, and they are exactly what
|
||||
`odysseus dev env` prints plus the dev admin account:
|
||||
|
||||
APP_PORT - which instance, read through
|
||||
`internal_api_base()`
|
||||
ODYSSEUS_ADMIN_USER - the account to authenticate as
|
||||
ODYSSEUS_ADMIN_PASSWORD
|
||||
ODYSSEUS_DATA_DIR - where the email fixture file goes, read
|
||||
through `src.constants.DATA_DIR`
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.constants import internal_api_base
|
||||
from tests.helpers.cli_loader import load_script
|
||||
from tests.smoke import areas
|
||||
from tests.smoke.stub_provider import MODEL_PRIMARY, StubProvider
|
||||
|
||||
# How long a smoke request may take. Generous: the first turn through a
|
||||
# cold agent path does real work, and a timeout here reads as a product
|
||||
# failure, which is the one thing this suite must not get wrong.
|
||||
REQUEST_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
# Auth and endpoint routes the suite drives directly. Kept here so a
|
||||
# route rename shows up in one place rather than twelve.
|
||||
LOGIN_PATH = "/api/auth/login"
|
||||
HEALTH_PATH = "/api/health"
|
||||
ENDPOINTS_PATH = "/api/model-endpoints"
|
||||
SESSION_PATH = "/api/session"
|
||||
|
||||
_NO_PORT = (
|
||||
"APP_PORT is not set, so there is no instance to drive. Run the suite "
|
||||
"with `scripts/odysseus-smoke`, which boots this worktree and exports it."
|
||||
)
|
||||
|
||||
|
||||
def _reserved_ports() -> dict:
|
||||
"""`odysseus dev`'s own refuse-list, read from the launcher.
|
||||
|
||||
The smoke suite writes and deletes real records, so pointing it at a
|
||||
port that means something - a normal launch of this checkout, the
|
||||
machine's production instance - has to be impossible rather than
|
||||
merely discouraged. Reusing the launcher's table keeps one source of
|
||||
truth instead of a second copy that can drift.
|
||||
"""
|
||||
try:
|
||||
return dict(load_script("odysseus-dev").RESERVED_PORTS)
|
||||
except Exception: # pragma: no cover - launcher absent or unloadable
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url() -> str:
|
||||
"""The instance this run drives, or a skip explaining why there is none."""
|
||||
port = (os.environ.get("APP_PORT") or "").strip()
|
||||
if not port:
|
||||
pytest.skip(_NO_PORT)
|
||||
reason = _reserved_ports().get(int(port)) if port.isdigit() else None
|
||||
if reason:
|
||||
pytest.skip(
|
||||
f"APP_PORT={port} is {reason}. The smoke suite creates and deletes "
|
||||
f"real records, so it refuses to run against that instance."
|
||||
)
|
||||
return internal_api_base()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def account() -> dict:
|
||||
user = (os.environ.get("ODYSSEUS_ADMIN_USER") or "").strip()
|
||||
password = os.environ.get("ODYSSEUS_ADMIN_PASSWORD") or ""
|
||||
if not user or not password:
|
||||
pytest.skip(
|
||||
"ODYSSEUS_ADMIN_USER / ODYSSEUS_ADMIN_PASSWORD are not set, so the "
|
||||
"suite cannot authenticate. Run it with `scripts/odysseus-smoke`."
|
||||
)
|
||||
return {"username": user, "password": password}
|
||||
|
||||
|
||||
def _new_client(base_url: str, account: dict) -> httpx.Client:
|
||||
"""An authenticated client, or a skip naming what the instance said."""
|
||||
client = httpx.Client(base_url=base_url, timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
follow_redirects=True)
|
||||
try:
|
||||
client.get(HEALTH_PATH)
|
||||
except httpx.HTTPError as exc:
|
||||
client.close()
|
||||
pytest.skip(f"no instance answering at {base_url} ({exc}). Boot one with "
|
||||
f"`odysseus dev up`, or run `scripts/odysseus-smoke`.")
|
||||
response = client.post(LOGIN_PATH, json=account)
|
||||
if response.status_code != 200:
|
||||
client.close()
|
||||
pytest.skip(
|
||||
f"could not log in as {account['username']} at {base_url}: "
|
||||
f"HTTP {response.status_code}. The recorded credentials may not "
|
||||
f"match this instance's data dir."
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client(base_url, account):
|
||||
"""One authenticated session shared by every scenario."""
|
||||
handle = _new_client(base_url, account)
|
||||
yield handle
|
||||
handle.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_client(base_url, account):
|
||||
"""A second authenticated session, for asserting something persisted.
|
||||
|
||||
Reading a value back on the same cookie proves the request handler
|
||||
returned it. Reading it back on a new login is the closest a test can
|
||||
get to the user reloading the page.
|
||||
"""
|
||||
handle = _new_client(base_url, account)
|
||||
yield handle
|
||||
handle.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def stub_provider():
|
||||
"""The deterministic provider every model-backed scenario talks to."""
|
||||
with StubProvider() as provider:
|
||||
yield provider
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def stub_endpoint(client, stub_provider) -> str:
|
||||
"""Register the stub as a model endpoint and return its id.
|
||||
|
||||
Registered as `endpoint_kind=local` so the app treats it the way it
|
||||
treats a Cookbook-served model rather than probing it as a hosted
|
||||
API, and removed afterwards so a `--keep-up` instance is not left
|
||||
pointing at a port that has gone away.
|
||||
"""
|
||||
response = client.post(ENDPOINTS_PATH, data={
|
||||
"name": "odysseus-smoke-stub",
|
||||
"base_url": stub_provider.base_url,
|
||||
"endpoint_kind": "local",
|
||||
})
|
||||
if response.status_code != 200:
|
||||
pytest.skip(
|
||||
f"the instance would not register the stub provider at "
|
||||
f"{stub_provider.base_url}: HTTP {response.status_code} "
|
||||
f"{response.text[:200]}"
|
||||
)
|
||||
body = response.json()
|
||||
endpoint_id = str(body.get("id") or "")
|
||||
if not endpoint_id:
|
||||
pytest.skip(f"the endpoint the instance registered has no id: {body}")
|
||||
if MODEL_PRIMARY not in (body.get("models") or []):
|
||||
pytest.skip(
|
||||
f"the instance did not discover {MODEL_PRIMARY} on the stub "
|
||||
f"provider; it saw {body.get('models')}"
|
||||
)
|
||||
yield endpoint_id
|
||||
client.delete(f"{ENDPOINTS_PATH}/{endpoint_id}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_session(client, stub_endpoint):
|
||||
"""A chat session bound to the stub provider, deleted afterwards."""
|
||||
response = client.post(SESSION_PATH, data={
|
||||
"name": "odysseus-smoke",
|
||||
"endpoint_id": stub_endpoint,
|
||||
"model": MODEL_PRIMARY,
|
||||
})
|
||||
assert response.status_code == 200, response.text
|
||||
session_id = response.json()["id"]
|
||||
yield session_id
|
||||
client.delete(f"{SESSION_PATH}/{session_id}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The per-area table
|
||||
# --------------------------------------------------------------------------
|
||||
# One row per area in `areas.COVERED`, built from the outcomes pytest
|
||||
# reports rather than from anything a test asserts about itself, so a
|
||||
# module that never ran cannot report a pass.
|
||||
|
||||
_outcomes: dict[str, list[str]] = {}
|
||||
_details: dict[str, str] = {}
|
||||
_checks: dict[str, int] = {}
|
||||
|
||||
|
||||
def _skip_reason(report) -> str:
|
||||
"""The reason text out of a skip report, best effort."""
|
||||
longrepr = getattr(report, "longrepr", None)
|
||||
if isinstance(longrepr, tuple) and len(longrepr) == 3:
|
||||
reason = str(longrepr[2] or "")
|
||||
return reason.removeprefix("Skipped: ").strip()
|
||||
return str(longrepr or "").strip()
|
||||
|
||||
|
||||
def pytest_runtest_logreport(report):
|
||||
key = areas.area_for_module(os.path.basename(str(report.fspath)))
|
||||
if key is None:
|
||||
return
|
||||
if report.skipped:
|
||||
_outcomes.setdefault(key, []).append(areas.SKIP)
|
||||
_details.setdefault(key, _skip_reason(report))
|
||||
return
|
||||
if report.failed:
|
||||
_outcomes.setdefault(key, []).append(areas.FAIL)
|
||||
_details[key] = f"{report.when} failed: {report.nodeid.split('::')[-1]}"
|
||||
return
|
||||
if report.when == "call" and report.passed:
|
||||
_outcomes.setdefault(key, []).append(areas.PASS)
|
||||
_checks[key] = _checks.get(key, 0) + 1
|
||||
|
||||
|
||||
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
||||
if not _outcomes:
|
||||
return
|
||||
results = {}
|
||||
for key, outcomes in _outcomes.items():
|
||||
results[key] = {
|
||||
"result": areas.resolve(outcomes),
|
||||
"checks": _checks.get(key, 0),
|
||||
"detail": _details.get(key, ""),
|
||||
}
|
||||
|
||||
if all(entry["result"] == areas.SKIP for entry in results.values()):
|
||||
reasons = {entry["detail"] for entry in results.values() if entry["detail"]}
|
||||
terminalreporter.write_line("")
|
||||
terminalreporter.write_line(
|
||||
"release smoke suite skipped: " + (
|
||||
reasons.pop() if len(reasons) == 1 else "; ".join(sorted(reasons))
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
header = f"Odysseus release smoke - {internal_api_base()}"
|
||||
terminalreporter.write_line("")
|
||||
terminalreporter.write_line(areas.render_table(results, header=header))
|
||||
@@ -0,0 +1,172 @@
|
||||
"""A deterministic OpenAI-compatible provider for the smoke suite.
|
||||
|
||||
Every scenario that needs a model talks to this instead of a real
|
||||
endpoint. It binds an ephemeral port on loopback, so no scenario depends
|
||||
on network egress, on a model being downloaded, or on two runs on the
|
||||
same machine picking the same port.
|
||||
|
||||
It answers the two routes the app needs to treat it as a local
|
||||
OpenAI-compatible server: ``GET /v1/models`` for discovery and probing,
|
||||
and ``POST /v1/chat/completions`` for both the buffered and the streamed
|
||||
turn. Each reply is a fixed marker plus the model id, so a test can tell
|
||||
the two models apart in a blind comparison; every request is recorded so
|
||||
a test can assert the user's message actually reached the provider
|
||||
rather than only that some text came back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
# Two model ids so the Compare area has something to reveal.
|
||||
MODEL_PRIMARY = "odysseus-smoke-primary"
|
||||
MODEL_SECONDARY = "odysseus-smoke-secondary"
|
||||
MODELS = (MODEL_PRIMARY, MODEL_SECONDARY)
|
||||
|
||||
# The marker each reply starts with. Distinctive enough that finding it
|
||||
# in a response body cannot be a coincidence, and short enough to read
|
||||
# in a failure message.
|
||||
REPLY_MARKER = "ODYSSEUS-SMOKE-REPLY"
|
||||
|
||||
# Bind on loopback, kernel-assigned port. No literal port anywhere.
|
||||
BIND_HOST = "127.0.0.1"
|
||||
BIND_PORT = 0
|
||||
|
||||
|
||||
def reply_for(model: str) -> str:
|
||||
"""The exact assistant text this provider returns for ``model``."""
|
||||
return f"{REPLY_MARKER} {model}"
|
||||
|
||||
|
||||
class _Recorder:
|
||||
"""Requests the provider has served, for assertions after the fact."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._calls = []
|
||||
|
||||
def record(self, payload: dict) -> None:
|
||||
with self._lock:
|
||||
self._calls.append(payload)
|
||||
|
||||
@property
|
||||
def calls(self) -> list[dict]:
|
||||
with self._lock:
|
||||
return list(self._calls)
|
||||
|
||||
def prompts(self) -> list[str]:
|
||||
"""Every user message this provider has been sent."""
|
||||
out = []
|
||||
for call in self.calls:
|
||||
for message in call.get("messages") or []:
|
||||
if message.get("role") == "user":
|
||||
out.append(str(message.get("content") or ""))
|
||||
return out
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._calls.clear()
|
||||
|
||||
|
||||
def _handler_for(recorder: _Recorder):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args): # noqa: D102 - silence stderr access log
|
||||
pass
|
||||
|
||||
def _send_json(self, status: int, body: dict) -> None:
|
||||
raw = json.dumps(body).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler's contract
|
||||
if self.path.rstrip("/").endswith("/models"):
|
||||
self._send_json(200, {
|
||||
"object": "list",
|
||||
"data": [{"id": name, "object": "model", "owned_by": "smoke"}
|
||||
for name in MODELS],
|
||||
})
|
||||
return
|
||||
self._send_json(404, {"error": {"message": f"no route {self.path}"}})
|
||||
|
||||
def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler's contract
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
try:
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
except ValueError:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
recorder.record(payload)
|
||||
|
||||
model = str(payload.get("model") or MODEL_PRIMARY)
|
||||
text = reply_for(model)
|
||||
if payload.get("stream"):
|
||||
self._send_stream(model, text)
|
||||
return
|
||||
self._send_json(200, {
|
||||
"id": "smoke-completion",
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
})
|
||||
|
||||
def _send_stream(self, model: str, text: str) -> None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
for chunk in (
|
||||
{"choices": [{"index": 0, "delta": {"content": text}}], "model": model},
|
||||
{"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "model": model},
|
||||
):
|
||||
self.wfile.write(b"data: " + json.dumps(chunk).encode("utf-8") + b"\n\n")
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
class StubProvider:
|
||||
"""A running stub provider. Use as a context manager."""
|
||||
|
||||
def __init__(self):
|
||||
self.recorder = _Recorder()
|
||||
self._server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), _handler_for(self.recorder))
|
||||
self._server.daemon_threads = True
|
||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._server.server_address[1]
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""The OpenAI-compatible base the app should be pointed at."""
|
||||
return f"http://{BIND_HOST}:{self.port}/v1"
|
||||
|
||||
def start(self) -> "StubProvider":
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self) -> None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def __enter__(self) -> "StubProvider":
|
||||
return self.start()
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.stop()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Calendar: an event created through the API shows up in the range the UI asks for."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
CALENDARS_PATH = "/api/calendar/calendars"
|
||||
EVENTS_PATH = "/api/calendar/events"
|
||||
|
||||
SUMMARY = "Odysseus smoke event"
|
||||
# Far enough out that a real local calendar's own entries cannot collide
|
||||
# with the assertion, and fixed relative to now so the window is never
|
||||
# empty for date reasons.
|
||||
DAYS_AHEAD = 30
|
||||
|
||||
|
||||
def test_an_event_round_trips(client):
|
||||
listed_calendars = client.get(CALENDARS_PATH)
|
||||
assert listed_calendars.status_code == 200, listed_calendars.text
|
||||
assert listed_calendars.json().get("calendars"), "no calendar to write an event into"
|
||||
|
||||
start = (datetime.now() + timedelta(days=DAYS_AHEAD)).replace(
|
||||
hour=10, minute=0, second=0, microsecond=0)
|
||||
created = client.post(EVENTS_PATH, json={
|
||||
"summary": SUMMARY,
|
||||
"dtstart": start.isoformat(),
|
||||
})
|
||||
assert created.status_code == 200, created.text
|
||||
uid = created.json()["uid"]
|
||||
try:
|
||||
window = client.get(EVENTS_PATH, params={
|
||||
"start": (start - timedelta(days=1)).isoformat(),
|
||||
"end": (start + timedelta(days=1)).isoformat(),
|
||||
})
|
||||
assert window.status_code == 200, window.text
|
||||
events = window.json().get("events") or []
|
||||
matching = [e for e in events if e.get("uid") == uid]
|
||||
assert matching, [e.get("summary") for e in events]
|
||||
assert matching[0].get("summary") == SUMMARY, matching[0]
|
||||
|
||||
read = client.get(f"{EVENTS_PATH}/{uid}")
|
||||
assert read.status_code == 200, read.text
|
||||
finally:
|
||||
removed = client.delete(f"{EVENTS_PATH}/{uid}")
|
||||
assert removed.status_code == 200, removed.text
|
||||
|
||||
after = client.get(EVENTS_PATH, params={
|
||||
"start": (start - timedelta(days=1)).isoformat(),
|
||||
"end": (start + timedelta(days=1)).isoformat(),
|
||||
})
|
||||
assert uid not in [e.get("uid") for e in after.json().get("events") or []]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Chat: a turn against the stub provider comes back rendered and saved.
|
||||
|
||||
The buffered and the streamed path are both checked because the UI uses
|
||||
the streamed one and the agent's own loop uses the buffered one, and a
|
||||
decomposition can break either alone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from tests.smoke.stub_provider import MODEL_PRIMARY, reply_for
|
||||
|
||||
CHAT_PATH = "/api/chat"
|
||||
CHAT_STREAM_PATH = "/api/chat_stream"
|
||||
HISTORY_PATH = "/api/history"
|
||||
|
||||
PROMPT = "Smoke check: reply with anything."
|
||||
|
||||
|
||||
def test_buffered_turn_returns_the_provider_reply(client, chat_session, stub_provider):
|
||||
response = client.post(CHAT_PATH, json={"message": PROMPT, "session": chat_session})
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body.get("response") == reply_for(MODEL_PRIMARY), body
|
||||
assert body.get("model") == MODEL_PRIMARY, body
|
||||
# The app prefaces the turn with its own date/time context block, so
|
||||
# the prompt is contained in what the provider saw rather than equal
|
||||
# to it.
|
||||
assert any(PROMPT in seen for seen in stub_provider.recorder.prompts()), (
|
||||
"the prompt never reached the provider, so the reply came from "
|
||||
"somewhere other than the model path"
|
||||
)
|
||||
|
||||
|
||||
def test_streamed_turn_emits_the_reply_and_saves_the_message(client, chat_session):
|
||||
deltas, saved = [], []
|
||||
with client.stream("POST", CHAT_STREAM_PATH,
|
||||
json={"message": PROMPT, "session": chat_session}) as response:
|
||||
assert response.status_code == 200
|
||||
for line in response.iter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = line[len("data: "):].strip()
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if "delta" in event:
|
||||
deltas.append(str(event["delta"]))
|
||||
if event.get("type") == "message_saved":
|
||||
saved.append(event.get("id"))
|
||||
|
||||
assert "".join(deltas) == reply_for(MODEL_PRIMARY), deltas
|
||||
assert saved and saved[0], "the stream never reported the assistant turn as saved"
|
||||
|
||||
|
||||
def test_the_turn_is_in_the_session_history(client, chat_session):
|
||||
client.post(CHAT_PATH, json={"message": PROMPT, "session": chat_session})
|
||||
response = client.get(f"{HISTORY_PATH}/{chat_session}")
|
||||
assert response.status_code == 200, response.text
|
||||
messages = response.json().get("history") or []
|
||||
rendered = [str(m.get("content") or "") for m in messages]
|
||||
assert any(PROMPT in text for text in rendered), rendered
|
||||
assert any(reply_for(MODEL_PRIMARY) in text for text in rendered), rendered
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Compare: a blind comparison streams both sides and reveals them on the vote.
|
||||
|
||||
Two model ids on the one stub provider is what makes this checkable
|
||||
without a second endpoint: each returns a reply naming itself, so the
|
||||
reveal can be matched against which text arrived on which side.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from tests.smoke.stub_provider import MODEL_PRIMARY, MODEL_SECONDARY, reply_for
|
||||
|
||||
COMPARE_PATH = "/api/compare"
|
||||
CHAT_STREAM_PATH = "/api/chat_stream"
|
||||
|
||||
PROMPT = "Smoke check: compare two replies."
|
||||
|
||||
|
||||
def _stream_text(client, session_id: str) -> str:
|
||||
deltas = []
|
||||
with client.stream("POST", CHAT_STREAM_PATH,
|
||||
json={"message": PROMPT, "session": session_id}) as response:
|
||||
assert response.status_code == 200
|
||||
for line in response.iter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = line[len("data: "):].strip()
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if "delta" in event:
|
||||
deltas.append(str(event["delta"]))
|
||||
return "".join(deltas)
|
||||
|
||||
|
||||
def test_a_blind_comparison_streams_and_reveals(client, stub_endpoint):
|
||||
started = client.post(f"{COMPARE_PATH}/start", data={
|
||||
"prompt": PROMPT,
|
||||
"model_a": MODEL_PRIMARY,
|
||||
"model_b": MODEL_SECONDARY,
|
||||
"endpoint_a_id": stub_endpoint,
|
||||
"endpoint_b_id": stub_endpoint,
|
||||
"is_blind": "true",
|
||||
})
|
||||
assert started.status_code == 200, started.text
|
||||
comparison = started.json()
|
||||
comparison_id = comparison["id"]
|
||||
|
||||
# Blind: the start response must not say which model is on which side.
|
||||
assert not comparison.get("model_left"), comparison
|
||||
assert not comparison.get("model_right"), comparison
|
||||
|
||||
left = _stream_text(client, comparison["session_left"])
|
||||
right = _stream_text(client, comparison["session_right"])
|
||||
assert {left, right} == {reply_for(MODEL_PRIMARY), reply_for(MODEL_SECONDARY)}, (left, right)
|
||||
|
||||
voted = client.post(f"{COMPARE_PATH}/{comparison_id}/vote", data={"winner": "left"})
|
||||
assert voted.status_code == 200, voted.text
|
||||
revealed = voted.json().get("revealed") or {}
|
||||
assert revealed.get("left") in (MODEL_PRIMARY, MODEL_SECONDARY), voted.text
|
||||
assert reply_for(revealed["left"]) == left, (revealed, left)
|
||||
assert reply_for(revealed["right"]) == right, (revealed, right)
|
||||
|
||||
history = client.get(f"{COMPARE_PATH}/history")
|
||||
assert history.status_code == 200, history.text
|
||||
entries = [row for row in history.json() if row.get("id") == comparison_id]
|
||||
assert entries, history.text
|
||||
assert entries[0].get("winner"), entries[0]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Cookbook: hardware is detected and the recommendations are sized against it.
|
||||
|
||||
What the README advertises here is hardware-aware recommendation, and
|
||||
that is exactly the part that runs offline. Downloading and serving a
|
||||
model is left to the gap list: it needs tmux, a GPU runtime and several
|
||||
gigabytes over the network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
SYSTEM_PATH = "/api/hwfit/system"
|
||||
MODELS_PATH = "/api/hwfit/models"
|
||||
STATE_PATH = "/api/cookbook/state"
|
||||
GPUS_PATH = "/api/cookbook/gpus"
|
||||
|
||||
STATE_MARKER = "odysseusSmokeMarker"
|
||||
|
||||
|
||||
def test_hardware_is_detected(client):
|
||||
response = client.get(SYSTEM_PATH)
|
||||
assert response.status_code == 200, response.text
|
||||
system = response.json()
|
||||
assert (system.get("total_ram_gb") or 0) > 0, system
|
||||
assert (system.get("cpu_cores") or 0) > 0, system
|
||||
assert system.get("cpu_name"), system
|
||||
|
||||
gpus = client.get(GPUS_PATH)
|
||||
assert gpus.status_code == 200, gpus.text
|
||||
assert gpus.json().get("ok") is True, gpus.text
|
||||
|
||||
|
||||
def test_recommendations_fit_the_detected_hardware(client):
|
||||
response = client.get(MODELS_PATH)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
system = body.get("system") or {}
|
||||
assert system.get("cpu_name"), body
|
||||
recommended = body.get("models") or body.get("recommendations") or []
|
||||
assert recommended, f"no model recommendation for this hardware: {list(body)}"
|
||||
|
||||
|
||||
def test_cookbook_state_persists(client):
|
||||
written = client.post(STATE_PATH, json={STATE_MARKER: "ody-95"})
|
||||
assert written.status_code == 200, written.text
|
||||
assert written.json().get("ok") is True, written.text
|
||||
|
||||
read = client.get(STATE_PATH)
|
||||
assert read.status_code == 200, read.text
|
||||
assert read.json().get(STATE_MARKER) == "ody-95", read.text
|
||||
|
||||
client.post(STATE_PATH, json={})
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Documents (RAG): an uploaded file is chunked, indexed and then listed.
|
||||
|
||||
This is the one area whose dependency is not satisfiable from a clean
|
||||
checkout. `requirements.txt` pins `chromadb-client`, the HTTP client;
|
||||
the ChromaDB *server* is a separate install, and without one reachable
|
||||
the app returns a deliberate 503 from the upload route rather than
|
||||
indexing into nothing. So the scenario skips with that reason printed in
|
||||
the table instead of being quietly dropped - a row saying SKIP and why
|
||||
is the honest report, and it goes green as soon as a vector service is
|
||||
there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
PERSONAL_PATH = "/api/personal"
|
||||
UPLOAD_PATH = "/api/personal/upload"
|
||||
|
||||
# The route uniquifies the stored name and lists it under the owner's
|
||||
# upload dir, so assertions match on the stem rather than the filename.
|
||||
STEM = "odysseus-smoke-corpus"
|
||||
FILENAME = f"{STEM}.txt"
|
||||
CONTENT = (
|
||||
"The release smoke suite indexed this file. "
|
||||
"It exists so the retrieval path has something deterministic to chunk."
|
||||
)
|
||||
# The route's own 503 text when no vector store answers.
|
||||
UNAVAILABLE_MARKER = "RAG system is not available"
|
||||
|
||||
|
||||
def test_an_uploaded_file_is_indexed_and_listed(client):
|
||||
response = client.post(UPLOAD_PATH,
|
||||
files={"files": (FILENAME, CONTENT.encode("utf-8"), "text/plain")})
|
||||
if response.status_code == 503 and UNAVAILABLE_MARKER in response.text:
|
||||
pytest.skip(
|
||||
"no vector service reachable, so indexing is unavailable. "
|
||||
"requirements.txt pins chromadb-client, not the server; install "
|
||||
"chromadb in the venv and re-run to cover this area."
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
try:
|
||||
assert body.get("indexed_count", 0) > 0, f"nothing was indexed: {body}"
|
||||
assert body.get("failed_count", 1) == 0, f"a chunk failed to index: {body}"
|
||||
assert FILENAME in (body.get("uploaded") or []), body
|
||||
|
||||
listed = client.get(PERSONAL_PATH)
|
||||
assert listed.status_code == 200, listed.text
|
||||
names = [str(f.get("name")) for f in listed.json().get("files") or []]
|
||||
assert any(STEM in name for name in names), names
|
||||
finally:
|
||||
listed = client.get(PERSONAL_PATH).json().get("files") or []
|
||||
for entry in listed:
|
||||
if STEM in str(entry.get("name")):
|
||||
client.request("DELETE", "/api/personal/file",
|
||||
params={"filepath": entry.get("path")})
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Documents: the editor's create, edit and version history survive a round trip."""
|
||||
from __future__ import annotations
|
||||
|
||||
DOCUMENT_PATH = "/api/document"
|
||||
LIBRARY_PATH = "/api/documents/library"
|
||||
|
||||
TITLE = "Odysseus smoke document"
|
||||
FIRST = "First revision, written by the release smoke suite."
|
||||
SECOND = "Second revision, written by the release smoke suite."
|
||||
|
||||
|
||||
def test_a_document_round_trips_with_its_versions(client):
|
||||
created = client.post(DOCUMENT_PATH, json={"title": TITLE, "content": FIRST})
|
||||
assert created.status_code == 200, created.text
|
||||
body = created.json()
|
||||
doc_id = body["id"]
|
||||
try:
|
||||
assert body.get("current_content") == FIRST, body
|
||||
assert body.get("version_count") == 1, body
|
||||
|
||||
library = client.get(LIBRARY_PATH)
|
||||
assert library.status_code == 200, library.text
|
||||
assert doc_id in [d.get("id") for d in library.json().get("documents") or []]
|
||||
|
||||
# `force_version` because a save inside the route's coalesce
|
||||
# window updates the current version in place instead of adding
|
||||
# one - which is right for autosave and would make a smoke check
|
||||
# that edits immediately depend on the clock.
|
||||
edited = client.put(f"{DOCUMENT_PATH}/{doc_id}",
|
||||
json={"content": SECOND, "force_version": True})
|
||||
assert edited.status_code == 200, edited.text
|
||||
assert edited.json().get("current_content") == SECOND, edited.text
|
||||
assert edited.json().get("version_count") == 2, edited.text
|
||||
|
||||
versions = client.get(f"{DOCUMENT_PATH}/{doc_id}/versions")
|
||||
assert versions.status_code == 200, versions.text
|
||||
contents = {v.get("version_number"): v.get("content") for v in versions.json()}
|
||||
assert contents.get(1) == FIRST, contents
|
||||
assert contents.get(2) == SECOND, contents
|
||||
|
||||
restored = client.post(f"{DOCUMENT_PATH}/{doc_id}/restore/1")
|
||||
assert restored.status_code == 200, restored.text
|
||||
assert client.get(f"{DOCUMENT_PATH}/{doc_id}").json()["current_content"] == FIRST
|
||||
finally:
|
||||
removed = client.delete(f"{DOCUMENT_PATH}/{doc_id}")
|
||||
assert removed.status_code == 200, removed.text
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Email: the inbox lists a seeded message, opens it, and marks it read.
|
||||
|
||||
Email is the one area with no way to reach a real account deterministically,
|
||||
and the repo already solved that: `routes/email_routes.py` carries a
|
||||
fixture path gated on `ODYSSEUS_EMAIL_FIXTURE=1` plus a fixture file in
|
||||
the data dir. This uses that mechanism rather than inventing a second
|
||||
one - which means it also only covers what the fixture covers. Real IMAP
|
||||
sync and SMTP send stay out, and say so in the table's gap list.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
LIST_PATH = "/api/email/list"
|
||||
READ_PATH = "/api/email/read"
|
||||
MARK_READ_PATH = "/api/email/mark-read"
|
||||
UNREAD_STATE_PATH = "/api/email/unread-state"
|
||||
|
||||
# The filename the fixture path reads. Same value as
|
||||
# routes/email_routes.py's `_fixture_email_file`.
|
||||
FIXTURE_FILENAME = "fixture_email_messages.json"
|
||||
|
||||
SUBJECT = "Odysseus smoke inbox message"
|
||||
BODY = "Body of the smoke fixture message."
|
||||
SENDER = "Smoke Sender <smoke@example.invalid>"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_inbox(client, account):
|
||||
"""Write the fixture inbox, and put back whatever was there before.
|
||||
|
||||
The flag itself has to be in the app's environment, which is the
|
||||
launcher's job; if it is missing the fixture path stays off and the
|
||||
list route falls through to a real account that does not exist. That
|
||||
reads as a skip, not a failure.
|
||||
"""
|
||||
path = Path(DATA_DIR) / FIXTURE_FILENAME
|
||||
previous = path.read_bytes() if path.exists() else None
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"messages": [{
|
||||
"owner": account["username"],
|
||||
"from": SENDER,
|
||||
"subject": SUBJECT,
|
||||
"date": "2026-09-29T12:00:00+00:00",
|
||||
"body": BODY,
|
||||
}]}, indent=2) + "\n", encoding="utf-8")
|
||||
try:
|
||||
yield path
|
||||
finally:
|
||||
if previous is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
path.write_bytes(previous)
|
||||
|
||||
|
||||
def _fixture_rows(client):
|
||||
response = client.get(LIST_PATH, params={"folder": "INBOX", "limit": 10})
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
rows = [e for e in body.get("emails") or [] if e.get("subject") == SUBJECT]
|
||||
if not rows:
|
||||
pytest.skip(
|
||||
"the instance is not serving the email fixture, so there is no "
|
||||
"deterministic inbox to read. Boot it with ODYSSEUS_EMAIL_FIXTURE=1 "
|
||||
"(scripts/odysseus-smoke does)."
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def test_the_inbox_lists_opens_and_marks_a_message(client, seeded_inbox):
|
||||
row = _fixture_rows(client)[0]
|
||||
uid = row["uid"]
|
||||
assert row.get("from_address") == "smoke@example.invalid", row
|
||||
assert row.get("is_read") is False, row
|
||||
|
||||
read = client.get(f"{READ_PATH}/{uid}", params={"folder": "INBOX"})
|
||||
assert read.status_code == 200, read.text
|
||||
opened = read.json()
|
||||
assert opened.get("subject") == SUBJECT, opened
|
||||
assert BODY in str(opened.get("body") or ""), opened
|
||||
assert BODY in str(opened.get("body_html") or ""), opened
|
||||
|
||||
before = client.get(UNREAD_STATE_PATH, params={"folder": "INBOX"})
|
||||
assert before.status_code == 200, before.text
|
||||
assert before.json().get("unread_count") == 1, before.text
|
||||
|
||||
marked = client.post(f"{MARK_READ_PATH}/{uid}", params={"folder": "INBOX"})
|
||||
assert marked.status_code == 200, marked.text
|
||||
|
||||
after = client.get(UNREAD_STATE_PATH, params={"folder": "INBOX"})
|
||||
assert after.json().get("unread_count") == 0, after.text
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Memory: a stored fact is listed, found by search, and gone after delete.
|
||||
|
||||
Keyword mode is enough here on purpose. The memory store degrades to
|
||||
keyword matching when no vector service answers, and that degraded path
|
||||
is the one a clean checkout actually runs, so it is the one worth
|
||||
smoking.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
MEMORY_PATH = "/api/memory"
|
||||
ADD_PATH = "/api/memory/add"
|
||||
SEARCH_PATH = "/api/memory/search"
|
||||
|
||||
# A token that cannot collide with a real memory on a scratch instance.
|
||||
TOKEN = "odysseus-smoke-marker-quintile"
|
||||
TEXT = f"The release smoke suite stored the token {TOKEN} as a fact."
|
||||
|
||||
|
||||
def test_a_memory_round_trips(client):
|
||||
created = client.post(ADD_PATH, json={"text": TEXT, "category": "fact"})
|
||||
assert created.status_code == 200, created.text
|
||||
assert created.json().get("ok") is True, created.text
|
||||
|
||||
listed = client.get(MEMORY_PATH)
|
||||
assert listed.status_code == 200, listed.text
|
||||
matching = [m for m in listed.json().get("memory") or [] if TOKEN in str(m.get("text"))]
|
||||
assert matching, [m.get("text") for m in listed.json().get("memory") or []]
|
||||
memory_id = matching[0]["id"]
|
||||
|
||||
try:
|
||||
found = client.post(SEARCH_PATH, data={"query": TOKEN})
|
||||
assert found.status_code == 200, found.text
|
||||
hits = [m for m in found.json().get("memories") or [] if TOKEN in str(m.get("text"))]
|
||||
assert hits, found.text
|
||||
finally:
|
||||
removed = client.delete(f"{MEMORY_PATH}/{memory_id}")
|
||||
assert removed.status_code == 200, removed.text
|
||||
|
||||
remaining = client.get(MEMORY_PATH).json().get("memory") or []
|
||||
assert memory_id not in [m.get("id") for m in remaining]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Notes: a note created through the API is readable, editable and gone after delete."""
|
||||
from __future__ import annotations
|
||||
|
||||
NOTES_PATH = "/api/notes"
|
||||
|
||||
TITLE = "Odysseus smoke note"
|
||||
BODY = "Created by the release smoke suite."
|
||||
EDITED_BODY = "Edited by the release smoke suite."
|
||||
|
||||
|
||||
def test_a_note_round_trips(client):
|
||||
created = client.post(NOTES_PATH, json={"title": TITLE, "content": BODY})
|
||||
assert created.status_code == 200, created.text
|
||||
note_id = created.json()["id"]
|
||||
try:
|
||||
listed = client.get(NOTES_PATH)
|
||||
assert listed.status_code == 200, listed.text
|
||||
titles = [n.get("title") for n in listed.json().get("notes") or []]
|
||||
assert TITLE in titles, titles
|
||||
|
||||
read = client.get(f"{NOTES_PATH}/{note_id}")
|
||||
assert read.status_code == 200, read.text
|
||||
assert read.json().get("content") == BODY, read.text
|
||||
|
||||
edited = client.put(f"{NOTES_PATH}/{note_id}",
|
||||
json={"title": TITLE, "content": EDITED_BODY})
|
||||
assert edited.status_code == 200, edited.text
|
||||
assert client.get(f"{NOTES_PATH}/{note_id}").json()["content"] == EDITED_BODY
|
||||
finally:
|
||||
removed = client.delete(f"{NOTES_PATH}/{note_id}")
|
||||
assert removed.status_code == 200, removed.text
|
||||
assert client.get(f"{NOTES_PATH}/{note_id}").status_code == 404
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Settings: a preference written through the API survives a new login.
|
||||
|
||||
Reading the value back on the same cookie only proves the handler
|
||||
answered. Reading it back after authenticating again is what proves it
|
||||
was persisted rather than held in the session, which is the closest an
|
||||
API-level check gets to the user reloading the page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
PREFS_PATH = "/api/prefs"
|
||||
|
||||
KEY = "odysseus_smoke_preference"
|
||||
VALUE = "set-by-the-release-smoke-suite"
|
||||
|
||||
|
||||
def test_a_preference_survives_a_new_login(client, fresh_client):
|
||||
written = client.put(f"{PREFS_PATH}/{KEY}", json={"value": VALUE})
|
||||
assert written.status_code == 200, written.text
|
||||
assert written.json().get("value") == VALUE, written.text
|
||||
|
||||
read = client.get(f"{PREFS_PATH}/{KEY}")
|
||||
assert read.status_code == 200, read.text
|
||||
assert read.json().get("value") == VALUE, read.text
|
||||
|
||||
reloaded = fresh_client.get(f"{PREFS_PATH}/{KEY}")
|
||||
assert reloaded.status_code == 200, reloaded.text
|
||||
assert reloaded.json().get("value") == VALUE, reloaded.text
|
||||
|
||||
listed = fresh_client.get(PREFS_PATH)
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert listed.json().get(KEY) == VALUE, listed.text
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Tasks: a scheduled task is created with a computed next run and is listed.
|
||||
|
||||
Deliberately not fired. Running a task is model and tool work the
|
||||
checkpoint benchmark covers; what this asserts is that the scheduler
|
||||
still accepts a task and computes when it should run, which is the part
|
||||
a route move can break silently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
TASKS_PATH = "/api/tasks"
|
||||
|
||||
NAME = "Odysseus smoke task"
|
||||
SCHEDULED_TIME = "03:00"
|
||||
|
||||
|
||||
def test_a_scheduled_task_round_trips(client):
|
||||
created = client.post(TASKS_PATH, json={
|
||||
"name": NAME,
|
||||
"task_type": "llm",
|
||||
"prompt": "Smoke task; never run by this suite.",
|
||||
"trigger_type": "schedule",
|
||||
"schedule": "daily",
|
||||
"scheduled_time": SCHEDULED_TIME,
|
||||
})
|
||||
assert created.status_code == 200, created.text
|
||||
body = created.json()
|
||||
task_id = body["id"]
|
||||
try:
|
||||
assert body.get("next_run"), f"no next run computed for a daily task: {body}"
|
||||
assert body.get("status") == "active", body
|
||||
|
||||
listed = client.get(TASKS_PATH)
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert task_id in [t.get("id") for t in listed.json().get("tasks") or []]
|
||||
|
||||
paused = client.post(f"{TASKS_PATH}/{task_id}/pause")
|
||||
assert paused.status_code == 200, paused.text
|
||||
assert client.get(f"{TASKS_PATH}/{task_id}").json().get("status") == "paused"
|
||||
finally:
|
||||
removed = client.delete(f"{TASKS_PATH}/{task_id}")
|
||||
assert removed.status_code == 200, removed.text
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Uploads: a file uploaded through the chat attachment route reads back byte for byte."""
|
||||
from __future__ import annotations
|
||||
|
||||
UPLOAD_PATH = "/api/upload"
|
||||
STATS_PATH = "/api/upload/stats"
|
||||
|
||||
FILENAME = "odysseus-smoke-attachment.txt"
|
||||
CONTENT = b"Uploaded by the release smoke suite."
|
||||
|
||||
|
||||
def test_an_upload_reads_back_unchanged(client):
|
||||
response = client.post(UPLOAD_PATH,
|
||||
files={"files": (FILENAME, CONTENT, "text/plain")})
|
||||
assert response.status_code == 200, response.text
|
||||
files = response.json().get("files") or []
|
||||
assert len(files) == 1, response.text
|
||||
entry = files[0]
|
||||
assert entry.get("name") == FILENAME, entry
|
||||
assert entry.get("size") == len(CONTENT), entry
|
||||
|
||||
fetched = client.get(f"{UPLOAD_PATH}/{entry['id']}")
|
||||
assert fetched.status_code == 200, fetched.text
|
||||
assert fetched.content == CONTENT, fetched.content
|
||||
|
||||
stats = client.get(STATS_PATH)
|
||||
assert stats.status_code == 200, stats.text
|
||||
assert stats.json().get("total_files", 0) >= 1, stats.text
|
||||
@@ -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,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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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]
|
||||
@@ -32,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
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_model_picker_source_invariants():
|
||||
|
||||
|
||||
def test_composer_reasoning_effort_ui_markup():
|
||||
"""Verify static/index.html and static/style.css include reasoning effort controls."""
|
||||
"""Verify static/index.html and the app stylesheet cascade include reasoning effort controls."""
|
||||
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
# HTML elements
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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
|
||||
@@ -65,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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ PUBLIC_GUIDES = {
|
||||
"agent-migration.md",
|
||||
"attachments.md",
|
||||
"backup-restore.md",
|
||||
"configuration-reference.md",
|
||||
"email-outlook.md",
|
||||
"pr-blocker-audit.md",
|
||||
"security-ci.md",
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
|
||||
@@ -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);"
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
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")
|
||||
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")
|
||||
|
||||
@@ -10,10 +10,10 @@ 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")
|
||||
DOC_JS = document_source()
|
||||
STYLE_CSS = app_css()
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ 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():
|
||||
|
||||
@@ -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
|
||||
@@ -5,10 +5,10 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
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_CSS = app_css()
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ 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():
|
||||
|
||||
@@ -5,10 +5,10 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ 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():
|
||||
|
||||
@@ -4,16 +4,15 @@ 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
|
||||
|
||||
@@ -7,10 +7,11 @@ 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()
|
||||
|
||||
|
||||
@@ -48,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
|
||||
|
||||
|
||||
@@ -4,16 +4,15 @@ 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
|
||||
|
||||
@@ -6,20 +6,17 @@ 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
|
||||
|
||||
@@ -6,10 +6,11 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -5,26 +5,20 @@ 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")
|
||||
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
|
||||
|
||||
@@ -4,19 +4,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_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
|
||||
|
||||
@@ -4,19 +4,18 @@ 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]
|
||||
|
||||
@@ -4,19 +4,18 @@ 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
|
||||
@@ -25,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
|
||||
|
||||
@@ -4,16 +4,15 @@ 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]
|
||||
|
||||
@@ -4,16 +4,15 @@ 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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,10 +5,10 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,10 +4,11 @@ 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():
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
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_CSS = app_css()
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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()
|
||||
script = document_source()
|
||||
styles = app_css()
|
||||
|
||||
leading = script.index('class="md-toolbar-leading-controls"')
|
||||
|
||||
@@ -4,10 +4,11 @@ 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():
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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()
|
||||
source = email_library_source()
|
||||
css = app_css()
|
||||
|
||||
assert 'class="email-meta-date-group"' in source
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']))}"
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Guards for the generated ODYSSEUS_* configuration reference.
|
||||
|
||||
`website/configuration-reference.md` is produced by
|
||||
`scripts/generate_env_reference.py`. The point of these tests is that adding a
|
||||
new `ODYSSEUS_*` read without documenting it fails the suite: the current state -
|
||||
most of the configuration surface undiscoverable - happened because nothing
|
||||
objected.
|
||||
|
||||
The generator's detection is exercised against a synthetic source tree rather
|
||||
than against the real one, so a test failure names a behavior rather than a
|
||||
count that drifted. Only the two whole-tree tests touch the repository, and they
|
||||
compare generator output to the committed page instead of asserting on source
|
||||
text.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.cli_loader import load_script
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PAGE = REPO / "website" / "configuration-reference.md"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def generator():
|
||||
return load_script("generate_env_reference.py")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def built(generator):
|
||||
"""The generator run once against the real tree; reused by the slow tests."""
|
||||
return generator.build()
|
||||
|
||||
|
||||
def _write(root: Path, relative: str, body: str) -> None:
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_tree(tmp_path, generator, monkeypatch):
|
||||
"""A miniature source tree covering every read pattern the generator claims."""
|
||||
monkeypatch.setattr(generator, "SOURCE_ROOTS", ("app.py", "src"))
|
||||
_write(tmp_path, "app.py", """
|
||||
import os
|
||||
|
||||
DIRECT = os.environ.get("ODYSSEUS_DIRECT_GET", "on")
|
||||
GETENV = os.getenv("ODYSSEUS_PLAIN_GETENV")
|
||||
SUBSCRIPT = os.environ["ODYSSEUS_SUBSCRIPT"]
|
||||
SPANNING = os.environ.get(
|
||||
"ODYSSEUS_SPANS_TWO_LINES", "spanned"
|
||||
)
|
||||
os.environ["ODYSSEUS_WRITE_ONLY"] = "1"
|
||||
""")
|
||||
_write(tmp_path, "src/indirect.py", '''
|
||||
import os
|
||||
|
||||
NAME_HELD_IN_CONSTANT = "ODYSSEUS_VIA_CONSTANT"
|
||||
FALLBACK = 7
|
||||
|
||||
value = os.environ.get(NAME_HELD_IN_CONSTANT, FALLBACK)
|
||||
|
||||
|
||||
def read_limit(name, default):
|
||||
"""An env-reader helper: the generator should follow calls to this."""
|
||||
raw = os.getenv(name)
|
||||
return default if raw is None else int(raw)
|
||||
|
||||
|
||||
LIMIT = read_limit("ODYSSEUS_VIA_HELPER", 5 * 1024)
|
||||
|
||||
|
||||
def flag(environ=None):
|
||||
source = os.environ if environ is None else environ
|
||||
return source.get("ODYSSEUS_VIA_MAPPING_ARG", "1")
|
||||
|
||||
|
||||
GENERATED = [
|
||||
"import os",
|
||||
"if os.environ.get('ODYSSEUS_INSIDE_A_STRING'): pass",
|
||||
]
|
||||
''')
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_finds_every_read_pattern_it_claims_to(generator, fake_tree):
|
||||
found = generator.collect(fake_tree)
|
||||
|
||||
assert set(found) == {
|
||||
"ODYSSEUS_DIRECT_GET",
|
||||
"ODYSSEUS_PLAIN_GETENV",
|
||||
"ODYSSEUS_SUBSCRIPT",
|
||||
"ODYSSEUS_SPANS_TWO_LINES",
|
||||
"ODYSSEUS_VIA_CONSTANT",
|
||||
"ODYSSEUS_VIA_HELPER",
|
||||
"ODYSSEUS_VIA_MAPPING_ARG",
|
||||
"ODYSSEUS_INSIDE_A_STRING",
|
||||
}
|
||||
|
||||
|
||||
def test_a_plain_grep_would_miss_what_the_extra_passes_find(generator, fake_tree):
|
||||
"""Pins why the generator is not a one-line grep."""
|
||||
naive = generator.naive_line_scan(fake_tree)
|
||||
found = set(generator.collect(fake_tree))
|
||||
|
||||
assert found - naive == {
|
||||
"ODYSSEUS_SPANS_TWO_LINES",
|
||||
"ODYSSEUS_VIA_CONSTANT",
|
||||
"ODYSSEUS_VIA_HELPER",
|
||||
"ODYSSEUS_VIA_MAPPING_ARG",
|
||||
}
|
||||
# And it over-counts in the other direction: a line-based scan cannot tell a
|
||||
# write from a read, which is why the page reports the intersection.
|
||||
assert naive - found == {"ODYSSEUS_WRITE_ONLY"}
|
||||
|
||||
|
||||
def test_records_defaults_and_locations_from_the_source(generator, fake_tree):
|
||||
found = generator.collect(fake_tree)
|
||||
|
||||
assert found["ODYSSEUS_DIRECT_GET"].primary.default == "'on'"
|
||||
assert found["ODYSSEUS_SPANS_TWO_LINES"].primary.default == "'spanned'"
|
||||
# One level of indirection is resolved: the name and the default both come
|
||||
# from module-level constants.
|
||||
assert found["ODYSSEUS_VIA_CONSTANT"].primary.default == "7"
|
||||
assert found["ODYSSEUS_VIA_HELPER"].primary.default == "5 * 1024"
|
||||
assert found["ODYSSEUS_PLAIN_GETENV"].primary.default is None
|
||||
|
||||
assert found["ODYSSEUS_DIRECT_GET"].primary.location == "app.py:4"
|
||||
assert found["ODYSSEUS_VIA_HELPER"].primary.path == "src/indirect.py"
|
||||
|
||||
|
||||
def test_environ_writes_are_not_reads(generator, fake_tree):
|
||||
assert "ODYSSEUS_WRITE_ONLY" not in generator.collect(fake_tree)
|
||||
|
||||
|
||||
def test_an_undocumented_variable_is_reported(generator, fake_tree):
|
||||
"""The whole point: a new variable with no notes entry must fail loudly."""
|
||||
problems = generator.check_notes(generator.collect(fake_tree))
|
||||
|
||||
assert problems
|
||||
offender = "ODYSSEUS_VIA_HELPER"
|
||||
assert any(offender in problem for problem in problems)
|
||||
assert any("VARIABLE_NOTES" in problem for problem in problems)
|
||||
assert any("src/indirect.py" in problem for problem in problems)
|
||||
|
||||
|
||||
def test_a_stale_notes_entry_is_reported(generator):
|
||||
problems = generator.check_notes({})
|
||||
|
||||
assert len(problems) == len(generator.VARIABLE_NOTES)
|
||||
assert all("no longer read anywhere" in problem for problem in problems)
|
||||
|
||||
|
||||
def test_renders_one_table_row_per_variable(generator, fake_tree, monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
generator.VARIABLE_NOTES,
|
||||
"ODYSSEUS_VIA_HELPER",
|
||||
("Search", generator.USER, "A synthetic limit."),
|
||||
)
|
||||
found = {"ODYSSEUS_VIA_HELPER": generator.collect(fake_tree)["ODYSSEUS_VIA_HELPER"]}
|
||||
|
||||
page = generator.render(found, generator.naive_line_scan(fake_tree))
|
||||
|
||||
assert page.startswith("---\nlayout: default\n---\n")
|
||||
assert "| `ODYSSEUS_VIA_HELPER` | `5 * 1024` | `src/indirect.py:16` | A synthetic limit. |" in page
|
||||
assert "### Search" in page
|
||||
|
||||
|
||||
def test_every_variable_read_in_the_repository_is_documented(built):
|
||||
_, variables, problems = built
|
||||
|
||||
assert not problems, "\n".join(problems)
|
||||
assert variables, "expected the generator to find ODYSSEUS_* reads"
|
||||
|
||||
|
||||
def test_committed_page_matches_the_source(built):
|
||||
page, _, _ = built
|
||||
|
||||
assert PAGE.read_text(encoding="utf-8") == page, (
|
||||
"website/configuration-reference.md is stale - regenerate it with "
|
||||
"`python3 scripts/generate_env_reference.py`"
|
||||
)
|
||||
|
||||
|
||||
def test_page_has_no_emoji_or_other_non_ascii(built):
|
||||
page, _, _ = built
|
||||
|
||||
offenders = sorted({character for character in page if ord(character) > 126})
|
||||
assert not offenders, f"non-ASCII in the generated page: {offenders}"
|
||||
|
||||
|
||||
def test_check_mode_reports_a_stale_page(generator, tmp_path, monkeypatch):
|
||||
stale = tmp_path / "configuration-reference.md"
|
||||
stale.write_text("out of date\n", encoding="utf-8")
|
||||
monkeypatch.setattr(generator, "OUTPUT_PATH", stale)
|
||||
|
||||
assert generator.main(["--check"]) == 1
|
||||
assert generator.main([]) == 0
|
||||
assert generator.main(["--check"]) == 0
|
||||
|
||||
|
||||
def test_script_runs_as_a_subprocess_without_importing_the_app():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "scripts/generate_env_reference.py", "--check"],
|
||||
cwd=REPO, capture_output=True, text=True, timeout=180,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert re.search(r"\d+ variables", result.stdout), result.stdout
|
||||
|
||||
|
||||
def test_page_is_linked_from_the_places_a_reader_starts():
|
||||
assert "configuration-reference.md" in (REPO / "website" / "setup.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "configuration-reference.md" in (REPO / ".env.example").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -6,7 +6,12 @@ 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
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_known_imap_mailbox_call_sites_are_quoted():
|
||||
assert 'conn.uid("MOVE", _b(uid), dest_folder)' not in mcp
|
||||
assert 'conn.uid("COPY", _b(uid), dest_folder)' not in mcp
|
||||
|
||||
pollers = Path("routes/email_pollers.py").read_text()
|
||||
pollers = Path("routes/email/email_pollers.py").read_text()
|
||||
assert "conn.select(sent_name" not in pollers
|
||||
assert "imap.append(sent_folder" not in pollers
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Regression guards for markdown raw-HTML sanitizer helpers."""
|
||||
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
@@ -27,7 +28,7 @@ def test_markdown_raw_html_sanitizer_strips_scriptable_css():
|
||||
|
||||
def test_email_rich_body_render_path_reuses_raw_html_sanitizer():
|
||||
markdown_src = (_REPO / "static" / "js" / "markdown.js").read_text(encoding="utf-8")
|
||||
document_src = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
|
||||
document_src = document_source()
|
||||
email_body_helper = document_src.split("function _emailBodyToHtml(text)", 1)[1].split(
|
||||
" // Mirror the rich body's plain text", 1
|
||||
)[0]
|
||||
|
||||
@@ -15,6 +15,7 @@ import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
@@ -401,7 +402,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
|
||||
|
||||
def test_pdf_export_typesets_its_container_before_html2pdf():
|
||||
"""Ordering in a call site, so pin the call site. No node needed."""
|
||||
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
|
||||
assert match, "exportAsPdf not found"
|
||||
body = match.group(1)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -12,7 +13,7 @@ def test_opening_notes_minimizes_open_document():
|
||||
|
||||
|
||||
def test_opening_document_minimizes_notes():
|
||||
script = (ROOT / "static/js/document.js").read_text()
|
||||
script = document_source()
|
||||
|
||||
assert "function _minimizeNotesForDocumentOpen()" in script
|
||||
assert "window.notesModule.closePanel('down')" in script
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.stylesheets import app_css, stylesheet_cache_version
|
||||
from tests.helpers.js_modules import email_library_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -54,9 +55,8 @@ def test_notes_mobile_checklist_asset_versions_are_bumped():
|
||||
|
||||
assert re.search(r"notes\.js\?v=[A-Za-z0-9_-]+", app)
|
||||
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]
|
||||
|
||||
|
||||
def test_drawing_edits_mark_notes_dirty_and_keep_one_gallery_image():
|
||||
@@ -72,7 +72,7 @@ def test_drawing_edits_mark_notes_dirty_and_keep_one_gallery_image():
|
||||
|
||||
|
||||
def test_calendar_email_attachments_have_a_calendar_import_action():
|
||||
email = (ROOT / "static" / "js" / "emailLibrary.js").read_text(encoding="utf-8")
|
||||
email = email_library_source()
|
||||
calendar = (ROOT / "static" / "js" / "calendar.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "email-attachment-calendar-open" in email
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_pdf_backed_documents_do_not_offer_destructive_html_pdf_export():
|
||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
|
||||
assert "if (!isForm) {" in source
|
||||
assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF'" in source
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_preview_hides_import_action_and_restores_it_for_empty_editor():
|
||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
|
||||
assert "const emptyImport = document.getElementById('doc-rich-empty-import');" in source
|
||||
assert "if (emptyImport) emptyImport.style.display = 'none';" in source
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Behaviour of scripts/ref_parity_audit.py.
|
||||
|
||||
The audit answers one question: which commits on a source ref left no trace in a
|
||||
target ref? The interesting cases are the ones a plain `git log A..B` gets wrong
|
||||
-- a change that was reproduced on the target under a different SHA must read as
|
||||
present, and a change whose lines were merely re-indented must not read as
|
||||
missing. Those are exercised against a real throwaway repository rather than a
|
||||
fake, because the verdicts come from git's own `grep` and `diff`.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.cli_loader import load_script
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def audit():
|
||||
return load_script("ref_parity_audit.py")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# probe selection
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_short_and_thin_lines_are_not_probes(audit):
|
||||
assert not audit.is_probe_candidate("pass")
|
||||
assert not audit.is_probe_candidate("})")
|
||||
# Long enough, but nothing named in it: a separator comment is in every file.
|
||||
assert not audit.is_probe_candidate("# " + "-" * 60)
|
||||
assert audit.is_probe_candidate("_hosts_cache_time = now # remember the empty answer")
|
||||
|
||||
|
||||
def test_probes_are_ranked_by_distinctiveness_and_capped(audit):
|
||||
lines = [
|
||||
("src/a.py", " x = 1"),
|
||||
("src/a.py", " parsed_args = json.loads(args) if args else []"),
|
||||
("src/a.py", " if not isinstance(parsed_args, list): raise HTTPException(400)"),
|
||||
("src/a.py", " return parsed_args"),
|
||||
]
|
||||
|
||||
probes = audit.pick_probes(lines, limit=2)
|
||||
|
||||
assert probes == [
|
||||
"if not isinstance(parsed_args, list): raise HTTPException(400)",
|
||||
"parsed_args = json.loads(args) if args else []",
|
||||
]
|
||||
|
||||
|
||||
def test_probes_are_stripped_so_a_reindented_port_still_counts(audit):
|
||||
probes = audit.pick_probes([("src/a.py", "\t\tself.cache_time = now # keep the empty answer")], limit=4)
|
||||
|
||||
assert probes == ["self.cache_time = now # keep the empty answer"]
|
||||
|
||||
|
||||
def test_identical_added_lines_yield_one_probe(audit):
|
||||
lines = [
|
||||
("src/a.py", " raise HTTPException(400, 'args must be a JSON array')"),
|
||||
("src/b.py", " raise HTTPException(400, 'args must be a JSON array')"),
|
||||
]
|
||||
|
||||
assert len(audit.pick_probes(lines, limit=4)) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# path exclusion and diff parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path, excluded",
|
||||
[
|
||||
("static/lib/marked.js", True),
|
||||
("static/js/chat.min.js", True),
|
||||
("package-lock.json", True),
|
||||
("assets/logo.png", True),
|
||||
("src/model_discovery.py", False),
|
||||
("static/js/chat.js", False),
|
||||
],
|
||||
)
|
||||
def test_default_excludes_cover_vendored_and_binary_paths(audit, path, excluded):
|
||||
assert audit.is_excluded(path, audit.DEFAULT_EXCLUDES) is excluded
|
||||
|
||||
|
||||
def test_added_lines_skips_headers_deletions_and_excluded_paths(audit):
|
||||
patch = "\n".join(
|
||||
[
|
||||
"diff --git a/src/a.py b/src/a.py",
|
||||
"--- a/src/a.py",
|
||||
"+++ b/src/a.py",
|
||||
"@@ -1 +1 @@",
|
||||
"-old_line_that_is_long_enough()",
|
||||
"+new_line_that_is_long_enough()",
|
||||
"diff --git a/static/lib/vendor.js b/static/lib/vendor.js",
|
||||
"--- a/static/lib/vendor.js",
|
||||
"+++ b/static/lib/vendor.js",
|
||||
"@@ -1 +1 @@",
|
||||
"+vendored_line_that_is_long_enough()",
|
||||
"diff --git a/src/gone.py b/src/gone.py",
|
||||
"--- a/src/gone.py",
|
||||
"+++ /dev/null",
|
||||
]
|
||||
)
|
||||
|
||||
assert audit.added_lines(patch, audit.DEFAULT_EXCLUDES) == [
|
||||
("src/a.py", "new_line_that_is_long_enough()")
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# end-to-end against a real repository
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
MARKER_ABSENT = "def confine_agent_to_workspace(root_dir, session_store):"
|
||||
MARKER_PORTED = "def cache_the_empty_tailscale_answer(now, hosts_cache_time):"
|
||||
MARKER_KEPT = "def shared_helper_present_on_both_lines(value):"
|
||||
|
||||
|
||||
def _git(repo, *args):
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"-c",
|
||||
"user.name=Audit Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
*args,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_line_repo(tmp_path):
|
||||
"""A repository shaped like the real problem: two branches off one root.
|
||||
|
||||
`source` carries three commits. `target` independently reproduces one of
|
||||
them (re-indented, so only a stripped probe finds it), never sees another,
|
||||
and carries a file of its own plus a vendored file that must stay out of
|
||||
the presence diff.
|
||||
"""
|
||||
if not shutil.which("git"):
|
||||
pytest.skip("git is not available")
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "root")
|
||||
(repo / "base.py").write_text(MARKER_KEPT + "\n return value\n", encoding="utf-8")
|
||||
_git(repo, "add", "base.py")
|
||||
_git(repo, "commit", "-q", "-m", "chore: base")
|
||||
|
||||
_git(repo, "checkout", "-q", "-b", "source")
|
||||
(repo / "confinement.py").write_text(MARKER_ABSENT + "\n return root_dir\n", encoding="utf-8")
|
||||
_git(repo, "add", "confinement.py")
|
||||
_git(repo, "commit", "-q", "-m", "fix: confine the agent to its workspace")
|
||||
|
||||
(repo / "discovery.py").write_text(MARKER_PORTED + "\n return now\n", encoding="utf-8")
|
||||
_git(repo, "add", "discovery.py")
|
||||
_git(repo, "commit", "-q", "-m", "fix: cache an empty lookup")
|
||||
|
||||
(repo / "base.py").write_text(MARKER_KEPT + "\n", encoding="utf-8")
|
||||
_git(repo, "add", "base.py")
|
||||
_git(repo, "commit", "-q", "-m", "refactor: drop the base body")
|
||||
|
||||
_git(repo, "checkout", "-q", "-b", "target", "root")
|
||||
# The same fix, written by hand at a different indentation and in another file.
|
||||
(repo / "net.py").write_text("class Net:\n " + MARKER_PORTED + "\n return now\n", encoding="utf-8")
|
||||
(repo / "target_only.py").write_text("# only on the target line\n", encoding="utf-8")
|
||||
(repo / "static").mkdir()
|
||||
(repo / "static" / "lib").mkdir()
|
||||
(repo / "static" / "lib" / "vendor.min.js").write_text("var a=1;\n", encoding="utf-8")
|
||||
_git(repo, "add", "-A")
|
||||
_git(repo, "commit", "-q", "-m", "feat: reproduce the lookup cache by hand")
|
||||
|
||||
return repo
|
||||
|
||||
|
||||
def _verdicts(report):
|
||||
return {item.commit.subject: item.verdict for item in report.verdicts}
|
||||
|
||||
|
||||
def test_absent_ported_and_deletion_only_commits_are_told_apart(audit, two_line_repo):
|
||||
report = audit.audit(source="source", target="target", repo=two_line_repo)
|
||||
|
||||
assert _verdicts(report) == {
|
||||
"fix: confine the agent to its workspace": audit.VERDICT_ABSENT,
|
||||
"fix: cache an empty lookup": audit.VERDICT_PRESENT,
|
||||
"refactor: drop the base body": audit.VERDICT_NO_PROBE,
|
||||
}
|
||||
|
||||
|
||||
def test_the_absent_commit_reports_the_probes_it_looked_for(audit, two_line_repo):
|
||||
report = audit.audit(source="source", target="target", repo=two_line_repo)
|
||||
absent = report.by_verdict(audit.VERDICT_ABSENT)
|
||||
|
||||
assert [item.commit.subject for item in absent] == ["fix: confine the agent to its workspace"]
|
||||
assert absent[0].probes == (MARKER_ABSENT,)
|
||||
assert absent[0].found == ()
|
||||
assert absent[0].paths == ("confinement.py",)
|
||||
|
||||
|
||||
def test_file_presence_diff_is_exact_and_skips_vendored_paths(audit, two_line_repo):
|
||||
report = audit.audit(source="source", target="target", repo=two_line_repo)
|
||||
|
||||
assert report.source_only_files == ("confinement.py", "discovery.py")
|
||||
assert report.target_only_files == ("net.py", "target_only.py")
|
||||
|
||||
|
||||
def test_probes_are_only_drawn_from_included_paths(audit, two_line_repo):
|
||||
"""Excluding discovery.py leaves its commit with nothing to probe."""
|
||||
report = audit.audit(
|
||||
source="source",
|
||||
target="target",
|
||||
repo=two_line_repo,
|
||||
excludes=list(audit.DEFAULT_EXCLUDES) + ["discovery.py"],
|
||||
)
|
||||
|
||||
assert _verdicts(report)["fix: cache an empty lookup"] == audit.VERDICT_NO_PROBE
|
||||
|
||||
|
||||
def test_a_probe_found_only_partly_reads_as_partial(audit, two_line_repo):
|
||||
# base.py's marker is on both branches; confinement.py's is not. A commit
|
||||
# adding both is the partial case.
|
||||
_git(two_line_repo, "checkout", "-q", "source")
|
||||
(two_line_repo / "mixed.py").write_text(
|
||||
MARKER_KEPT + "\n" + MARKER_ABSENT + "\n", encoding="utf-8"
|
||||
)
|
||||
_git(two_line_repo, "add", "mixed.py")
|
||||
_git(two_line_repo, "commit", "-q", "-m", "fix: a change that half landed")
|
||||
|
||||
report = audit.audit(source="source", target="target", repo=two_line_repo, probe_limit=4)
|
||||
|
||||
assert _verdicts(report)["fix: a change that half landed"] == audit.VERDICT_PARTIAL
|
||||
|
||||
|
||||
def test_first_parent_traversal_diffs_a_merge_against_its_first_parent(audit, two_line_repo):
|
||||
"""A squash-free merge carries its content in the merge commit itself."""
|
||||
_git(two_line_repo, "checkout", "-q", "-b", "side", "root")
|
||||
(two_line_repo / "from_side.py").write_text(MARKER_ABSENT + "\n", encoding="utf-8")
|
||||
_git(two_line_repo, "add", "from_side.py")
|
||||
_git(two_line_repo, "commit", "-q", "-m", "fix: arrived on a side branch")
|
||||
_git(two_line_repo, "checkout", "-q", "source")
|
||||
_git(two_line_repo, "merge", "-q", "--no-ff", "-m", "Merge commit from fork", "side")
|
||||
|
||||
report = audit.audit(
|
||||
source="source", target="target", repo=two_line_repo, traversal="first-parent"
|
||||
)
|
||||
merge = [v for v in report.verdicts if v.commit.subject == "Merge commit from fork"]
|
||||
|
||||
assert len(merge) == 1
|
||||
assert merge[0].commit.parent_count == 2
|
||||
assert merge[0].verdict == audit.VERDICT_ABSENT
|
||||
assert MARKER_ABSENT in merge[0].probes
|
||||
|
||||
|
||||
def test_linear_traversal_reports_the_side_branch_commit_not_the_merge(audit, two_line_repo):
|
||||
_git(two_line_repo, "checkout", "-q", "-b", "side", "root")
|
||||
(two_line_repo / "from_side.py").write_text(MARKER_ABSENT + "\n", encoding="utf-8")
|
||||
_git(two_line_repo, "add", "from_side.py")
|
||||
_git(two_line_repo, "commit", "-q", "-m", "fix: arrived on a side branch")
|
||||
_git(two_line_repo, "checkout", "-q", "source")
|
||||
_git(two_line_repo, "merge", "-q", "--no-ff", "-m", "Merge commit from fork", "side")
|
||||
|
||||
subjects = [
|
||||
v.commit.subject
|
||||
for v in audit.audit(source="source", target="target", repo=two_line_repo).verdicts
|
||||
]
|
||||
|
||||
assert "fix: arrived on a side branch" in subjects
|
||||
assert "Merge commit from fork" not in subjects
|
||||
|
||||
|
||||
def test_date_window_bounds_the_commit_range(audit, two_line_repo):
|
||||
empty = audit.audit(source="source", target="target", repo=two_line_repo, until="2000-01-01")
|
||||
full = audit.audit(source="source", target="target", repo=two_line_repo, since="2000-01-01")
|
||||
|
||||
assert empty.verdicts == []
|
||||
assert len(full.verdicts) == 3
|
||||
# The presence diff is not date-filtered: it compares the two trees.
|
||||
assert empty.source_only_files == full.source_only_files
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# rendering and cli
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_markdown_report_leads_with_the_absent_commits(audit, two_line_repo):
|
||||
text = audit.render_markdown(audit.audit(source="source", target="target", repo=two_line_repo))
|
||||
|
||||
assert "No trace in the target: **1**" in text
|
||||
assert "Fully present: **1**" in text
|
||||
assert "fix: confine the agent to its workspace" in text
|
||||
assert "`confinement.py`" in text
|
||||
|
||||
|
||||
def test_json_output_is_written_to_the_requested_path(audit, two_line_repo, tmp_path, capsys):
|
||||
out = tmp_path / "report.json"
|
||||
|
||||
code = audit.main(
|
||||
[
|
||||
"--source",
|
||||
"source",
|
||||
"--target",
|
||||
"target",
|
||||
"--repo",
|
||||
str(two_line_repo),
|
||||
"--format",
|
||||
"json",
|
||||
"--output",
|
||||
str(out),
|
||||
"--quiet",
|
||||
]
|
||||
)
|
||||
|
||||
assert code == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||
assert payload["totals"] == {"absent": 1, "no-probe": 1, "partial": 0, "present": 1}
|
||||
assert payload["source_only_files"] == ["confinement.py", "discovery.py"]
|
||||
|
||||
|
||||
def test_an_unknown_ref_exits_two_with_a_message(audit, two_line_repo, capsys):
|
||||
code = audit.main(
|
||||
[
|
||||
"--source",
|
||||
"no-such-ref",
|
||||
"--target",
|
||||
"target",
|
||||
"--repo",
|
||||
str(two_line_repo),
|
||||
"--quiet",
|
||||
]
|
||||
)
|
||||
|
||||
assert code == 2
|
||||
assert "error:" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_probe_count_must_be_positive(audit):
|
||||
with pytest.raises(SystemExit):
|
||||
audit.build_parser().parse_args(
|
||||
["--source", "a", "--target", "b", "--probes", "0"]
|
||||
)
|
||||
@@ -3,7 +3,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
|
||||
@@ -78,6 +78,5 @@ def test_research_panel_uses_one_versioned_module_instance():
|
||||
|
||||
panel_version = re.search(r"research/panel\.js\?v=([^'\"]+)", app)
|
||||
asset_version = re.search(r"/static/app\.js\?v=([^'\"]+)", index)
|
||||
style_version = re.search(r"/static/style\.css\?v=([^'\"]+)", index)
|
||||
assert panel_version and renderer.count(f"research/panel.js?v={panel_version.group(1)}") == 2
|
||||
assert asset_version and style_version and asset_version.group(1) == style_version.group(1)
|
||||
assert asset_version and asset_version.group(1) == stylesheet_cache_version()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Execute the actual DOCX handlers with deferred network responses."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
def test_docx_responses_do_not_overwrite_new_tabs_or_hidden_previews():
|
||||
source = (Path(__file__).resolve().parents[1] / "static/js/document.js").read_text()
|
||||
source = document_source()
|
||||
handlers = source.split(" let _docxPreviewRequest = 0;", 1)[1].split(" /** Parse CSV", 1)[0]
|
||||
script = r'''
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user