mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
Merge pull request #31 from o3LL/test/document-module-set-helper
test(document): read the editor through a module-set helper before it is split
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);"
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
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]
|
||||
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 = app_css()
|
||||
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||
APP = (ROOT / "static/app.js").read_text(encoding="utf-8")
|
||||
|
||||
@@ -10,10 +10,10 @@ document.js is browser-coupled and not importable in pytest.
|
||||
|
||||
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_CSS = app_css()
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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
|
||||
@@ -5,10 +5,10 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
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_CSS = app_css()
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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():
|
||||
|
||||
@@ -5,10 +5,10 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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():
|
||||
|
||||
@@ -4,16 +4,15 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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
|
||||
|
||||
@@ -7,10 +7,11 @@ from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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()
|
||||
|
||||
|
||||
@@ -48,8 +49,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
|
||||
|
||||
|
||||
@@ -4,16 +4,15 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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
|
||||
|
||||
@@ -6,20 +6,17 @@ from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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
|
||||
|
||||
@@ -6,10 +6,11 @@ from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -5,26 +5,20 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
|
||||
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 = app_css()
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -4,19 +4,16 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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
|
||||
|
||||
@@ -4,19 +4,18 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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]
|
||||
|
||||
@@ -4,19 +4,18 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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
|
||||
@@ -25,9 +24,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
|
||||
|
||||
@@ -4,16 +4,15 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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]
|
||||
|
||||
@@ -4,16 +4,15 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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
|
||||
|
||||
@@ -5,10 +5,10 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import app_css
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
|
||||
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,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:
|
||||
|
||||
@@ -4,10 +4,11 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
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_CSS = app_css()
|
||||
|
||||
|
||||
|
||||
@@ -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,12 +1,13 @@
|
||||
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]
|
||||
|
||||
|
||||
def test_toolbar_arrows_have_real_flex_slots_outside_icon_scroller():
|
||||
script = (ROOT / "static/js/document.js").read_text()
|
||||
script = document_source()
|
||||
styles = app_css()
|
||||
|
||||
leading = script.index('class="md-toolbar-leading-controls"')
|
||||
|
||||
@@ -4,10 +4,11 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
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,12 +1,13 @@
|
||||
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]
|
||||
|
||||
|
||||
def test_selection_overlays_have_individual_clear_controls():
|
||||
js = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
js = document_source()
|
||||
css = app_css()
|
||||
|
||||
assert "function clearSelectionAt(index)" in js
|
||||
|
||||
Reference in New Issue
Block a user