Files
odysseus/tests/helpers/js_modules.py
T
Léo c5db6ae7fd refactor(email): split the email library into seven modules
`emailLibrary/index.js` goes from 11,375 lines to 6,187. What came out:

- `settingsPage.js` (879) — the Email Settings view, its form and controls,
  away/auto-reply including the calendar-event sync, and the display
  preferences. The inline-image preference lives here rather than with the
  renderer because the settings form owns writing it.
- `unsubscribe.js` (1,224) — the bulk-unsubscribe review flow. One exported
  entry point, its own localStorage keys, its own agent-tool-output listeners.
- `reader.js` (676) — opening an email as a docked tab or a floating window,
  plus the AI summary panel both of them share.
- `menus.js` (855) — the reader More menu, the card kebab menu, the bulk
  Actions menu and `_bulkAction`.
- `bodyRender.js` (871) — plain and threaded body rendering, inline MIME
  images, quote folding.
- `attachments.js` (491) — attachment chips and the deferred load for messages
  whose attachment list was not in the list response.
- `aiReply.js` (449) — AI-reply entry points, the per-message context draft,
  the translate and remind submenus.

The extracted modules import back from `index.js`, so the graph has cycles.
That is safe for hoisted function declarations and unsafe for a value read
during evaluation, so nothing crosses a module boundary except functions:
`API_BASE` is re-declared per module, the way `emailInbox.js` and
`emailShared.js` already do it, and `_autoReplyRefreshSeq` moves into
`state.js` because the settings page and the unread-badge refresh both write
it and an imported binding is read-only. The module-graph test enters the
package at each module in turn, which is the order that would expose a
dead-zone read.

Two test helpers grew while doing this. `js_function_source` replaces three
marker-pair slices ("from this signature down to that comment") whose end
marker had moved into another module — the slice ran past the function and
kept passing against the wrong text. Its first implementation balanced braces
by walking characters and ended `_toggleCardPreview` 18 lines early, because
the apostrophe in `// that's a scroll, not a nav` opened a string that ate the
braces after it. It now keys on 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.
2026-09-30 09:26:34 +02:00

84 lines
3.4 KiB
Python

"""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()]