refactor(email): move emailLibrary.js into static/js/emailLibrary/

The email library was 11,375 lines in one file, the second-largest JS
module in the repo. `static/js/emailLibrary/` already held four extracted
helpers, so the package existed; the bulk of the code just was not in it.

The implementation moves to `emailLibrary/index.js` and the old path
becomes a re-export wrapper. Five call sites import that path, four of
them dynamically with a `?v=` string, and `sw.js` caches URLs verbatim,
so a wrapper is what makes the move need no coordinated edit to any of
them.

The test side is the part worth reviewing. 58 tests read
`static/js/emailLibrary.js` as text. Pointing them at
`emailLibrary/index.js` would buy one move and break again on the next
one, which is exactly what happened to the stylesheet tests (they now go
through `tests/helpers/stylesheets.py`). So the same shape:
`tests/helpers/js_modules.py` reads the whole package, and assertions
stop caring which module a function sits in.

`tests/test_email_library_module_graph_js.py` is new. This frontend has
no module-graph validation, and a package fails in ways a single file
cannot: a wrapper that drops an export is `undefined` at call time rather
than an error at load time, and a module that reads a `const` across an
import cycle throws only when that module is entered first. It pins the
wrapper's surface against the entry module's, evaluates every module on
its own in a browser, and requires both import paths to hand out one
instance.

It also caught a live gap while being written:
`emailLibrary/replyRecipients.js` is imported by `emailInbox.js`, an app
shell module, and was never in the `sw.js` precache.
This commit is contained in:
Léo
2026-09-30 09:08:21 +02:00
parent 6105702901
commit ab5a08a6f9
21 changed files with 11729 additions and 11446 deletions
+24 -11373
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v654-css-split-3';
const CACHE_NAME = 'odysseus-v655-email-library-split';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -84,9 +84,12 @@ const PRECACHE = [
'/static/js/slashCommands.js?v=20260921chatgptusage1',
'/static/js/research/jobs.js?v=20260910researcherrorpersist1',
'/static/js/emailInbox.js?v=20260914aireply4',
'/static/js/emailLibrary/index.js',
'/static/js/emailLibrary/utils.js',
'/static/js/emailLibrary/signatureFold.js',
'/static/js/emailLibrary/state.js',
// emailInbox.js has always imported this one; it was never precached.
'/static/js/emailLibrary/replyRecipients.js',
'/static/js/notes.js?v=20260911notesselectioncancel1',
'/static/js/tasks.js?v=20260914taskmodel1',
'/static/js/calendar.js?v=20260914emailsource11',
+50
View File
@@ -0,0 +1,50 @@
"""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
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)
)
+28 -23
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,7 +262,7 @@ 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")
frontend = email_library_source()
backend = (ROOT / "routes/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
@@ -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]
@@ -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
@@ -1,5 +1,6 @@
from pathlib import Path
import re
from tests.helpers.js_modules import email_library_paths
ROOT = Path(__file__).resolve().parents[1]
@@ -42,8 +43,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,4 +1,5 @@
from pathlib import Path
from tests.helpers.js_modules import email_library_paths
ROOT = Path(__file__).resolve().parents[1]
@@ -31,14 +32,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,11 +1,12 @@
from pathlib import Path
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 +18,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
+6 -5
View File
@@ -1,5 +1,6 @@
import sqlite3
from email.message import EmailMessage
from tests.helpers.js_modules import email_library_source
def test_attachment_filename_is_part_of_ui_index_search(tmp_path, monkeypatch):
@@ -89,7 +90,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]
@@ -109,7 +110,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 +160,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 +168,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 +178,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")
+2 -1
View File
@@ -1,11 +1,12 @@
from pathlib import Path
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 = (ROOT / "static" / "style.css").read_text()
assert 'class="email-meta-date-group"' in source
+7 -7
View File
@@ -1,22 +1,22 @@
from pathlib import Path
from tests.helpers.js_modules import email_library_source
_REPO = Path(__file__).resolve().parents[1]
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
_EMAIL_ROUTES = _REPO / "routes" / "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")
text = email_library_source()
start = text.index("async function _bulkAction(action)")
end = text.index("\n}\n\n// _extractName", start) + 3
return text[start:end]
def _function_source(name: str) -> str:
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
text = email_library_source()
start = text.index(f"function {name}")
next_function = text.find("\nfunction ", start + 1)
next_async = text.find("\nasync function ", start + 1)
@@ -50,7 +50,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 +86,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 +118,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 +150,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
+187
View File
@@ -0,0 +1,187 @@
"""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_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
def test_wrapper_re_exports_the_entry_module_surface_exactly():
"""The old path must expose the same names as the package entry module.
Not a subset and not a superset: a missing name breaks a caller silently,
and a name the entry module no longer exports is a load-time error.
"""
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 and static/js/emailLibrary/index.js disagree "
f"on the public surface; only in the wrapper: {sorted(wrapper - entry)}; "
f"only in the entry module: {sorted(entry - wrapper)}"
)
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']))}"
)
+2 -3
View File
@@ -4,14 +4,13 @@ import shutil
import subprocess
import pytest
from tests.helpers.js_modules import email_library_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:
+4 -4
View File
@@ -6,11 +6,11 @@ import subprocess
from pathlib import Path
import pytest
from tests.helpers.js_modules import email_library_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,7 +21,7 @@ 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")
source = email_library_source()
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
assert function.count("/api/email/read/") == 1
@@ -38,7 +38,7 @@ 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")
source = email_library_source()
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
settlements = _extract_between(
function,
@@ -101,7 +101,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
@@ -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 -1
View File
@@ -1,11 +1,12 @@
from pathlib import Path
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
+2 -2
View File
@@ -8,6 +8,7 @@ from pathlib import Path
import pytest
from tests.helpers.js_modules import email_library_paths
import src.tool_capabilities as tool_capabilities
from src.tool_capabilities import (
KNOWN_CAPABILITY_TOOLS,
@@ -1405,11 +1406,10 @@ def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
"static/js/chatStream.js",
"static/js/document.js",
"static/js/emailInbox.js",
"static/js/emailLibrary.js",
"static/js/settings.js",
"static/js/slashCommands.js",
)
]
] + [p.read_text() for p in email_library_paths(include_wrapper=True)]
assert all(
"20260722emailfastindex1" not in source
for source in approval_module_sources
@@ -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().parent.parent
@@ -72,7 +73,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
+2 -2
View File
@@ -18,6 +18,7 @@ import importlib
from pathlib import Path
import pytest
from tests.helpers.js_modules import email_library_source
# ── prompt-injection context wrapper ────────────────────────────
@@ -1009,8 +1010,7 @@ def test_diagnostics_routes_are_admin_gated():
def test_email_thread_rendering_sanitizes_body_html():
"""Both threaded render paths must run server-parsed body_html through the
allowlist sanitizer (the flat path already did)."""
src = Path(__file__).resolve().parents[1] / "static" / "js" / "emailLibrary.js"
text = src.read_text()
text = email_library_source()
# every `t.body_html` reference is wrapped by _sanitizeHtml(...)
assert text.count("t.body_html") == text.count("_sanitizeHtml(t.body_html")
assert "t.body_html" in text # guard against the file being refactored away