mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-08 16:02:20 +02:00
merge: reconcile PR 40 with current lab
Integrate lab fff55a78 into PR #40 (cc25d5ba). Lab's modular email backend/frontend, modular settings, split stylesheets (static/style.css stays deleted), procfs compatibility, and request-scoped TurnContract authority win; PR #40's routing classifiers, editor/email/task features, and style.css changes are ported into lab's module and stylesheet homes. Integration fixes: - settings/api.js imports ui.js under its canonical versioned URL - browser observations keep legacy CAPTCHA/access-block evidence - artifact turns do not re-trigger broad-web research recovery - env reference documents PR test-tool variables; page regenerated PR #40 defects surfaced by lab gates and fixed here: - web_fetch generic schema drops top-level anyOf (OpenAI contract); the compact preview contract still requires url or urls - get_weather registered as a brokered network read - new lazy editor modules precached for offline use - SearXNG pin mirrored into GPU standalone compose files - image model picker again skips offline endpoints Tests updated where PR #40 changed behaviour on purpose, and PR tests moved onto lab's document_source helpers.
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
"""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 most 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.openEmailFromTool` in chat.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",
|
||||
"openEmailFromTool",
|
||||
"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']))}"
|
||||
)
|
||||
Reference in New Issue
Block a user