mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
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:
+24
-11373
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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,6 +2,7 @@ from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.js_modules import email_library_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -32,7 +33,7 @@ def test_calendar_chat_event_links_fetch_uid_and_show_title_time():
|
||||
app_src = (ROOT / "static/app.js").read_text()
|
||||
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
|
||||
inbox_src = (ROOT / "static/js/emailInbox.js").read_text()
|
||||
library_src = (ROOT / "static/js/emailLibrary.js").read_text()
|
||||
library_src = email_library_source()
|
||||
|
||||
assert '@router.get("/events/{uid}")' in routes_src
|
||||
assert "async function _fetchEventByUid" in calendar_src
|
||||
|
||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.js_modules import email_library_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -285,13 +286,10 @@ def test_calendar_tool_guidance_preserves_manual_tags_on_unrelated_updates():
|
||||
|
||||
def test_calendar_visual_asset_versions_are_bumped():
|
||||
versions = []
|
||||
for rel in (
|
||||
"static/app.js",
|
||||
"static/js/chatRenderer.js",
|
||||
"static/js/emailInbox.js",
|
||||
"static/js/emailLibrary.js",
|
||||
):
|
||||
src = (ROOT / rel).read_text()
|
||||
for src in [
|
||||
(ROOT / rel).read_text()
|
||||
for rel in ("static/app.js", "static/js/chatRenderer.js", "static/js/emailInbox.js")
|
||||
] + [email_library_source()]:
|
||||
match = re.search(r"calendar\.js\?v=([A-Za-z0-9_-]+)", src)
|
||||
assert match
|
||||
versions.append(match.group(1))
|
||||
|
||||
@@ -1,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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']))}"
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user