Merge lab into feat/admin-build-provenance

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