mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
test(document): read the editor through a module-set helper before it is split
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.
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
"""Read the document editor's JavaScript the way the browser loads it.
|
||||
|
||||
``static/js/document.js`` is being decomposed. It stays the entry point the
|
||||
browser requests -- ``static/index.html`` names it, ``static/sw.js`` precaches
|
||||
it, and five modules import it -- but the implementation moves into modules
|
||||
under ``static/js/document/``. The implementation set is the entry plus that
|
||||
directory.
|
||||
|
||||
Two habits in the existing tests do not survive that move, and this module
|
||||
exists to replace both.
|
||||
|
||||
**Reading the entry file alone.** A membership assertion against
|
||||
``document.js`` silently covers less the moment the behaviour it names moves
|
||||
out. Use :func:`document_source` for those: it is the whole implementation set,
|
||||
so a test keeps finding what it asserts on wherever the code lands.
|
||||
|
||||
**Slicing between two adjacent functions.** ``function_body("a")`` means "the region between a and b", which is only
|
||||
the body of ``a`` while ``a`` and ``b`` happen to be neighbours in one file.
|
||||
After a split they may sit in different modules, and then the slice runs to the
|
||||
end of the concatenation and quietly grows: an ``assert "x" in region`` passes
|
||||
against code it was never meant to see. Several of these also hard-code the
|
||||
entry file's two-space indentation (``"\\n function showDocTabMenu"``), which
|
||||
no extracted module reproduces. Use :func:`function_body` or
|
||||
:func:`declaration` instead -- they find the construct by name, in whichever
|
||||
module defines it, and end at its real closing brace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STATIC = Path(__file__).resolve().parents[2] / "static"
|
||||
_ENTRY = _STATIC / "js" / "document.js"
|
||||
|
||||
# Extracted implementation modules get one home, so the set is discoverable
|
||||
# without a manifest anyone has to remember to update.
|
||||
_IMPL_DIR = _STATIC / "js" / "document"
|
||||
|
||||
|
||||
def document_source_paths() -> list[Path]:
|
||||
"""Every file holding document-editor implementation, entry first.
|
||||
|
||||
The entry comes first so a concatenation reads in the order the browser
|
||||
evaluates the graph's root; the rest are sorted for determinism.
|
||||
"""
|
||||
if not _ENTRY.is_file():
|
||||
raise AssertionError(f"document editor entry point is missing: {_ENTRY}")
|
||||
extracted = sorted(_IMPL_DIR.rglob("*.js")) if _IMPL_DIR.is_dir() else []
|
||||
return [_ENTRY, *extracted]
|
||||
|
||||
|
||||
def document_source() -> str:
|
||||
"""The whole implementation set as one string, entry first.
|
||||
|
||||
For membership assertions (``assert "..." in document_source()``). For
|
||||
anything positional use :func:`function_body` or :func:`declaration`.
|
||||
"""
|
||||
return "\n".join(p.read_text(encoding="utf-8") for p in document_source_paths())
|
||||
|
||||
|
||||
# --- Locating a construct by name, not by what follows it ------------------
|
||||
|
||||
def _defining_source(pattern: re.Pattern[str], what: str) -> tuple[str, int]:
|
||||
"""The source text that defines ``what``, and the offset of the match."""
|
||||
hits = []
|
||||
for path in document_source_paths():
|
||||
src = path.read_text(encoding="utf-8")
|
||||
for m in pattern.finditer(src):
|
||||
hits.append((path, src, m.start()))
|
||||
if not hits:
|
||||
raise AssertionError(f"{what} is not defined anywhere in {_describe_set()}")
|
||||
if len(hits) > 1:
|
||||
where = ", ".join(
|
||||
f"{p.relative_to(_STATIC.parent)}:{s.count(chr(10), 0, o) + 1}"
|
||||
for p, s, o in hits
|
||||
)
|
||||
raise AssertionError(f"{what} is defined more than once ({where})")
|
||||
_path, src, offset = hits[0]
|
||||
return src, offset
|
||||
|
||||
|
||||
def _describe_set() -> str:
|
||||
return ", ".join(str(p.relative_to(_STATIC.parent)) for p in document_source_paths())
|
||||
|
||||
|
||||
def function_body(name: str) -> str:
|
||||
"""The full text of function ``name``, signature through closing brace.
|
||||
|
||||
Matches ``function name``, optionally prefixed by ``export`` and/or
|
||||
``async``, at any indentation, in whichever module of the implementation
|
||||
set defines it. The end is found by matching braces rather than by naming
|
||||
whatever declaration follows, so moving the function -- or the one after
|
||||
it -- does not change the region a test sees.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r"^[ \t]*(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
|
||||
re.M,
|
||||
)
|
||||
src, offset = _defining_source(pattern, f"function {name}")
|
||||
# Skip the parameter list before looking for the body. A destructured
|
||||
# parameter -- `function f(table, { headerRow, headerColumn })` -- opens a
|
||||
# brace that is not the body, and matching it would return the signature
|
||||
# alone.
|
||||
body_start = _end_of_params(src, src.index("(", offset))
|
||||
return src[offset : _end_of_block(src, body_start)]
|
||||
|
||||
|
||||
def declaration(name: str) -> str:
|
||||
"""The full text of a top-level ``const``/``let``/``var`` named ``name``.
|
||||
|
||||
For the array and object tables the tests assert on (toolbar groups, slash
|
||||
commands, input rules). Ends at the declaration's closing bracket or brace,
|
||||
or at the end of the statement for a simple initialiser.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r"^[ \t]*(?:export\s+)?(?:const|let|var)\s+" + re.escape(name) + r"\b",
|
||||
re.M,
|
||||
)
|
||||
src, offset = _defining_source(pattern, f"declaration {name}")
|
||||
return src[offset : _end_of_statement(src, offset)]
|
||||
|
||||
|
||||
# --- A brace matcher that is not fooled by braces inside literals ----------
|
||||
#
|
||||
# `document.js` is full of template literals building DOM, regexes containing
|
||||
# braces, and apostrophes inside comments. Counting raw `{`/`}` mis-slices on
|
||||
# all three, so the scan tracks what kind of text it is inside.
|
||||
|
||||
# After one of these, `/` starts a regex literal; after a value it is division.
|
||||
_REGEX_OK_BEFORE = re.compile(r"[({\[,;:=!&|?+\-*~^%<>]\s*$|\b(?:return|typeof|case|in|of|new|delete|void|do|else|yield|await)\s*$")
|
||||
|
||||
|
||||
def _scan(src: str, start: int, stop):
|
||||
"""Walk ``src`` from ``start``, skipping literals and comments.
|
||||
|
||||
Calls ``stop(index, depth_delta_applied)``-free: instead it yields
|
||||
``(index, char)`` for code positions only, so callers can track nesting.
|
||||
"""
|
||||
i, n = start, len(src)
|
||||
# Stack of template-literal depths: entering `${` pushes brace depth.
|
||||
template_stack: list[int] = []
|
||||
while i < n:
|
||||
c = src[i]
|
||||
two = src[i : i + 2]
|
||||
if two == "//":
|
||||
j = src.find("\n", i)
|
||||
i = n if j == -1 else j + 1
|
||||
continue
|
||||
if two == "/*":
|
||||
j = src.find("*/", i + 2)
|
||||
i = n if j == -1 else j + 2
|
||||
continue
|
||||
if c in "'\"":
|
||||
i = _skip_quoted(src, i, c)
|
||||
continue
|
||||
if c == "`":
|
||||
i += 1
|
||||
i, entered = _skip_template(src, i)
|
||||
if entered:
|
||||
template_stack.append(0)
|
||||
continue
|
||||
if c == "/" and _REGEX_OK_BEFORE.search(src[max(0, i - 24) : i]):
|
||||
j = _skip_regex(src, i)
|
||||
if j is not None:
|
||||
i = j
|
||||
continue
|
||||
if template_stack:
|
||||
# Inside `${ ... }`: a `}` that closes it returns to template text.
|
||||
if c == "{":
|
||||
template_stack[-1] += 1
|
||||
elif c == "}":
|
||||
if template_stack[-1] == 0:
|
||||
template_stack.pop()
|
||||
i += 1
|
||||
i, entered = _skip_template(src, i)
|
||||
if entered:
|
||||
template_stack.append(0)
|
||||
continue
|
||||
template_stack[-1] -= 1
|
||||
yield i, c
|
||||
i += 1
|
||||
|
||||
|
||||
def _skip_quoted(src: str, i: int, quote: str) -> int:
|
||||
i += 1
|
||||
n = len(src)
|
||||
while i < n:
|
||||
if src[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if src[i] == quote:
|
||||
return i + 1
|
||||
if src[i] == "\n": # unterminated; do not run away
|
||||
return i
|
||||
i += 1
|
||||
return n
|
||||
|
||||
|
||||
def _skip_template(src: str, i: int) -> tuple[int, bool]:
|
||||
"""From inside template text, advance to the backtick end or a ``${``.
|
||||
|
||||
Returns the new index and whether an interpolation was entered.
|
||||
"""
|
||||
n = len(src)
|
||||
while i < n:
|
||||
if src[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if src[i] == "`":
|
||||
return i + 1, False
|
||||
if src[i : i + 2] == "${":
|
||||
return i + 2, True
|
||||
i += 1
|
||||
return n, False
|
||||
|
||||
|
||||
def _skip_regex(src: str, i: int) -> int | None:
|
||||
"""Past a regex literal starting at ``i``, or None if it is not one."""
|
||||
i += 1
|
||||
n = len(src)
|
||||
in_class = False
|
||||
while i < n:
|
||||
c = src[i]
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == "\n":
|
||||
return None
|
||||
if in_class:
|
||||
if c == "]":
|
||||
in_class = False
|
||||
elif c == "[":
|
||||
in_class = True
|
||||
elif c == "/":
|
||||
i += 1
|
||||
while i < n and src[i].isalpha(): # flags
|
||||
i += 1
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def _end_of_block(src: str, start: int) -> int:
|
||||
"""Index just past the ``}`` closing the first ``{`` at or after ``start``."""
|
||||
depth = 0
|
||||
seen = False
|
||||
for i, c in _scan(src, start, None):
|
||||
if c == "{":
|
||||
depth += 1
|
||||
seen = True
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if seen and depth == 0:
|
||||
return i + 1
|
||||
raise AssertionError(f"unbalanced braces from offset {start}")
|
||||
|
||||
|
||||
def _end_of_statement(src: str, start: int) -> int:
|
||||
"""Index just past the end of the declaration statement at ``start``.
|
||||
|
||||
Ends on the ``;`` or newline that closes it at nesting depth zero, so an
|
||||
array or object initialiser is returned whole.
|
||||
"""
|
||||
depth = 0
|
||||
for i, c in _scan(src, start, None):
|
||||
if c in "{[(":
|
||||
depth += 1
|
||||
elif c in "}])":
|
||||
depth -= 1
|
||||
elif depth == 0 and c == ";":
|
||||
return i + 1
|
||||
elif depth == 0 and c == "\n" and i > start:
|
||||
return i
|
||||
return len(src)
|
||||
|
||||
|
||||
def _end_of_params(src: str, open_paren: int) -> int:
|
||||
"""Index just past the ``)`` closing the parameter list at ``open_paren``."""
|
||||
depth = 0
|
||||
for i, c in _scan(src, open_paren, None):
|
||||
if c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i + 1
|
||||
raise AssertionError(f"unbalanced parameter list at offset {open_paren}")
|
||||
@@ -1,9 +1,10 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCUMENT_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOCUMENT_JS = document_source()
|
||||
CHAT_JS = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
||||
APP_JS = (ROOT / "static/app.js").read_text(encoding="utf-8")
|
||||
SETTINGS_JS = (ROOT / "static/js/settings.js").read_text(encoding="utf-8")
|
||||
@@ -12,9 +13,7 @@ CHAT_ROUTE = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_visible_or_minimized_linked_document_is_sent_as_chat_context():
|
||||
function = DOCUMENT_JS.split("export function getChatDocumentId()", 1)[1].split(
|
||||
"export function getActiveEmailComposerContext()", 1
|
||||
)[0]
|
||||
function = function_body("getChatDocumentId")
|
||||
assert "pane?.isConnected" in function
|
||||
assert "document.body.classList.contains('doc-view')" not in function
|
||||
assert "style?.display !== 'none'" in function
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Regression guards for restoring a chat's exact active document."""
|
||||
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
DOC_JS = (
|
||||
Path(__file__).resolve().parents[1] / "static/js/document.js"
|
||||
).read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_active_document_is_persisted_per_session():
|
||||
@@ -32,9 +31,7 @@ def test_closing_active_document_clears_stale_restore_pointer():
|
||||
|
||||
|
||||
def test_explicit_document_open_clears_minimized_dock_state():
|
||||
ensure_mounted = DOC_JS.split("function _ensureDocPaneMounted()", 1)[1].split(
|
||||
"export async function loadDocument", 1
|
||||
)[0]
|
||||
ensure_mounted = function_body("_ensureDocPaneMounted")
|
||||
|
||||
assert "Modals.isMinimized('doc-panel')" in ensure_mounted
|
||||
assert "Modals.unregister('doc-panel');" in ensure_mounted
|
||||
@@ -42,9 +39,7 @@ def test_explicit_document_open_clears_minimized_dock_state():
|
||||
|
||||
|
||||
def test_library_open_intent_is_persisted_before_delayed_session_restore():
|
||||
body = DOC_JS.split("export function prepareDocumentOpen(sessionId)", 1)[1].split(
|
||||
"/** Switch chat", 1
|
||||
)[0]
|
||||
body = function_body("prepareDocumentOpen")
|
||||
|
||||
assert "_markDocVisibleState(sessionId, 'open');" in body
|
||||
assert "Modals.isMinimized('doc-panel')" in body
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
SRC = Path(__file__).resolve().parent.parent / "static/js/document.js"
|
||||
|
||||
|
||||
def _function_body(name: str) -> str:
|
||||
text = SRC.read_text(encoding="utf-8")
|
||||
text = document_source()
|
||||
match = re.search(rf"\n\s*(?:export\s+)?(?:async\s+)?function\s+{name}\([^)]*\)\s*\{{", text)
|
||||
assert match, f"{name} not found"
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ no JS unit harness for it — these pin the source-level invariants that the
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -21,13 +22,13 @@ def test_chat_document_links_use_the_document_id():
|
||||
def test_document_deeplink_handled_on_hashchange_and_load():
|
||||
"""#document-<id> in the URL must open the doc on refresh / URL-bar nav,
|
||||
not just on click."""
|
||||
js = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
|
||||
js = document_source()
|
||||
assert "addEventListener('hashchange', _maybeOpenDocFromHash)" in js
|
||||
assert "#document-" in js
|
||||
|
||||
|
||||
def test_failed_document_load_surfaces_user_error():
|
||||
"""A missing/failed document must tell the user, not fail silently."""
|
||||
js = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
|
||||
js = document_source()
|
||||
assert "uiModule.showError" in js
|
||||
assert "Document not found" in js
|
||||
|
||||
@@ -19,9 +19,10 @@ browser-coupled and not importable in pytest.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text()
|
||||
DOC_JS = document_source()
|
||||
|
||||
GUARD = "if (_diffModeActive) exitDiffMode(true);"
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Regression guards for document-selection references in chat bubbles."""
|
||||
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||
DOCUMENT = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOCUMENT = document_source()
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||
APP = (ROOT / "static/app.js").read_text(encoding="utf-8")
|
||||
|
||||
@@ -9,10 +9,11 @@ document.js is browser-coupled and not importable in pytest.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_history_buttons_start_disabled_and_follow_native_history():
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""The document editor's public surface, pinned against the running module.
|
||||
|
||||
``static/js/document.js`` is becoming a re-export wrapper. Five modules import
|
||||
its default export, ``static/index.html`` loads it, and ``documentLibrary.js``
|
||||
is handed named functions through its config object -- so the surface is the
|
||||
contract that decomposition must not change, and a method that quietly stops
|
||||
being re-exported is a runtime ``TypeError`` in whichever panel used it.
|
||||
|
||||
This loads the module in a browser and reads what it actually exports, rather
|
||||
than grepping the source for the literal object: after extraction the object
|
||||
may be assembled from imports, and a source-shape assertion would pass while
|
||||
the export was broken.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = document_source()
|
||||
|
||||
# Every key on the default export. Consumers reach the editor through this
|
||||
# object, so removing one is a breaking change; adding one is not.
|
||||
DEFAULT_EXPORT_KEYS = {
|
||||
"clearAll",
|
||||
"clearSelection",
|
||||
"closeLibrary",
|
||||
"closePanel",
|
||||
"createDocument",
|
||||
"ensureDocPanel",
|
||||
"ensureEmailDraftEnvelope",
|
||||
"ensurePaneMounted",
|
||||
"enterDiffMode",
|
||||
"exitDiffMode",
|
||||
"findEmailDocId",
|
||||
"focusEmailReplyBody",
|
||||
"getActiveEmailComposerContext",
|
||||
"getChatDocumentId",
|
||||
"getCurrentDocId",
|
||||
"getSelectionContext",
|
||||
"handleDocSuggestions",
|
||||
"handleDocUpdate",
|
||||
"init",
|
||||
"injectFreshDoc",
|
||||
"isDiffModeActive",
|
||||
"isLibraryOpen",
|
||||
"isPanelOpen",
|
||||
"loadDocument",
|
||||
"loadSessionDocs",
|
||||
"moveActiveDocumentToCurrentChat",
|
||||
"moveActiveDocumentToNewChat",
|
||||
"newDocument",
|
||||
"openEmailDraft",
|
||||
"openLibrary",
|
||||
"openPanel",
|
||||
"replaceEmailReplyBody",
|
||||
"restoreSelectionReference",
|
||||
"saveDocument",
|
||||
"streamDocDelta",
|
||||
"streamDocFinalize",
|
||||
"streamDocOpen",
|
||||
"swapSide",
|
||||
}
|
||||
|
||||
# Named exports. `prepareDocumentOpen` is deliberately in this set and not on
|
||||
# the default export: `documentLibrary.js` receives it through `initLibrary`'s
|
||||
# config, and a browser test calls it off the module namespace.
|
||||
NAMED_EXPORTS = {
|
||||
"clearAll",
|
||||
"closePanel",
|
||||
"createDocument",
|
||||
"ensureDocPanel",
|
||||
"ensureEmailDraftEnvelope",
|
||||
"findEmailDocId",
|
||||
"focusEmailReplyBody",
|
||||
"getActiveEmailComposerContext",
|
||||
"getChatDocumentId",
|
||||
"getCurrentDocId",
|
||||
"getSelectionContext",
|
||||
"handleDocSuggestions",
|
||||
"handleDocUpdate",
|
||||
"init",
|
||||
"injectFreshDoc",
|
||||
"isPanelOpen",
|
||||
"loadDocument",
|
||||
"loadSessionDocs",
|
||||
"newDocument",
|
||||
"openEmailDraft",
|
||||
"openPanel",
|
||||
"prepareDocumentOpen",
|
||||
"replaceEmailReplyBody",
|
||||
"restoreSelectionReference",
|
||||
"saveDocument",
|
||||
"streamDocDelta",
|
||||
"streamDocFinalize",
|
||||
"streamDocOpen",
|
||||
"swapSide",
|
||||
}
|
||||
|
||||
|
||||
def _module_surface():
|
||||
script = r"""
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`);
|
||||
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
const surface = await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?v=module-api-surface-1');
|
||||
const fn = (o) => Object.keys(o).filter(k => typeof o[k] === 'function');
|
||||
return {
|
||||
named: Object.keys(mod).filter(k => k !== 'default'),
|
||||
namedFunctions: fn(mod).filter(k => k !== 'default'),
|
||||
defaultKeys: Object.keys(mod.default),
|
||||
defaultFunctions: fn(mod.default),
|
||||
globalIsSameObject: window.documentModule === mod.default,
|
||||
};
|
||||
});
|
||||
console.log(JSON.stringify(surface));
|
||||
await browser.close();
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module", "-e", script],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_default_export_surface_is_complete_and_callable():
|
||||
surface = _module_surface()
|
||||
|
||||
missing = DEFAULT_EXPORT_KEYS - set(surface["defaultKeys"])
|
||||
assert not missing, f"default export lost methods: {sorted(missing)}"
|
||||
|
||||
not_callable = DEFAULT_EXPORT_KEYS - set(surface["defaultFunctions"])
|
||||
assert not not_callable, (
|
||||
f"default export keys that are not functions: {sorted(not_callable)}"
|
||||
)
|
||||
|
||||
|
||||
def test_named_exports_survive_and_stay_callable():
|
||||
surface = _module_surface()
|
||||
|
||||
missing = NAMED_EXPORTS - set(surface["named"])
|
||||
assert not missing, f"named exports lost: {sorted(missing)}"
|
||||
|
||||
not_callable = NAMED_EXPORTS - set(surface["namedFunctions"])
|
||||
assert not not_callable, f"named exports that are not functions: {sorted(not_callable)}"
|
||||
|
||||
|
||||
def test_window_bridge_is_the_default_export():
|
||||
"""`window.documentModule` is a compatibility bridge no import graph shows.
|
||||
|
||||
Consumers reach the editor off the global, so it must stay the same object
|
||||
as the default export rather than a second, partially wired copy.
|
||||
"""
|
||||
assert "window.documentModule = documentModule" in DOC_JS
|
||||
assert _module_surface()["globalIsSameObject"] is True
|
||||
@@ -3,10 +3,11 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Regression guards for the Markdown preview hover-to-edit control."""
|
||||
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_checklist_enter_uses_native_edit_commands_and_resets_state():
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_rich_docx_converter_maps_editor_structure_instead_of_raw_html():
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_find_index_inserts_boundaries_without_flattening_inline_spans():
|
||||
section = DOC_JS.split("function _buildRichFindRanges", 1)[1].split(
|
||||
"function _renderRichFindRanges", 1
|
||||
)[0]
|
||||
section = function_body("_buildRichFindRanges")
|
||||
assert "const blockSelector = 'p,div,h1,h2,h3,h4,h5,h6,li,blockquote,pre,td,th'" in section
|
||||
assert "block !== previousBlock" in section
|
||||
assert "between.cloneContents().querySelector?.('br')" in section
|
||||
|
||||
@@ -6,10 +6,11 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
@@ -47,8 +48,8 @@ def test_horizontal_rule_is_ordered_after_clear_formatting():
|
||||
|
||||
|
||||
def test_image_options_are_hidden_until_a_rich_image_is_selected():
|
||||
clear_fn = DOC_JS.split("function _clearRichImageSelection()", 1)[1].split("function _selectRichImage", 1)[0]
|
||||
select_fn = DOC_JS.split("function _selectRichImage", 1)[1].split("function _selectedRichImage", 1)[0]
|
||||
clear_fn = function_body("_clearRichImageSelection")
|
||||
select_fn = function_body("_selectRichImage")
|
||||
assert "imageButton.style.display = 'none';" in clear_fn
|
||||
assert "imageButton.style.display = '';" in select_fn
|
||||
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_heading_enter_uses_single_native_history_commands():
|
||||
helper = DOC_JS.split("function _handleRichHeadingEnter", 1)[1].split(
|
||||
"let _richInlineCodeTypingArmed", 1
|
||||
)[0]
|
||||
helper = function_body("_handleRichHeadingEnter")
|
||||
|
||||
assert "selection.isCollapsed" in helper
|
||||
assert "h1, h2, h3, h4, h5, h6" in helper
|
||||
|
||||
@@ -5,20 +5,17 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_image_caption_uses_semantic_figure_and_structured_export_paths():
|
||||
caption = DOC_JS.split("async function _editRichImageCaption", 1)[1].split(
|
||||
"function _applyRichImageAction", 1
|
||||
)[0]
|
||||
converter = DOC_JS.split("function _docxFigureBlocks", 1)[1].split(
|
||||
"function _docxBlocksFromNodes", 1
|
||||
)[0]
|
||||
caption = function_body("_editRichImageCaption")
|
||||
converter = function_body("_docxFigureBlocks")
|
||||
|
||||
assert "function _promptImageCaption" in DOC_JS
|
||||
assert "function _replaceRichImageFigure" in DOC_JS
|
||||
|
||||
@@ -5,10 +5,11 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -3,26 +3,21 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import declaration, document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_heading_levels_and_page_break_are_exposed_everywhere():
|
||||
slash = DOC_JS.split("const _RICH_SLASH_COMMANDS", 1)[1].split(
|
||||
"const _RICH_BLOCK_INPUT_RULES", 1
|
||||
)[0]
|
||||
rules = DOC_JS.split("const _RICH_BLOCK_INPUT_RULES", 1)[1].split(
|
||||
"function _applyRichBlockInputRule", 1
|
||||
)[0]
|
||||
slash = declaration("_RICH_SLASH_COMMANDS")
|
||||
rules = declaration("_RICH_BLOCK_INPUT_RULES")
|
||||
dropdown = DOC_JS.split("function _showMdDropdown", 1)[1].split(
|
||||
"function initMdToolbar", 1
|
||||
)[0]
|
||||
exporter = DOC_JS.split("function _richTextExportCss", 1)[1].split(
|
||||
"function exportAsHtml", 1
|
||||
)[0]
|
||||
exporter = function_body("_richTextExportCss")
|
||||
|
||||
for level in (5, 6):
|
||||
assert f"action: 'h{level}'" in slash
|
||||
|
||||
@@ -3,19 +3,16 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_table_cell_alignment_uses_contextual_undoable_table_path():
|
||||
actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
|
||||
"function _insertRichPageBreak", 1
|
||||
)[0]
|
||||
state = DOC_JS.split("function _richDropdownCurrentActions", 1)[1].split(
|
||||
"function _showMdDropdown", 1
|
||||
)[0]
|
||||
actions = function_body("_applyRichTableAction")
|
||||
state = function_body("_richDropdownCurrentActions")
|
||||
|
||||
for alignment in ("top", "middle", "bottom"):
|
||||
assert f"table:cell-align:{alignment}" in DOC_JS
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_table_structure_uses_central_header_mode_normalization():
|
||||
helpers = DOC_JS.split("function _richTableHeaderModes", 1)[1].split(
|
||||
"function _replaceRichTable", 1
|
||||
)[0]
|
||||
append = DOC_JS.split("function _appendRichTableRow", 1)[1].split(
|
||||
"function _insertRichTable", 1
|
||||
)[0]
|
||||
append = function_body("_appendRichTableRow")
|
||||
actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
|
||||
"function applyMdFormat", 1
|
||||
)[0]
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_table_header_controls_use_tag_replacement_and_native_history():
|
||||
table_actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
|
||||
"function applyMdFormat", 1
|
||||
)[0]
|
||||
menu_state = DOC_JS.split("function _richDropdownCurrentActions", 1)[1].split(
|
||||
"function _showMdDropdown", 1
|
||||
)[0]
|
||||
menu_state = function_body("_richDropdownCurrentActions")
|
||||
|
||||
assert "function _replaceRichTableCellTag" in DOC_JS
|
||||
assert "table:toggle-header-row" in table_actions
|
||||
@@ -24,9 +23,7 @@ def test_table_header_controls_use_tag_replacement_and_native_history():
|
||||
assert "headerModes.headerRow = !headerModes.headerRow" in table_actions
|
||||
assert "headerModes.headerColumn = !headerModes.headerColumn" in table_actions
|
||||
assert "_applyRichTableHeaderModes(clone, headerModes)" in table_actions
|
||||
header_normalizer = DOC_JS.split("function _applyRichTableHeaderModes", 1)[1].split(
|
||||
"function _replaceRichTable", 1
|
||||
)[0]
|
||||
header_normalizer = function_body("_applyRichTableHeaderModes")
|
||||
assert "_replaceRichTableCellTag" in header_normalizer
|
||||
assert "_replaceRichTable(rich, original, clone" in table_actions
|
||||
assert "current.add('table:toggle-header-row')" in menu_state
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_merge_split_commands_use_contextual_undoable_table_path():
|
||||
actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split(
|
||||
"function _insertRichPageBreak", 1
|
||||
)[0]
|
||||
actions = function_body("_applyRichTableAction")
|
||||
menu = DOC_JS.split("function _showMdDropdown", 1)[1].split(
|
||||
"function initMdToolbar", 1
|
||||
)[0]
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_last_cell_tab_uses_the_undoable_table_replacement_path():
|
||||
helper = DOC_JS.split("function _appendRichTableRow", 1)[1].split(
|
||||
"function _insertRichTable", 1
|
||||
)[0]
|
||||
helper = function_body("_appendRichTableRow")
|
||||
key_handler = DOC_JS.split("if (e.key === 'Tab')", 1)[1].split(
|
||||
"let inList = false", 1
|
||||
)[0]
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.document_source import document_source, function_body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
@@ -84,9 +85,7 @@ def test_rich_text_commands_sync_and_schedule_save():
|
||||
|
||||
|
||||
def test_rich_text_paste_uses_document_allowlist_and_drops_embedded_media():
|
||||
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
|
||||
"function _wireEmailRichbody", 1
|
||||
)[0]
|
||||
paste_cleaner = function_body("_cleanRichTextPasteHtml")
|
||||
|
||||
assert "const allowedTags = new Set" in paste_cleaner
|
||||
assert "'TABLE'" in paste_cleaner
|
||||
@@ -154,9 +153,7 @@ def test_table_mutations_are_undoable_and_restore_the_caret():
|
||||
|
||||
|
||||
def test_temporary_table_tokens_are_not_persisted():
|
||||
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
|
||||
"function _richTextContentToHtml", 1
|
||||
)[0]
|
||||
sanitizer = function_body("_sanitizedRichTextHtml")
|
||||
|
||||
assert "data-editor-(?:table|checklist|image|inline-code|link)-token" in sanitizer
|
||||
assert ".replace(" in sanitizer
|
||||
@@ -176,9 +173,7 @@ def test_rich_text_checklists_support_conversion_and_checked_state():
|
||||
|
||||
|
||||
def test_checklist_interactions_cover_pointer_and_keyboard_users():
|
||||
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
|
||||
"function _emailRichbodyActive", 1
|
||||
)[0]
|
||||
rich_wiring = function_body("_wireEmailRichbody")
|
||||
|
||||
assert "rich.addEventListener('pointerdown'" in rich_wiring
|
||||
assert "mod && key === 'enter'" in rich_wiring
|
||||
@@ -186,9 +181,7 @@ def test_checklist_interactions_cover_pointer_and_keyboard_users():
|
||||
|
||||
|
||||
def test_checklist_markup_survives_paste_save_and_export():
|
||||
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
|
||||
"function _wireEmailRichbody", 1
|
||||
)[0]
|
||||
paste_cleaner = function_body("_cleanRichTextPasteHtml")
|
||||
|
||||
assert "keepChecklistClass" in paste_cleaner
|
||||
assert "keepChecklistState" in paste_cleaner
|
||||
@@ -239,9 +232,7 @@ def test_rich_text_image_insertion_and_edits_are_undoable():
|
||||
|
||||
|
||||
def test_rich_text_image_selection_markers_are_not_persisted():
|
||||
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
|
||||
"function _richTextContentToHtml", 1
|
||||
)[0]
|
||||
sanitizer = function_body("_sanitizedRichTextHtml")
|
||||
|
||||
assert "(?:table|checklist|image|inline-code|link)-token" in sanitizer
|
||||
assert "data-editor-image-selected" in sanitizer
|
||||
@@ -258,9 +249,7 @@ def test_rich_text_image_styles_are_available_in_editor_and_export():
|
||||
|
||||
|
||||
def test_existing_figure_wrapped_images_are_normalized_on_load():
|
||||
normalizer = DOC_JS.split("function _normalizeRichTextImages", 1)[1].split(
|
||||
"function _clearRichImageSelection", 1
|
||||
)[0]
|
||||
normalizer = function_body("_normalizeRichTextImages")
|
||||
|
||||
assert "figure.richtext-image" in normalizer
|
||||
assert "image.classList.add('richtext-image')" in normalizer
|
||||
@@ -293,9 +282,7 @@ def test_inline_code_supports_selection_toggle_and_future_typing():
|
||||
|
||||
|
||||
def test_inline_code_live_marker_is_saved_as_semantic_code():
|
||||
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
|
||||
"function _richTextContentToHtml", 1
|
||||
)[0]
|
||||
sanitizer = function_body("_sanitizedRichTextHtml")
|
||||
|
||||
assert "_isRichInlineCodeMarker(span)" in sanitizer
|
||||
assert "document.createElement('code')" in sanitizer
|
||||
@@ -303,9 +290,7 @@ def test_inline_code_live_marker_is_saved_as_semantic_code():
|
||||
|
||||
|
||||
def test_rich_code_shortcuts_and_active_state_are_wired():
|
||||
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
|
||||
"function _emailRichbodyActive", 1
|
||||
)[0]
|
||||
rich_wiring = function_body("_wireEmailRichbody")
|
||||
|
||||
assert "action = 'codeblock'" in rich_wiring
|
||||
assert "action = 'code'" in rich_wiring
|
||||
@@ -339,12 +324,8 @@ def test_rich_links_validate_protocols_during_paste_save_and_editing():
|
||||
normalizer = DOC_JS.split("function _normalizeRichLinkUrl", 1)[1].split(
|
||||
"function _promptLink", 1
|
||||
)[0]
|
||||
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
|
||||
"function _richTextContentToHtml", 1
|
||||
)[0]
|
||||
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
|
||||
"function _wireEmailRichbody", 1
|
||||
)[0]
|
||||
sanitizer = function_body("_sanitizedRichTextHtml")
|
||||
paste_cleaner = function_body("_cleanRichTextPasteHtml")
|
||||
|
||||
assert "https?:|mailto:|tel:" in normalizer
|
||||
assert "https:${url}" in normalizer
|
||||
@@ -370,9 +351,7 @@ def test_rich_link_edits_and_removal_use_native_undoable_commands():
|
||||
|
||||
|
||||
def test_link_toolbar_toggles_link_when_selection_is_already_linked():
|
||||
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
|
||||
"function _emailRichbodyActive", 1
|
||||
)[0]
|
||||
rich_wiring = function_body("_wireEmailRichbody")
|
||||
|
||||
assert "const currentLink = _richLinkAtRange(rich, selectionRange)" in rich_wiring
|
||||
assert "set('[data-md=\"link\"]', !!currentLink)" in rich_wiring
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCUMENT_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOCUMENT_JS = document_source()
|
||||
|
||||
|
||||
def test_format_change_clears_stale_run_output() -> None:
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_save_shortcut_uses_manual_version_path_and_cancels_autosave():
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Tests must reason about the document editor's module set, not one file.
|
||||
|
||||
``static/js/document.js`` is being decomposed. It stays the URL the browser
|
||||
requests, so a browser test that imports ``/static/js/document.js`` keeps
|
||||
working. What does not survive is reading the file off disk: a test that greps
|
||||
the entry file alone silently covers less as soon as the behaviour it names
|
||||
moves into a module, and it keeps passing while doing so.
|
||||
|
||||
``tests/helpers/document_source`` is the way to read it. This fails on the two
|
||||
habits that break, both of which existed here before the helper did.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SELF = Path(__file__).name
|
||||
|
||||
# The helper itself names the file, because being the one place that does is
|
||||
# the point.
|
||||
ALLOWED = {SELF, "document_source.py"}
|
||||
|
||||
# Every test language the assertions can hide in. A Python-only glob is what
|
||||
# let the JS references to ``static/style.css`` outlive the file they named.
|
||||
_SUFFIXES = ("*.py", "*.mjs", "*.js", "*.html")
|
||||
|
||||
# Reading the entry file off disk. Not matched: `/static/js/document.js` as a
|
||||
# request URL or a dynamic `import()`, which stay correct through the wrapper,
|
||||
# and which `test_frontend_module_version_parity.py` already pins.
|
||||
_DIRECT_READ = re.compile(
|
||||
r'["\']static/js/document\.js["\']' # string literal
|
||||
r'|"static"\s*/\s*"js"\s*/\s*"document\.js"' # pathlib join
|
||||
)
|
||||
|
||||
# Slices of the form `src.split(A, 1)[1].split(B, 1)[0]` -- "the region between
|
||||
# A and B". That region is only what the test means while A and B are
|
||||
# neighbours in one file. Several also hard-code the entry file's two-space
|
||||
# indentation, which no extracted module reproduces.
|
||||
_ADJACENCY_SLICE = re.compile(
|
||||
r'(?P<var>\b[A-Za-z_]\w*)\.split\(\s*\n?\s*(?P<q1>["\'])(?P<a>(?:[^"\'\\]|\\.)+?)(?P=q1)'
|
||||
r'\s*,\s*1\s*\)\[1\]\s*\n?\s*\.split\(\s*\n?\s*(?P<q2>["\'])(?P<b>(?:[^"\'\\]|\\.)+?)(?P=q2)'
|
||||
r'\s*,\s*1\s*\)\[0\]',
|
||||
re.S,
|
||||
)
|
||||
|
||||
# The adjacency slices still in the tree, each spanning a whole family of
|
||||
# functions rather than one construct -- "everything from _docxHexColor to
|
||||
# exportAsDocx". Collapsing one to its first member drops what the assertions
|
||||
# look for, so they cannot be rewritten mechanically: each is converted when
|
||||
# the family it spans becomes a module, and its entry deleted here then.
|
||||
#
|
||||
# This list may only shrink. A new entry means a new adjacency-dependent slice
|
||||
# was written, which is the habit the helper exists to end.
|
||||
KNOWN_ADJACENCY_SLICES = {
|
||||
('test_document_active_restore.py',
|
||||
'for (const doc of activeDocs)',
|
||||
'_syncDocIndicator'),
|
||||
('test_document_edit_reference_js.py',
|
||||
'function clearSelection() {',
|
||||
'\\n }'),
|
||||
('test_document_rich_checklist_enter.py',
|
||||
'function _handleRichChecklistEnter',
|
||||
'let _richInlineCodeTypingArmed'),
|
||||
('test_document_rich_docx_export.py',
|
||||
'async function exportAsDocx',
|
||||
'/** Delete the active document'),
|
||||
('test_document_rich_docx_export.py',
|
||||
'function _docxHexColor',
|
||||
'async function exportAsDocx'),
|
||||
('test_document_rich_structure_tools.py',
|
||||
'function _showMdDropdown',
|
||||
'function initMdToolbar'),
|
||||
('test_document_rich_table_header_preservation.py',
|
||||
'function _applyRichTableAction',
|
||||
'function applyMdFormat'),
|
||||
('test_document_rich_table_header_preservation.py',
|
||||
'function _richTableHeaderModes',
|
||||
'function _replaceRichTable'),
|
||||
('test_document_rich_table_headers.py',
|
||||
'function _applyRichTableAction',
|
||||
'function applyMdFormat'),
|
||||
('test_document_rich_table_merge_split.py',
|
||||
'function _showMdDropdown',
|
||||
'function initMdToolbar'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'// ---- Selection-based AI editing ----',
|
||||
'// ── Inline Suggestion Comments'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'// Undo button in header',
|
||||
'// Diff toggle button'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'// ── In-document find (Ctrl+F) ──',
|
||||
'// Delete (or Backspace)'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'const _richSpacingBlockSelector',
|
||||
'function _focusRichTextOffset'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'function _insertRichTextImages',
|
||||
'async function _uploadMarkdownImages'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'function _normalizeRichLinkUrl',
|
||||
'function _promptImageAlt'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'function _normalizeRichLinkUrl',
|
||||
'function _promptLink'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'function _replaceAllLiteral',
|
||||
'function _doFind'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'function _replaceRichTable',
|
||||
'function applyMdFormat'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'function _richLinkAtRange',
|
||||
'function _richSelectionCell'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'function _richSelectionChecklistItem',
|
||||
'function _cleanRichTextPasteHtml'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'function _richSelectionInlineCode',
|
||||
'function _cleanRichTextPasteHtml'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'function _showMdDropdown',
|
||||
'function initMdToolbar'),
|
||||
('test_document_rich_text_tools.py',
|
||||
'function _wireEmailRichbody',
|
||||
'function _richSelectionElement'),
|
||||
('test_document_rich_toolbar_menus.py',
|
||||
'function _showMdDropdown',
|
||||
'function initMdToolbar'),
|
||||
('test_document_rich_toolbar_menus.py',
|
||||
'function initMdToolbar',
|
||||
'function _applyDocFont'),
|
||||
('test_document_toolbar_order.py',
|
||||
'const _DOCUMENT_TOOLBAR_GROUPS',
|
||||
'function _orderDocumentToolbar'),
|
||||
('test_review_docx_async_identity.py',
|
||||
' let _docxPreviewRequest = 0;',
|
||||
' /** Parse CSV'),
|
||||
}
|
||||
|
||||
|
||||
def _test_sources() -> list[Path]:
|
||||
found: list[Path] = []
|
||||
for suffix in _SUFFIXES:
|
||||
found.extend((ROOT / "tests").rglob(suffix))
|
||||
return [p for p in sorted(set(found)) if p.name not in ALLOWED]
|
||||
|
||||
|
||||
def test_sources_are_discoverable() -> None:
|
||||
"""Guard the guard: a layout change must not make this vacuous."""
|
||||
sources = _test_sources()
|
||||
assert len(sources) > 100
|
||||
suffixes = {p.suffix for p in sources}
|
||||
assert {".py", ".mjs", ".js"} <= suffixes, suffixes
|
||||
assert any(p.parent != ROOT / "tests" for p in sources), "walk is not recursive"
|
||||
|
||||
|
||||
def test_no_test_reads_the_document_entry_file_off_disk() -> None:
|
||||
offenders = [
|
||||
str(p.relative_to(ROOT))
|
||||
for p in _test_sources()
|
||||
if _DIRECT_READ.search(p.read_text(encoding="utf-8"))
|
||||
]
|
||||
|
||||
assert not offenders, (
|
||||
"read the document editor through tests.helpers.document_source instead "
|
||||
"of static/js/document.js, which is becoming a re-export wrapper: "
|
||||
f"{offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_adjacency_slices_only_shrink() -> None:
|
||||
"""No new "region between two declarations" slice enters the tree."""
|
||||
found = set()
|
||||
for path in _test_sources():
|
||||
if path.suffix != ".py":
|
||||
continue
|
||||
source = path.read_text(encoding="utf-8")
|
||||
if "tests.helpers.document_source" not in source:
|
||||
continue
|
||||
bound = {
|
||||
m.group(1)
|
||||
for m in re.finditer(r"(\w+)\s*=\s*document_source\(\)", source)
|
||||
}
|
||||
for m in _ADJACENCY_SLICE.finditer(source):
|
||||
if m.group("var") in bound:
|
||||
found.add((path.name, m.group("a"), m.group("b")))
|
||||
|
||||
added = found - KNOWN_ADJACENCY_SLICES
|
||||
assert not added, (
|
||||
"these slices depend on two declarations being neighbours in one file, "
|
||||
"which decomposition breaks; use function_body()/declaration() or "
|
||||
"assert against the owning module: " + repr(sorted(added))
|
||||
)
|
||||
|
||||
removed = KNOWN_ADJACENCY_SLICES - found
|
||||
assert not removed, (
|
||||
"these adjacency slices are gone -- delete them from "
|
||||
f"KNOWN_ADJACENCY_SLICES so the list keeps shrinking: {sorted(removed)}"
|
||||
)
|
||||
@@ -5,10 +5,11 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Regression guards for exact inline document-suggestion highlighting."""
|
||||
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
SOURCE = Path("static/js/document.js").read_text()
|
||||
SOURCE = document_source()
|
||||
|
||||
|
||||
def test_newly_mounted_document_pane_cannot_save_before_binding():
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_toolbar_arrows_have_real_flex_slots_outside_icon_scroller():
|
||||
script = (ROOT / "static/js/document.js").read_text()
|
||||
script = document_source()
|
||||
styles = (ROOT / "static/style.css").read_text()
|
||||
|
||||
leading = script.index('class="md-toolbar-leading-controls"')
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
DOC_JS = document_source()
|
||||
|
||||
|
||||
def test_toolbar_groups_define_writing_actions_before_view_controls():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -27,7 +28,7 @@ def test_email_ai_reply_context_only_clears_after_draft_opens():
|
||||
|
||||
|
||||
def test_document_ai_reply_does_not_overwrite_an_edited_draft():
|
||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
start = source.index(" async function _aiReply(")
|
||||
end = source.index(" async function _scheduleSend(", start)
|
||||
function = source[start:end]
|
||||
@@ -46,7 +47,7 @@ def test_document_ai_reply_does_not_overwrite_an_edited_draft():
|
||||
|
||||
|
||||
def test_replacing_an_email_reply_checks_the_visible_draft_first():
|
||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
start = source.index(" export async function replaceEmailReplyBody(")
|
||||
end = source.index(" export async function ensureEmailDraftEnvelope(", start)
|
||||
function = source[start:end]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import sqlite3
|
||||
from email.message import EmailMessage
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
def test_attachment_filename_is_part_of_ui_index_search(tmp_path, monkeypatch):
|
||||
@@ -80,7 +81,7 @@ def test_remote_search_explicitly_checks_mime_filename_headers():
|
||||
|
||||
def test_forwarding_filters_signature_assets_and_mobile_export_stops_bubbling():
|
||||
inbox = open("static/js/emailInbox.js", encoding="utf-8").read()
|
||||
document = open("static/js/document.js", encoding="utf-8").read()
|
||||
document = document_source()
|
||||
|
||||
assert "const forwardedAttachments = mode === 'forward'" in inbox
|
||||
assert "forwardedAttachments.map" in inbox
|
||||
@@ -99,7 +100,7 @@ def test_attachment_open_spins_icon_only():
|
||||
|
||||
|
||||
def test_move_document_creates_destination_before_adopting_it():
|
||||
document = open("static/js/document.js", encoding="utf-8").read()
|
||||
document = document_source()
|
||||
start = document.index("async function moveActiveDocumentToNewChat()")
|
||||
end = document.index("\n function showDocTabMenu", start)
|
||||
handler = document[start:end]
|
||||
@@ -188,7 +189,7 @@ def test_empty_reply_has_two_editable_rows_and_reply_survives_compact_toolbar():
|
||||
|
||||
|
||||
def test_email_toolbar_places_attachment_before_link():
|
||||
document = open("static/js/document.js", encoding="utf-8").read()
|
||||
document = document_source()
|
||||
toolbar_start = document.index('<div class="md-toolbar-items"')
|
||||
toolbar_end = document.index('</div>', toolbar_start)
|
||||
toolbar = document[toolbar_start:toolbar_end]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
@@ -164,8 +165,7 @@ def test_email_client_cache_drops_fixture_rows():
|
||||
|
||||
def test_email_compose_can_attach_gallery_images():
|
||||
"""Compose attachments should support local files, documents, and Gallery images."""
|
||||
frontend = _REPO / "static" / "js" / "document.js"
|
||||
frontend_text = frontend.read_text(encoding="utf-8")
|
||||
frontend_text = document_source()
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "Upload from computer" in frontend_text
|
||||
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
@@ -120,7 +121,7 @@ def test_library_reply_open_carries_immutable_mailbox_context():
|
||||
|
||||
|
||||
def test_attachment_warning_only_checks_authored_reply_text():
|
||||
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
helper = source[source.index("function _bodyMentionsAttachment"):source.index("\n\n function _clearMissingAttachmentWarnings", source.index("function _bodyMentionsAttachment"))]
|
||||
|
||||
assert "_emailReplyOwnText(text)" in helper
|
||||
@@ -128,7 +129,7 @@ def test_attachment_warning_only_checks_authored_reply_text():
|
||||
|
||||
|
||||
def test_email_send_saves_recovery_draft_before_send_and_retains_it_on_failure():
|
||||
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
|
||||
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
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
SCRIPT = (
|
||||
Path(__file__).resolve().parents[1] / "static/js/document.js"
|
||||
).read_text(encoding="utf-8")
|
||||
SCRIPT = document_source()
|
||||
|
||||
|
||||
def test_email_send_requires_actual_send_button_event_target():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -14,7 +15,7 @@ def test_card_delete_waits_for_durable_success_and_uses_email_identity():
|
||||
|
||||
|
||||
def test_finished_send_does_not_close_whichever_library_opened_later():
|
||||
source = (ROOT / "static/js/document.js").read_text()
|
||||
source = document_source()
|
||||
start = source.index("async function _sendEmail()")
|
||||
end = source.index("async function _saveDraft()", start)
|
||||
send = source[start:end]
|
||||
|
||||
@@ -19,6 +19,7 @@ from src.tool_capabilities import (
|
||||
messages_contain_external_untrusted_context,
|
||||
tool_result_should_arm_gate,
|
||||
)
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ToolBlock = namedtuple("ToolBlock", ["tool_type", "content"])
|
||||
@@ -1403,13 +1404,15 @@ def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
|
||||
"static/js/chat.js",
|
||||
"static/js/chatRenderer.js",
|
||||
"static/js/chatStream.js",
|
||||
"static/js/document.js",
|
||||
"static/js/emailInbox.js",
|
||||
"static/js/emailLibrary.js",
|
||||
"static/js/settings.js",
|
||||
"static/js/slashCommands.js",
|
||||
)
|
||||
]
|
||||
# The document editor is a module set, not one file: a stale version string
|
||||
# must not be able to hide in a module extracted out of document.js.
|
||||
approval_module_sources.append(document_source())
|
||||
assert all(
|
||||
"20260722emailfastindex1" not in source
|
||||
for source in approval_module_sources
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Every module the frontend imports must exist, and the document set must be cached.
|
||||
|
||||
There is no bundler here, so nothing resolves the import graph before a browser
|
||||
does. A specifier that names a file which is not there is valid JavaScript:
|
||||
``node --check`` passes, and ``test_frontend_module_version_parity.py`` checks
|
||||
that a module is loaded under one URL identity without checking that the URL
|
||||
leads anywhere. The failure surfaces as a blank panel at runtime, and in the
|
||||
test suite as a scatter of unrelated browser tests going red at once with no
|
||||
mention of the missing file.
|
||||
|
||||
That is affordable to close statically, so this closes it.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.document_source import document_source_paths
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
|
||||
# Vendored third-party builds and the committed editor build output are not
|
||||
# ours to reason about.
|
||||
_SKIP_DIRS = ("lib/", "js/editor/build/")
|
||||
|
||||
# `import x from '...'`, `export ... from '...'`, and `import('...')`. Only
|
||||
# quoted specifiers: a template literal is not statically resolvable, and the
|
||||
# app does not use one.
|
||||
_SPECIFIER = re.compile(
|
||||
r"""(?:^|[^\w.$])(?:import|export)\s*(?:[\w*{},\s$]*?\s*from\s*)?['"]([^'"]+)['"]"""
|
||||
r"""|\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)""",
|
||||
re.M,
|
||||
)
|
||||
|
||||
|
||||
def _own_scripts() -> list[Path]:
|
||||
out = []
|
||||
for path in sorted(STATIC.rglob("*.js")):
|
||||
rel = path.relative_to(STATIC).as_posix()
|
||||
if any(rel.startswith(d) or f"/{d}" in rel for d in _SKIP_DIRS):
|
||||
continue
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
|
||||
def _imports(path: Path):
|
||||
"""(line, specifier, resolved path) for each relative/app-absolute import."""
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for match in _SPECIFIER.finditer(source):
|
||||
specifier = match.group(1) or match.group(2)
|
||||
if not specifier:
|
||||
continue
|
||||
if not (specifier.startswith(".") or specifier.startswith("/static/")):
|
||||
continue # bare specifier: not a file in this tree
|
||||
bare = specifier.split("?")[0].split("#")[0]
|
||||
if bare.startswith("/static/"):
|
||||
target = STATIC / bare.removeprefix("/static/")
|
||||
else:
|
||||
target = path.parent / bare
|
||||
line = source.count("\n", 0, match.start()) + 1
|
||||
yield line, specifier, target
|
||||
|
||||
|
||||
def test_sources_are_discoverable() -> None:
|
||||
"""Guard the guard: this must not pass by scanning nothing."""
|
||||
scripts = _own_scripts()
|
||||
assert len(scripts) > 100, len(scripts)
|
||||
total = sum(1 for p in scripts for _ in _imports(p))
|
||||
assert total > 300, total
|
||||
|
||||
|
||||
def test_every_frontend_import_resolves_to_a_file() -> None:
|
||||
broken = [
|
||||
f"{path.relative_to(ROOT)}:{line} -> {specifier}"
|
||||
for path in _own_scripts()
|
||||
for line, specifier, target in _imports(path)
|
||||
if not target.is_file()
|
||||
]
|
||||
|
||||
assert not broken, "imports naming files that do not exist: " + repr(broken)
|
||||
|
||||
|
||||
def test_document_implementation_set_is_precached() -> None:
|
||||
"""A module extracted out of document.js must join the offline manifest.
|
||||
|
||||
``static/sw.js`` fetches the URLs it lists, nothing they in turn import, so
|
||||
a new module under ``static/js/document/`` is not cached just because the
|
||||
entry point that imports it is. Without it the editor breaks offline for
|
||||
anyone whose cache predates the split.
|
||||
"""
|
||||
service_worker = (STATIC / "sw.js").read_text(encoding="utf-8")
|
||||
|
||||
missing = []
|
||||
for path in document_source_paths():
|
||||
url = "/static/" + path.relative_to(STATIC).as_posix()
|
||||
if not re.search(rf"['\"]{re.escape(url)}(?:\?[^'\"]*)?['\"]", service_worker):
|
||||
missing.append(url)
|
||||
|
||||
assert not missing, (
|
||||
"document editor modules absent from the sw.js precache lists: "
|
||||
f"{missing}"
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Regression guards for markdown raw-HTML sanitizer helpers."""
|
||||
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
@@ -27,7 +28,7 @@ def test_markdown_raw_html_sanitizer_strips_scriptable_css():
|
||||
|
||||
def test_email_rich_body_render_path_reuses_raw_html_sanitizer():
|
||||
markdown_src = (_REPO / "static" / "js" / "markdown.js").read_text(encoding="utf-8")
|
||||
document_src = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
|
||||
document_src = document_source()
|
||||
email_body_helper = document_src.split("function _emailBodyToHtml(text)", 1)[1].split(
|
||||
" // Mirror the rich body's plain text", 1
|
||||
)[0]
|
||||
|
||||
@@ -15,6 +15,7 @@ import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
@@ -401,7 +402,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
|
||||
|
||||
def test_pdf_export_typesets_its_container_before_html2pdf():
|
||||
"""Ordering in a call site, so pin the call site. No node needed."""
|
||||
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
|
||||
assert match, "exportAsPdf not found"
|
||||
body = match.group(1)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -12,7 +13,7 @@ def test_opening_notes_minimizes_open_document():
|
||||
|
||||
|
||||
def test_opening_document_minimizes_notes():
|
||||
script = (ROOT / "static/js/document.js").read_text()
|
||||
script = document_source()
|
||||
|
||||
assert "function _minimizeNotesForDocumentOpen()" in script
|
||||
assert "window.notesModule.closePanel('down')" in script
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_pdf_backed_documents_do_not_offer_destructive_html_pdf_export():
|
||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
|
||||
assert "if (!isForm) {" in source
|
||||
assert "label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF'" in source
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_preview_hides_import_action_and_restores_it_for_empty_editor():
|
||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
|
||||
assert "const emptyImport = document.getElementById('doc-rich-empty-import');" in source
|
||||
assert "if (emptyImport) emptyImport.style.display = 'none';" in source
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Execute the actual DOCX handlers with deferred network responses."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
def test_docx_responses_do_not_overwrite_new_tabs_or_hidden_previews():
|
||||
source = (Path(__file__).resolve().parents[1] / "static/js/document.js").read_text()
|
||||
source = document_source()
|
||||
handlers = source.split(" let _docxPreviewRequest = 0;", 1)[1].split(" /** Parse CSV", 1)[0]
|
||||
script = r'''
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_richtext_toolbar_preserves_selection_before_formatting():
|
||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
|
||||
assert "let _savedFormatTextareaSelection = null;" in source
|
||||
assert "let _savedFormatRichRange = null;" in source
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_richtext_preview_returns_to_contenteditable_editor():
|
||||
source = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
source = document_source()
|
||||
|
||||
assert "const currentLang = document.getElementById('doc-language-select')?.value || '';" in source
|
||||
assert "if (richMode) {" in source
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_selection_overlays_have_individual_clear_controls():
|
||||
js = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
js = document_source()
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
assert "function clearSelectionAt(index)" in js
|
||||
|
||||
Reference in New Issue
Block a user