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

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

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

Two test helpers grew while doing this. `js_function_source` replaces three
marker-pair slices ("from this signature down to that comment") whose end
marker had moved into another module — the slice ran past the function and
kept passing against the wrong text. Its first implementation balanced braces
by walking characters and ended `_toggleCardPreview` 18 lines early, because
the apostrophe in `// that's a scroll, not a nav` opened a string that ate the
braces after it. It now keys on the invariant the file actually holds: a
top-level declaration starts at column 0, so its closing brace is the next
lone `}` at column 0.
2026-09-30 09:26:34 +02:00

357 lines
13 KiB
Python

import json
from pathlib import Path
import shutil
import subprocess
import pytest
from tests.helpers.js_modules import email_library_source, js_function_source
_REPO = Path(__file__).resolve().parents[1]
def _source() -> str:
return email_library_source()
def _function_source(name: str) -> str:
return js_function_source(name, _source())
def _run_scheduler_scenario(scenario: str):
node = shutil.which("node")
if not node:
pytest.skip("node not on PATH")
functions = "\n".join(
_function_source(name)
for name in (
"_isChatInteractionBusy",
"_canRunEmailPrewarm",
"_isEmailPrewarmTemporarilyBlocked",
"_settleEmailPrewarm",
"_cancelEmailPrewarm",
"_scheduleEmailPrewarm",
)
)
script = f"""
let now = 0;
Date.now = () => now;
const state = {{ _libOpen: false, _libLoading: false }};
let _libSearchInFlight = false;
let _libPrewarmDelayTimer = null;
let _libPrewarmIdleHandle = null;
let _libPrewarmPromise = null;
let _libPrewarmResolve = null;
let _libPrewarmAbortController = null;
let _libPrewarmDetachPriorityListeners = null;
let _libPrewarmGeneration = 0;
let nextHandle = 1;
const timers = new Map();
const idleCallbacks = new Map();
let idleRequestCount = 0;
function eventTarget(target) {{
const listeners = new Map();
target.addEventListener = (type, callback) => {{
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(callback);
}};
target.removeEventListener = (type, callback) => listeners.get(type)?.delete(callback);
target.dispatchEvent = (event) => {{
for (const callback of [...(listeners.get(event.type) || [])]) callback(event);
}};
target.listenerCount = (type) => listeners.get(type)?.size || 0;
return target;
}}
const document = eventTarget({{ visibilityState: 'visible' }});
const window = {{
__odysseusChatBusy: false,
__odysseusChatBusyUntil: 0,
requestIdleCallback(callback) {{
const handle = nextHandle++;
idleRequestCount += 1;
idleCallbacks.set(handle, callback);
return handle;
}},
cancelIdleCallback(handle) {{ idleCallbacks.delete(handle); }},
}};
eventTarget(window);
function setTimeout(callback, delay) {{
const handle = nextHandle++;
timers.set(handle, {{ callback, at: now + Number(delay || 0) }});
return handle;
}}
function clearTimeout(handle) {{ timers.delete(handle); }}
async function flushMicrotasks() {{
for (let i = 0; i < 6; i += 1) await Promise.resolve();
}}
async function advanceTo(target) {{
while (true) {{
const pending = [...timers.entries()]
.filter(([, timer]) => timer.at <= target)
.sort((a, b) => a[1].at - b[1].at)[0];
if (!pending) break;
const [handle, timer] = pending;
timers.delete(handle);
now = timer.at;
timer.callback();
await flushMicrotasks();
}}
now = target;
await flushMicrotasks();
}}
async function fireNextIdle(budget = 5) {{
const pending = idleCallbacks.entries().next().value;
if (!pending) throw new Error('no idle callback pending');
const [handle, callback] = pending;
idleCallbacks.delete(handle);
callback({{ didTimeout: false, timeRemaining: () => budget }});
await flushMicrotasks();
}}
{functions}
{scenario}
"""
proc = subprocess.run(
[node, "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_prewarm_is_genuine_idle_only_and_single_flight():
scheduler = _function_source("_scheduleEmailPrewarm")
assert "if (_libPrewarmPromise) return _libPrewarmPromise;" in scheduler
assert "typeof window.requestIdleCallback !== 'function'" in scheduler
assert "return Promise.resolve(false);" in scheduler
assert "window.requestIdleCallback((deadline)" in scheduler
assert "!deadline.didTimeout" in scheduler
assert "deadline.timeRemaining() > 0" in scheduler
idle_callback = scheduler.index("window.requestIdleCallback((deadline)")
assert "Promise.resolve()" in scheduler
task_start = scheduler.index("task({ signal: controller.signal, generation })")
assert idle_callback < task_start, "network work must only be reachable from the idle callback"
def test_temporary_chat_priority_retries_one_single_flight_until_idle():
out = _run_scheduler_scenario("""
window.__odysseusChatBusyUntil = 10000;
let taskCalls = 0;
const task = async () => { taskCalls += 1; return true; };
const first = _scheduleEmailPrewarm(task, { delay: 1800 });
const joined = _scheduleEmailPrewarm(task, { delay: 0 });
const samePromise = first === joined;
await advanceTo(1800);
await fireNextIdle(7);
const callsWhileBusy = taskCalls;
while (now < 10300) {
await advanceTo(now + 500);
await fireNextIdle(7);
}
const result = await first;
console.log(JSON.stringify({
result, samePromise, callsWhileBusy, taskCalls, idleRequestCount,
timers: timers.size, idleCallbacks: idleCallbacks.size,
}));
""")
assert out == {
"result": True,
"samePromise": True,
"callsWhileBusy": 0,
"taskCalls": 1,
"idleRequestCount": 18,
"timers": 0,
"idleCallbacks": 0,
}
def test_cancelled_prewarm_cannot_issue_a_delayed_duplicate():
out = _run_scheduler_scenario("""
let taskCalls = 0;
const pending = _scheduleEmailPrewarm(async () => { taskCalls += 1; return true; }, { delay: 1800 });
await advanceTo(1400);
_cancelEmailPrewarm();
await advanceTo(12000);
const result = await pending;
console.log(JSON.stringify({
result, taskCalls, idleRequestCount,
timers: timers.size, idleCallbacks: idleCallbacks.size,
}));
""")
assert out == {
"result": False,
"taskCalls": 0,
"idleRequestCount": 0,
"timers": 0,
"idleCallbacks": 0,
}
@pytest.mark.parametrize("transition", ["busy", "hidden"])
def test_active_prewarm_is_aborted_and_retried_once_after_priority_transition(transition):
block = (
"window.__odysseusChatBusy = true; "
"window.dispatchEvent({ type: 'odysseus:chat-busy-change' });"
if transition == "busy"
else "document.visibilityState = 'hidden'; document.dispatchEvent({ type: 'visibilitychange' });"
)
unblock = (
"window.__odysseusChatBusy = false; window.__odysseusChatBusyUntil = now; "
"window.dispatchEvent({ type: 'odysseus:chat-busy-change' });"
if transition == "busy"
else "document.visibilityState = 'visible'; document.dispatchEvent({ type: 'visibilitychange' });"
)
out = _run_scheduler_scenario(f"""
let taskCalls = 0;
let firstSignal = null;
let finishFirst;
const firstAttempt = new Promise(resolve => {{ finishFirst = resolve; }});
const pending = _scheduleEmailPrewarm(async ({{ signal }}) => {{
taskCalls += 1;
if (taskCalls === 1) {{ firstSignal = signal; return firstAttempt; }}
return true;
}});
await fireNextIdle(7);
{block}
const aborted = firstSignal.aborted;
{unblock}
const callsBeforeLateResult = taskCalls;
finishFirst(true);
await flushMicrotasks();
const stillPendingAfterLateResult = _libPrewarmPromise === pending;
await advanceTo(now + 500);
await fireNextIdle(7);
const result = await pending;
console.log(JSON.stringify({{
result, aborted, callsBeforeLateResult, taskCalls,
stillPendingAfterLateResult,
timers: timers.size, idleCallbacks: idleCallbacks.size,
chatListeners: window.listenerCount('odysseus:chat-busy-change'),
visibilityListeners: document.listenerCount('visibilitychange'),
}}));
""")
assert out == {
"result": True,
"aborted": True,
"callsBeforeLateResult": 1,
"taskCalls": 2,
"stillPendingAfterLateResult": True,
"timers": 0,
"idleCallbacks": 0,
"chatListeners": 0,
"visibilityListeners": 0,
}
def test_prewarm_skips_hidden_and_foreground_work():
guard = _function_source("_canRunEmailPrewarm")
assert "state._libOpen" in guard
assert "state._libLoading" in guard
assert "_libSearchInFlight" in guard
assert "document.visibilityState !== 'visible'" in guard
assert "!_isChatInteractionBusy()" in guard
def test_prewarm_selects_only_last_used_or_default_account():
chooser = _function_source("_chooseEmailPrewarmAccountId")
prewarm = _function_source("_prewarmEmailViews")
assert "_rememberedEmailAccountId()" in chooser
assert "a.enabled !== false" in chooser
assert "a.is_default" in chooser
assert "enabled[0]" in chooser
assert "for (" not in prewarm
assert "orderedAccountIds" not in prewarm
assert "slice(0, 4)" not in prewarm
assert "/api/email/folders" not in prewarm
assert "/api/email/unread-state" not in prewarm
assert prewarm.count("/api/email/list") == 1
def test_prewarm_account_chooser_rejects_disabled_or_empty_authoritative_inventory():
node = shutil.which("node")
if not node:
pytest.skip("node not on PATH")
chooser = _function_source("_chooseEmailPrewarmAccountId")
script = f"""
const state = {{ _libAccountId: 'disabled-current' }};
function _rememberedEmailAccountId() {{ return 'disabled-remembered'; }}
{chooser}
const onlyDisabled = _chooseEmailPrewarmAccountId([
{{ id: 'disabled-remembered', enabled: false, is_default: true }},
{{ id: 'disabled-current', enabled: false }},
]);
const empty = _chooseEmailPrewarmAccountId([]);
const mixed = _chooseEmailPrewarmAccountId([
{{ id: 'disabled-remembered', enabled: false, is_default: true }},
{{ id: 'enabled-default', enabled: true, is_default: true }},
]);
console.log(JSON.stringify({{ onlyDisabled, empty, mixed }}));
"""
proc = subprocess.run(
[node, "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout.strip()) == {
"onlyDisabled": "",
"empty": "",
"mixed": "enabled-default",
}
ensure_accounts = _function_source("_ensureEmailAccountsForPrewarm")
assert "if (!accountId) return null;" in ensure_accounts
assert "state._libAccountId = accountId" not in ensure_accounts
assert "_publishActiveAccount();" not in ensure_accounts
def test_prewarm_is_bounded_to_the_interactive_initial_page_size():
text = _source()
prewarm = _function_source("_prewarmEmailViews")
assert "const _LIB_INITIAL_PAGE_SIZE = 100;" in text
assert "limit: _LIB_INITIAL_PAGE_SIZE" in prewarm
assert text.count("limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}") == 2
assert "limit: 100" not in prewarm
def test_open_cancels_scheduled_or_inflight_prewarm_first():
text = _source()
cancel = _function_source("_cancelEmailPrewarm")
open_library = _function_source("openEmailLibrary")
assert "clearTimeout(_libPrewarmDelayTimer)" in cancel
assert "window.cancelIdleCallback(_libPrewarmIdleHandle)" in cancel
assert "_libPrewarmAbortController?.abort()" in cancel
assert "_libPrewarmGeneration += 1" in cancel
assert open_library.index("_cancelEmailPrewarm();") < open_library.index("state._libOpen = true;")
assert "_loadEmailsWhenChatIdle" not in text
assert text.count("_loadEmails({ useCache: true });") >= 2
def test_close_cancels_pending_prewarm_cleanup():
close_library = _function_source("closeEmailLibrary")
assert close_library.index("_cancelEmailPrewarm();") < close_library.index("state._libOpen = false;")
def test_unread_warm_joins_the_same_idle_single_flight_gate():
unread_entry = _function_source("prewarmUnreadEmails")
unread_work = _function_source("_prewarmUnreadEmailsNow")
assert "_scheduleEmailPrewarm(" in unread_entry
assert "fetch(" not in unread_entry
assert "_ensureEmailAccountsForPrewarm({ signal, generation })" in unread_work
assert "signal" in unread_work
assert "Math.min(20" in unread_work