mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 23:12:22 +02:00
static/js/document.js is 17,579 lines and is about to be decomposed behind a
re-export wrapper. 51 test files read it off disk and grep it as text, and 28
of those slice it with `src.split("function a", 1)[1].split("function b", 1)[0]`
-- "the region between a and b", which only means what the test intends while a
and b are neighbours in one file. Several also hard-code the file's two-space
indentation, which no extracted module reproduces. Left alone, the first
extraction makes those assertions cover the wrong region, and an `x in region`
check passes while covering more than it was written for.
tests/helpers/document_source is the one place that names the file now:
- document_source() is the entry plus everything under static/js/document/,
so a membership assertion keeps finding its subject wherever it lands;
- function_body()/declaration() locate a construct by name in whichever
module defines it and end at its real closing brace, so neither moving it
nor moving its neighbour changes the region.
The rewrite only collapses a slice when the old terminator sat at the
construct's end. 28 slices deliberately span a whole family of functions --
everything from _docxHexColor to exportAsDocx -- and collapsing one to its
first member drops what the assertions look for, so those stay as they are and
are listed in KNOWN_ADJACENCY_SLICES, to be converted as each family becomes a
module. That list may only shrink.
Two guards come with it:
- test_document_source_test_hygiene fails on a direct read of the entry file
and on any new adjacency slice;
- test_frontend_module_graph resolves every relative import under static/
(718 of them, none broken today) and requires the document module set to
stay in the sw.js precache, since the worker fetches the URLs it lists and
not what they import.
test_document_module_api pins the 38 default-export keys and 29 named exports
by loading the module in a browser and reading what it actually exports, rather
than grepping for the literal object -- after extraction that object may be
assembled from imports, and a source-shape check would pass while the export
was broken.
No JavaScript moves here. static/ is untouched.
262 lines
10 KiB
Python
262 lines
10 KiB
Python
"""Focused browser-side regression coverage for authoritative email opens."""
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from tests.helpers.document_source import document_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
|
|
|
|
|
|
def _extract_between(source: str, signature: str, next_marker: str) -> str:
|
|
start = source.index(signature)
|
|
end = source.index(next_marker, start)
|
|
return source[start:end].rstrip()
|
|
|
|
|
|
def test_library_unread_preview_has_one_authoritative_request_and_rollback():
|
|
source = _LIBRARY_JS.read_text(encoding="utf-8")
|
|
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
|
|
|
|
assert function.count("/api/email/read/") == 1
|
|
assert "/api/email/mark-read/" not in function
|
|
assert "&mark_seen=true" in function
|
|
assert "_syncEmailReadState(uidAtStart, true, readContext)" in function
|
|
assert "_syncEmailReadState(uidAtStart, false, readContext)" in function
|
|
assert "openGeneration === _emailCardOpenSeq" in function
|
|
assert "_emailReadMutations.get(readContextKey)?.generation !== readMutation.generation" in function
|
|
assert "authoritativeReadSucceeded = true;" in function
|
|
assert "if (!authoritativeReadSucceeded) restoreUnreadState();" in function
|
|
assert "if (!isCurrentOpen()) return" in function
|
|
|
|
|
|
@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")
|
|
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
|
|
settlements = _extract_between(
|
|
function,
|
|
" const restoreUnreadState = () => {",
|
|
"\n\n // Collapse any other expanded card",
|
|
)
|
|
|
|
harness = f"""
|
|
const _emailReadMutations = new Map();
|
|
const readContextKey = 'same-mailbox-message';
|
|
const uidAtStart = '1';
|
|
const readContext = {{ accountId: 'acct-a', folder: 'INBOX', uid: '1' }};
|
|
const readUpdates = [];
|
|
function _syncEmailReadState(uid, isRead, context) {{
|
|
readUpdates.push({{ uid, isRead, context }});
|
|
}}
|
|
function createSettlers(readMutation) {{
|
|
{settlements}
|
|
return {{ restoreUnreadState, commitReadState }};
|
|
}}
|
|
function runRace(successFirst) {{
|
|
_emailReadMutations.clear();
|
|
readUpdates.length = 0;
|
|
const mutationA = {{ generation: 1, rollbackUnread: true }};
|
|
_emailReadMutations.set(readContextKey, mutationA);
|
|
const settlersA = createSettlers(mutationA);
|
|
const mutationB = {{ generation: 2, rollbackUnread: true }};
|
|
_emailReadMutations.set(readContextKey, mutationB);
|
|
const settlersB = createSettlers(mutationB);
|
|
if (successFirst) {{
|
|
settlersA.commitReadState();
|
|
settlersB.restoreUnreadState();
|
|
}} else {{
|
|
settlersB.restoreUnreadState();
|
|
settlersA.commitReadState();
|
|
}}
|
|
return {{
|
|
hasMutation: _emailReadMutations.has(readContextKey),
|
|
readUpdates: readUpdates.map(update => update.isRead),
|
|
}};
|
|
}}
|
|
console.log(JSON.stringify({{
|
|
successFirst: runRace(true),
|
|
failureFirst: runRace(false),
|
|
}}));
|
|
"""
|
|
proc = subprocess.run(
|
|
["node", "--input-type=module"],
|
|
input=harness,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(_REPO),
|
|
timeout=30,
|
|
)
|
|
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
|
|
assert json.loads(proc.stdout.strip()) == {
|
|
"successFirst": {"hasMutation": False, "readUpdates": [True]},
|
|
"failureFirst": {"hasMutation": False, "readUpdates": [False, True]},
|
|
}
|
|
|
|
|
|
def test_library_reply_open_carries_immutable_mailbox_context():
|
|
library_source = _LIBRARY_JS.read_text(encoding="utf-8")
|
|
inbox_source = _INBOX_JS.read_text(encoding="utf-8")
|
|
|
|
assert "const mailboxGeneration = _emailMailboxGeneration;" in library_source
|
|
assert "messageFolder = String(options.email?.folder || libraryFolder)" in library_source
|
|
assert "return onEmailClick({ ...options, mailboxContext });" in library_source
|
|
assert "mailboxContext?.messageFolder || _currentFolder" in inbox_source
|
|
assert "mailboxContextIsCurrent()" in inbox_source
|
|
reply_branch = inbox_source[
|
|
inbox_source.index("const reuseExisting = mode !== 'forward'"):
|
|
inbox_source.index("const createReplyDoc =", inbox_source.index("const reuseExisting = mode !== 'forward'"))
|
|
]
|
|
assert reply_branch.index("if (!isCurrentOpen()) return;") < reply_branch.index("_createEmailChat(data")
|
|
assert "let activeSid = sessionModule?.getCurrentSessionId?.() || '';" in reply_branch
|
|
assert "_createEmailChat(data);" in reply_branch
|
|
assert "forceNew: !aiSuggestedBody" not in reply_branch
|
|
|
|
|
|
def test_attachment_warning_only_checks_authored_reply_text():
|
|
source = document_source()
|
|
helper = source[source.index("function _bodyMentionsAttachment"):source.index("\n\n function _clearMissingAttachmentWarnings", source.index("function _bodyMentionsAttachment"))]
|
|
|
|
assert "_emailReplyOwnText(text)" in helper
|
|
assert "text.split(/^>|^On .* wrote:/m)" not in helper
|
|
|
|
|
|
def test_email_send_saves_recovery_draft_before_send_and_retains_it_on_failure():
|
|
source = document_source()
|
|
send = source[source.index("async function _sendEmail"):source.index("\n\n async function _saveDraft", source.index("async function _sendEmail"))]
|
|
|
|
assert "async function _saveEmailDraftForRecovery" in source
|
|
assert send.index("_saveEmailDraftForRecovery({") < send.index("fetch(`${API_BASE}/api/email/send`")
|
|
assert send.index("if (isLibraryOpen()) closeLibrary();") > send.index("const sendRequest = fetch(")
|
|
assert send.index("if (isLibraryOpen()) closeLibrary();") < send.index("const res = await sendRequest")
|
|
success_branch = send[send.index("if (data.success) {"):]
|
|
assert "fetch(`${API_BASE}/api/document/${sendDocId}`, { method: 'DELETE' })" in success_branch
|
|
assert "Draft kept in Drafts." in send
|
|
assert "recoveryDraft.draft_uid" in send
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_inbox_late_read_response_cannot_apply_after_newer_open():
|
|
source = _INBOX_JS.read_text(encoding="utf-8")
|
|
function = _extract_between(source, "async function _openEmail", "\nfunction _showEmailMenu")
|
|
assert "let _openEmailRequestSeq = 0;" in source
|
|
|
|
harness = f"""
|
|
const realLog = console.log;
|
|
console.error = () => {{}};
|
|
const API_BASE = 'https://odysseus.invalid';
|
|
const window = {{ __odysseusActiveEmailAccount: 'acct-a' }};
|
|
let _currentFolder = 'INBOX';
|
|
const _acct = () => '&account_id=acct-a';
|
|
let _openEmailRequestSeq = 0;
|
|
let _docModule = null;
|
|
const spinnerModule = {{ createWhirlpool() {{ throw new Error('spinner should not run'); }} }};
|
|
const sessionModule = null;
|
|
let firstResolve;
|
|
const calls = [];
|
|
async function fetch(url) {{
|
|
calls.push(String(url));
|
|
if (calls.length === 1) {{
|
|
return await new Promise((resolve) => {{
|
|
firstResolve = () => resolve({{ json: async () => ({{ uid: '1', subject: 'old' }}) }});
|
|
}});
|
|
}}
|
|
return {{ json: async () => ({{ error: 'newer open completed test' }}) }};
|
|
}}
|
|
{function}
|
|
const oldEmail = {{ uid: '1', is_read: false }};
|
|
const newerEmail = {{ uid: '2', is_read: false }};
|
|
const first = _openEmail(oldEmail, null);
|
|
await Promise.resolve();
|
|
const second = _openEmail(newerEmail, null);
|
|
await second;
|
|
firstResolve();
|
|
await first;
|
|
realLog(JSON.stringify({{ calls, oldRead: oldEmail.is_read, newerRead: newerEmail.is_read }}));
|
|
"""
|
|
proc = subprocess.run(
|
|
["node", "--input-type=module"],
|
|
input=harness,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(_REPO),
|
|
timeout=30,
|
|
)
|
|
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
|
|
result = json.loads(proc.stdout.strip())
|
|
assert len(result["calls"]) == 2
|
|
assert all("mark_seen=true" in url for url in result["calls"])
|
|
assert result["oldRead"] is False
|
|
assert result["newerRead"] is False
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
@pytest.mark.parametrize("context_change", ["account", "folder", "library"])
|
|
def test_inbox_late_read_response_cannot_apply_after_mailbox_switch(context_change):
|
|
source = _INBOX_JS.read_text(encoding="utf-8")
|
|
function = _extract_between(source, "async function _openEmail", "\nfunction _showEmailMenu")
|
|
|
|
changes = {
|
|
"account": "window.__odysseusActiveEmailAccount = 'acct-b';",
|
|
"folder": "_currentFolder = 'Archive';",
|
|
"library": "libraryCurrent = false;",
|
|
}
|
|
change = changes[context_change]
|
|
open_call = (
|
|
"_openEmail(email, null, null, 'reply', '', '', mailboxContext)"
|
|
if context_change == "library"
|
|
else "_openEmail(email, null)"
|
|
)
|
|
harness = f"""
|
|
const realLog = console.log;
|
|
console.error = () => {{}};
|
|
const API_BASE = 'https://odysseus.invalid';
|
|
const window = {{ __odysseusActiveEmailAccount: 'acct-a' }};
|
|
let _currentFolder = 'INBOX';
|
|
const _acct = () => '&account_id=acct-a';
|
|
let _openEmailRequestSeq = 0;
|
|
let libraryCurrent = true;
|
|
const mailboxContext = {{
|
|
accountId: 'acct-a',
|
|
messageFolder: 'Archive',
|
|
isCurrent: () => libraryCurrent,
|
|
}};
|
|
let createCalls = 0;
|
|
let _docModule = {{}};
|
|
async function _createEmailChat() {{ createCalls += 1; return 'stale-session'; }}
|
|
const spinnerModule = {{ createWhirlpool() {{ throw new Error('spinner should not run'); }} }};
|
|
const sessionModule = null;
|
|
let resolveRead;
|
|
async function fetch() {{
|
|
return await new Promise((resolve) => {{
|
|
resolveRead = () => resolve({{ json: async () => ({{ uid: '1', subject: 'old' }}) }});
|
|
}});
|
|
}}
|
|
{function}
|
|
const email = {{ uid: '1', is_read: false }};
|
|
const pending = {open_call};
|
|
await Promise.resolve();
|
|
{change}
|
|
resolveRead();
|
|
await pending;
|
|
realLog(JSON.stringify({{ createCalls, isRead: email.is_read }}));
|
|
"""
|
|
proc = subprocess.run(
|
|
["node", "--input-type=module"],
|
|
input=harness,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(_REPO),
|
|
timeout=30,
|
|
)
|
|
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
|
|
result = json.loads(proc.stdout.strip())
|
|
assert result == {"createCalls": 0, "isRead": False}
|