mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-25 07:12:20 +01: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.
|
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||||
// - API / non-GET: never cached.
|
// - API / non-GET: never cached.
|
||||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
// 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
|
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||||
// gives offline math fallback glyphs instead of proper typesetting.
|
// gives offline math fallback glyphs instead of proper typesetting.
|
||||||
@@ -84,9 +84,12 @@ const PRECACHE = [
|
|||||||
'/static/js/slashCommands.js?v=20260921chatgptusage1',
|
'/static/js/slashCommands.js?v=20260921chatgptusage1',
|
||||||
'/static/js/research/jobs.js?v=20260910researcherrorpersist1',
|
'/static/js/research/jobs.js?v=20260910researcherrorpersist1',
|
||||||
'/static/js/emailInbox.js?v=20260914aireply4',
|
'/static/js/emailInbox.js?v=20260914aireply4',
|
||||||
|
'/static/js/emailLibrary/index.js',
|
||||||
'/static/js/emailLibrary/utils.js',
|
'/static/js/emailLibrary/utils.js',
|
||||||
'/static/js/emailLibrary/signatureFold.js',
|
'/static/js/emailLibrary/signatureFold.js',
|
||||||
'/static/js/emailLibrary/state.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/notes.js?v=20260911notesselectioncancel1',
|
||||||
'/static/js/tasks.js?v=20260914taskmodel1',
|
'/static/js/tasks.js?v=20260914taskmodel1',
|
||||||
'/static/js/calendar.js?v=20260914emailsource11',
|
'/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
|
import subprocess
|
||||||
|
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
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/tasks.js": "orderActionMenuItems",
|
||||||
"static/js/sessions.js": "orderActionMenuItems",
|
"static/js/sessions.js": "orderActionMenuItems",
|
||||||
"static/js/research/panel.js": "orderActionMenuItems",
|
"static/js/research/panel.js": "orderActionMenuItems",
|
||||||
"static/js/emailLibrary.js": "orderActionMenuItems",
|
|
||||||
"static/js/memory.js": "orderActionMenuItems",
|
"static/js/memory.js": "orderActionMenuItems",
|
||||||
}
|
}
|
||||||
for relative_path, helper in expected_imports.items():
|
for relative_path, helper in expected_imports.items():
|
||||||
source = (ROOT / relative_path).read_text(encoding="utf-8")
|
source = (ROOT / relative_path).read_text(encoding="utf-8")
|
||||||
assert "actionMenuOrder.js" in source
|
assert "actionMenuOrder.js" in source
|
||||||
assert helper 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:
|
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/sessions.js",
|
||||||
"static/js/skills.js",
|
"static/js/skills.js",
|
||||||
"static/js/tasks.js",
|
"static/js/tasks.js",
|
||||||
"static/js/emailLibrary.js",
|
|
||||||
"static/js/research/panel.js",
|
"static/js/research/panel.js",
|
||||||
):
|
):
|
||||||
module = (ROOT / relative_path).read_text(encoding="utf-8")
|
module = (ROOT / relative_path).read_text(encoding="utf-8")
|
||||||
assert "SELECT_MENU_ICON" in module
|
assert "SELECT_MENU_ICON" in module
|
||||||
|
assert "SELECT_MENU_ICON" in email_library_source()
|
||||||
|
|
||||||
|
|
||||||
def test_email_filter_menu_has_context_title() -> None:
|
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
|
assert 'email-filter-menu-title">Filter by...</div>' in source
|
||||||
|
|
||||||
|
|
||||||
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
|
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()
|
style = app_css()
|
||||||
assert 'email-settings-auto-reply-section' in source
|
assert 'email-settings-auto-reply-section' in source
|
||||||
assert 'email-settings-display-enabled-state' 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:
|
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_start = source.index('id="email-search-options-menu"')
|
||||||
menu_end = source.index("</div>", menu_start) + len("</div>")
|
menu_end = source.index("</div>", menu_start) + len("</div>")
|
||||||
assert 'email-search-options-title">Filter by...</div>' in source[menu_start:menu_end]
|
assert 'email-search-options-title">Filter by...</div>' in source[menu_start:menu_end]
|
||||||
|
|
||||||
|
|
||||||
def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
|
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 "function _emailTimelineGapThreshold(items)" in source
|
||||||
assert "email-date-gap-break" in source
|
assert "email-date-gap-break" in source
|
||||||
assert "gapDays > 90 && gapDays > timelineGapThreshold" 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:
|
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 '<option value="tag:action-needed">' not in source
|
||||||
assert "filter:tag:action-needed" not in source
|
assert "filter:tag:action-needed" not in source
|
||||||
assert "email-card-favorite" 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:
|
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 "function _todayDateInputValue()" in source
|
||||||
assert "if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();" in source
|
assert "if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();" in source
|
||||||
assert "autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);" 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:
|
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 "function _syncAutoReplyCalendarEvent(cfg)" in source
|
||||||
assert "summary: 'Email Auto Reply (away)'" in source
|
assert "summary: 'Email Auto Reply (away)'" in source
|
||||||
assert "function _findAutoReplyCalendarEventUids(cfg, accountId)" 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:
|
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()
|
style = app_css()
|
||||||
assert 'email-account-away-label">(AWAY)</span>' in source
|
assert 'email-account-away-label">(AWAY)</span>' in source
|
||||||
assert 'id="email-lib-auto-reply-badge"' 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:
|
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")]
|
cleanup = source[source.index("function _emailCleanupSettingsHtml"):source.index("function _emailDisplaySettingsHtml")]
|
||||||
assert "email-settings-clean-btn" in cleanup
|
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
|
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:
|
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'))"
|
settings_guard = "if (modal.classList.contains('email-settings-mode'))"
|
||||||
assert settings_guard in source
|
assert settings_guard in source
|
||||||
assert source.index(settings_guard) < source.index("closeEmailLibrary();", source.index(settings_guard))
|
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:
|
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_guard = "if (state._selectMode) {"
|
||||||
select_start = source.index(select_guard, source.index("if (e.key === 'Escape')"))
|
select_start = source.index(select_guard, source.index("if (e.key === 'Escape')"))
|
||||||
assert "_setSelectBtnState(false);" in source[select_start:select_start + 260]
|
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:
|
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")
|
start = source.index("function _askAgentToUnsubscribe")
|
||||||
end = source.index("function _unsubscribeCandidateUids", start)
|
end = source.index("function _unsubscribeCandidateUids", start)
|
||||||
prompt = source[start:end]
|
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:
|
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")
|
start = source.index("function _bindEmailSettingsPageControls")
|
||||||
end = source.index("function _setUnsubButtonBusy", start)
|
end = source.index("function _setUnsubButtonBusy", start)
|
||||||
controls = source[start:end]
|
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:
|
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()
|
stylesheet = app_css()
|
||||||
assert "email-unsub-duplicate-badge" in frontend
|
assert "email-unsub-duplicate-badge" in frontend
|
||||||
start = stylesheet.index(".email-unsub-duplicate-badge {")
|
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:
|
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()
|
stylesheet = app_css()
|
||||||
start = frontend.index("function _emailCleanupSettingsHtml")
|
start = frontend.index("function _emailCleanupSettingsHtml")
|
||||||
end = frontend.index("function _emailDisplaySettingsHtml", start)
|
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:
|
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")
|
backend = (ROOT / "routes/email_routes.py").read_text(encoding="utf-8")
|
||||||
mcp = (ROOT / "mcp_servers/email_server.py").read_text(encoding="utf-8")
|
mcp = (ROOT / "mcp_servers/email_server.py").read_text(encoding="utf-8")
|
||||||
assert "async function _deleteAfterUnsubscribe" in frontend
|
assert "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:
|
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")
|
start = source.index("function _agentDeletedEmailUids")
|
||||||
end = source.index("function _handleAgentEmailToolOutput", start)
|
end = source.index("function _handleAgentEmailToolOutput", start)
|
||||||
resolver = source[start:end]
|
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:
|
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")
|
start = source.index("function _agentBrowserUnsubscribeSucceeded")
|
||||||
end = source.index("function _agentDeletedEmailUids", start)
|
end = source.index("function _agentDeletedEmailUids", start)
|
||||||
browser_flow = source[start:end]
|
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:
|
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")
|
start = source.index("function _markUnsubscribeCardDone")
|
||||||
end = source.index("async function _runUnsubscribeCleanup", start)
|
end = source.index("async function _runUnsubscribeCleanup", start)
|
||||||
card = source[start:end]
|
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:
|
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()
|
styles = app_css()
|
||||||
assert "email-unsub-ignore-btn" in source
|
assert "email-unsub-ignore-btn" in source
|
||||||
assert "_rememberUnsubscribeIgnored(c)" 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:
|
def test_email_settings_sections_use_static_headers() -> None:
|
||||||
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
source = email_library_source()
|
||||||
styles = app_css()
|
styles = app_css()
|
||||||
assert 'class="email-unsub-accent-icon"' in source
|
assert 'class="email-unsub-accent-icon"' in source
|
||||||
assert 'M12 0L14.59 8.41' in source
|
assert 'M12 0L14.59 8.41' in source
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
from tests.helpers.js_modules import email_library_paths
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
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/chat.js",
|
||||||
ROOT / "static/js/chatStream.js",
|
ROOT / "static/js/chatStream.js",
|
||||||
ROOT / "static/js/chatRenderer.js",
|
ROOT / "static/js/chatRenderer.js",
|
||||||
ROOT / "static/js/emailLibrary.js",
|
|
||||||
ROOT / "static/js/slashCommands.js",
|
ROOT / "static/js/slashCommands.js",
|
||||||
|
*email_library_paths(include_wrapper=True),
|
||||||
]
|
]
|
||||||
versions = {
|
versions = {
|
||||||
match
|
match
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
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()
|
app_src = (ROOT / "static/app.js").read_text()
|
||||||
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
|
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
|
||||||
inbox_src = (ROOT / "static/js/emailInbox.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 '@router.get("/events/{uid}")' in routes_src
|
||||||
assert "async function _fetchEventByUid" in calendar_src
|
assert "async function _fetchEventByUid" in calendar_src
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
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():
|
def test_calendar_visual_asset_versions_are_bumped():
|
||||||
versions = []
|
versions = []
|
||||||
for rel in (
|
for src in [
|
||||||
"static/app.js",
|
(ROOT / rel).read_text()
|
||||||
"static/js/chatRenderer.js",
|
for rel in ("static/app.js", "static/js/chatRenderer.js", "static/js/emailInbox.js")
|
||||||
"static/js/emailInbox.js",
|
] + [email_library_source()]:
|
||||||
"static/js/emailLibrary.js",
|
|
||||||
):
|
|
||||||
src = (ROOT / rel).read_text()
|
|
||||||
match = re.search(r"calendar\.js\?v=([A-Za-z0-9_-]+)", src)
|
match = re.search(r"calendar\.js\?v=([A-Za-z0-9_-]+)", src)
|
||||||
assert match
|
assert match
|
||||||
versions.append(match.group(1))
|
versions.append(match.group(1))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.js_modules import email_library_paths
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
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():
|
def test_card_menus_use_the_same_anchor_gap():
|
||||||
for relative_path in (
|
modules = [
|
||||||
"static/js/sessions.js",
|
ROOT / relative_path
|
||||||
"static/js/documentLibrary.js",
|
for relative_path in (
|
||||||
"static/js/emailLibrary.js",
|
"static/js/sessions.js",
|
||||||
"static/js/memory.js",
|
"static/js/documentLibrary.js",
|
||||||
"static/js/tasks.js",
|
"static/js/memory.js",
|
||||||
"static/js/skills.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
|
] + email_library_paths(include_wrapper=True)
|
||||||
assert "r.bottom + 2" not in source, relative_path
|
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 pathlib import Path
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
def test_email_ai_reply_context_is_saved_and_restored_per_message():
|
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 "_AI_REPLY_CONTEXT_DRAFT_PREFIX" in source
|
||||||
assert "data?.account_id || em?.account_id || state._libAccountId" 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():
|
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")
|
inbox = (ROOT / "static/js/emailInbox.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "const draftOpened = await _runAiReplyFromButton" in library
|
assert "const draftOpened = await _runAiReplyFromButton" in library
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from email.message import EmailMessage
|
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):
|
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():
|
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')")
|
start = library.index("reader.querySelectorAll('.email-attachment-open')")
|
||||||
end = library.index("reader.querySelectorAll('.email-attachment-download')", start)
|
end = library.index("reader.querySelectorAll('.email-attachment-download')", start)
|
||||||
handler = library[start:end]
|
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():
|
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")
|
start = library.index("function _loadDeferredAttachmentsIntoReader")
|
||||||
end = library.index('\n// "Open in new tab"', start)
|
end = library.index('\n// "Open in new tab"', start)
|
||||||
loader = library[start:end]
|
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():
|
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(")]
|
group = library[library.index("function _emailTagGroupHtml("):library.index("function _fitEmailCardTags(")]
|
||||||
assert "if (visible.length === 1) return visible[0];" in group
|
assert "if (visible.length === 1) return visible[0];" in group
|
||||||
assert "if (visible.length === 2) return visible.join('');" 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():
|
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(
|
assert library.count(
|
||||||
"bindMenuDismiss(menu, finishClose, e => !picker.contains(e.target))"
|
"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():
|
def test_empty_reply_has_two_editable_rows_and_reply_survives_compact_toolbar():
|
||||||
inbox = open("static/js/emailInbox.js", encoding="utf-8").read()
|
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
|
assert "<p><br></p><p><br></p>\\n" in inbox
|
||||||
fit_start = library.index("function _fitReaderActions")
|
fit_start = library.index("function _fitReaderActions")
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_folder_chip_stays_with_date_and_moves_down():
|
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()
|
css = (ROOT / "static" / "style.css").read_text()
|
||||||
|
|
||||||
assert 'class="email-meta-date-group"' in source
|
assert 'class="email-meta-date-group"' in source
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
|
|
||||||
_EMAIL_ROUTES = _REPO / "routes" / "email_routes.py"
|
_EMAIL_ROUTES = _REPO / "routes" / "email_routes.py"
|
||||||
_EMAIL_MCP_SERVER = _REPO / "mcp_servers" / "email_server.py"
|
_EMAIL_MCP_SERVER = _REPO / "mcp_servers" / "email_server.py"
|
||||||
_EMAIL_FIXTURE_HELPER = _REPO / "scripts" / "ody_eval_email_fixture.py"
|
_EMAIL_FIXTURE_HELPER = _REPO / "scripts" / "ody_eval_email_fixture.py"
|
||||||
|
|
||||||
|
|
||||||
def _bulk_action_source() -> str:
|
def _bulk_action_source() -> str:
|
||||||
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
|
text = email_library_source()
|
||||||
start = text.index("async function _bulkAction(action)")
|
start = text.index("async function _bulkAction(action)")
|
||||||
end = text.index("\n}\n\n// _extractName", start) + 3
|
end = text.index("\n}\n\n// _extractName", start) + 3
|
||||||
return text[start:end]
|
return text[start:end]
|
||||||
|
|
||||||
|
|
||||||
def _function_source(name: str) -> str:
|
def _function_source(name: str) -> str:
|
||||||
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
|
text = email_library_source()
|
||||||
start = text.index(f"function {name}")
|
start = text.index(f"function {name}")
|
||||||
next_function = text.find("\nfunction ", start + 1)
|
next_function = text.find("\nfunction ", start + 1)
|
||||||
next_async = text.find("\nasync function ", 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():
|
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")
|
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||||
export_src = frontend[
|
export_src = frontend[
|
||||||
frontend.index("async function _exportSelectedAttachments()"):
|
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
|
Folder, account, filter, quick-filter, attachment, and search basis changes
|
||||||
must exit select mode before the next list/search view can run bulk actions.
|
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")
|
reset_src = _function_source("_resetBulkSelectionForContextChange")
|
||||||
fresh_src = _function_source("_resetEmailListForFreshLoad")
|
fresh_src = _function_source("_resetEmailListForFreshLoad")
|
||||||
add_pill_src = _function_source("_addSearchPill")
|
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
|
refresh button should keep the old rows visible while asking the server to
|
||||||
evict those fast paths and refetch the visible mailbox slice.
|
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")
|
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "refresh=1&_=${Date.now()}" in frontend
|
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():
|
def test_email_client_cache_drops_fixture_rows():
|
||||||
"""Old fixture rows in browser storage must not keep rendering."""
|
"""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 _looksLikeFixtureEmailRow(row)" in frontend
|
||||||
assert "function _libCacheHasFixtureRows(value)" 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 subprocess
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
|
|
||||||
|
|
||||||
|
|
||||||
def _source() -> str:
|
def _source() -> str:
|
||||||
return _EMAIL_LIBRARY.read_text(encoding="utf-8")
|
return email_library_source()
|
||||||
|
|
||||||
|
|
||||||
def _function_source(name: str) -> str:
|
def _function_source(name: str) -> str:
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parent.parent
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
_INBOX_JS = _REPO / "static" / "js" / "emailInbox.js"
|
_INBOX_JS = _REPO / "static" / "js" / "emailInbox.js"
|
||||||
_LIBRARY_JS = _REPO / "static" / "js" / "emailLibrary.js"
|
|
||||||
_HAS_NODE = shutil.which("node") is not None
|
_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():
|
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")
|
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
|
||||||
|
|
||||||
assert function.count("/api/email/read/") == 1
|
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")
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
def test_library_authoritative_success_defeats_newer_rollback_in_either_order():
|
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")
|
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
|
||||||
settlements = _extract_between(
|
settlements = _extract_between(
|
||||||
function,
|
function,
|
||||||
@@ -101,7 +101,7 @@ console.log(JSON.stringify({{
|
|||||||
|
|
||||||
|
|
||||||
def test_library_reply_open_carries_immutable_mailbox_context():
|
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")
|
inbox_source = _INBOX_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "const mailboxGeneration = _emailMailboxGeneration;" in library_source
|
assert "const mailboxGeneration = _emailMailboxGeneration;" in library_source
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
def test_email_folder_and_filter_pickers_are_exclusive_and_escape_safe():
|
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 filterMenu = document.getElementById('email-filter-menu');") == 1
|
||||||
assert source.count("const folderMenu = document.getElementById('email-folder-menu');") == 1
|
assert source.count("const folderMenu = document.getElementById('email-folder-menu');") == 1
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_card_delete_waits_for_durable_success_and_uses_email_identity():
|
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 "function _emailMutationQuery(em" in source
|
||||||
assert "em?.folder || fallbackFolder" in source
|
assert "em?.folder || fallbackFolder" in source
|
||||||
assert "em?.account_id || state._libAccountId" in source
|
assert "em?.account_id || state._libAccountId" in source
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from tests.helpers.js_modules import email_library_paths
|
||||||
import src.tool_capabilities as tool_capabilities
|
import src.tool_capabilities as tool_capabilities
|
||||||
from src.tool_capabilities import (
|
from src.tool_capabilities import (
|
||||||
KNOWN_CAPABILITY_TOOLS,
|
KNOWN_CAPABILITY_TOOLS,
|
||||||
@@ -1405,11 +1406,10 @@ def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
|
|||||||
"static/js/chatStream.js",
|
"static/js/chatStream.js",
|
||||||
"static/js/document.js",
|
"static/js/document.js",
|
||||||
"static/js/emailInbox.js",
|
"static/js/emailInbox.js",
|
||||||
"static/js/emailLibrary.js",
|
|
||||||
"static/js/settings.js",
|
"static/js/settings.js",
|
||||||
"static/js/slashCommands.js",
|
"static/js/slashCommands.js",
|
||||||
)
|
)
|
||||||
]
|
] + [p.read_text() for p in email_library_paths(include_wrapper=True)]
|
||||||
assert all(
|
assert all(
|
||||||
"20260722emailfastindex1" not in source
|
"20260722emailfastindex1" not in source
|
||||||
for source in approval_module_sources
|
for source in approval_module_sources
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from tests.helpers.stylesheets import app_css
|
from tests.helpers.stylesheets import app_css
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
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():
|
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")
|
calendar = (ROOT / "static" / "js" / "calendar.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "email-attachment-calendar-open" in email
|
assert "email-attachment-calendar-open" in email
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import importlib
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.helpers.js_modules import email_library_source
|
||||||
|
|
||||||
|
|
||||||
# ── prompt-injection context wrapper ────────────────────────────
|
# ── prompt-injection context wrapper ────────────────────────────
|
||||||
@@ -1009,8 +1010,7 @@ def test_diagnostics_routes_are_admin_gated():
|
|||||||
def test_email_thread_rendering_sanitizes_body_html():
|
def test_email_thread_rendering_sanitizes_body_html():
|
||||||
"""Both threaded render paths must run server-parsed body_html through the
|
"""Both threaded render paths must run server-parsed body_html through the
|
||||||
allowlist sanitizer (the flat path already did)."""
|
allowlist sanitizer (the flat path already did)."""
|
||||||
src = Path(__file__).resolve().parents[1] / "static" / "js" / "emailLibrary.js"
|
text = email_library_source()
|
||||||
text = src.read_text()
|
|
||||||
# every `t.body_html` reference is wrapped by _sanitizeHtml(...)
|
# every `t.body_html` reference is wrapped by _sanitizeHtml(...)
|
||||||
assert text.count("t.body_html") == text.count("_sanitizeHtml(t.body_html")
|
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
|
assert "t.body_html" in text # guard against the file being refactored away
|
||||||
|
|||||||
Reference in New Issue
Block a user