diff --git a/tests/helpers/document_source.py b/tests/helpers/document_source.py new file mode 100644 index 000000000..691466978 --- /dev/null +++ b/tests/helpers/document_source.py @@ -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}") diff --git a/tests/test_active_document_visibility_contract.py b/tests/test_active_document_visibility_contract.py index efe3c2f2f..81bbfdfea 100644 --- a/tests/test_active_document_visibility_contract.py +++ b/tests/test_active_document_visibility_contract.py @@ -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 diff --git a/tests/test_document_active_restore.py b/tests/test_document_active_restore.py index 527cad7d8..1a2e32c8d 100644 --- a/tests/test_document_active_restore.py +++ b/tests/test_document_active_restore.py @@ -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 diff --git a/tests/test_document_ai_preview_refresh_js.py b/tests/test_document_ai_preview_refresh_js.py index ff57d50e1..6138e1fe5 100644 --- a/tests/test_document_ai_preview_refresh_js.py +++ b/tests/test_document_ai_preview_refresh_js.py @@ -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" diff --git a/tests/test_document_deeplink.py b/tests/test_document_deeplink.py index 95ee24f43..621249834 100644 --- a/tests/test_document_deeplink.py +++ b/tests/test_document_deeplink.py @@ -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- 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 diff --git a/tests/test_document_diff_discard_on_update_js.py b/tests/test_document_diff_discard_on_update_js.py index eb2ed05b0..40375d915 100644 --- a/tests/test_document_diff_discard_on_update_js.py +++ b/tests/test_document_diff_discard_on_update_js.py @@ -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);" diff --git a/tests/test_document_edit_reference_js.py b/tests/test_document_edit_reference_js.py index 65b607598..9ee7842ff 100644 --- a/tests/test_document_edit_reference_js.py +++ b/tests/test_document_edit_reference_js.py @@ -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") diff --git a/tests/test_document_editor_scroll.py b/tests/test_document_editor_scroll.py index 1023cab2f..a84876dd8 100644 --- a/tests/test_document_editor_scroll.py +++ b/tests/test_document_editor_scroll.py @@ -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() diff --git a/tests/test_document_history_controls.py b/tests/test_document_history_controls.py index ddffe6e69..709937586 100644 --- a/tests/test_document_history_controls.py +++ b/tests/test_document_history_controls.py @@ -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(): diff --git a/tests/test_document_module_api.py b/tests/test_document_module_api.py new file mode 100644 index 000000000..97c95de75 --- /dev/null +++ b/tests/test_document_module_api.py @@ -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('
'); + 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 diff --git a/tests/test_document_outline.py b/tests/test_document_outline.py index 63f4ebc90..ae5859b20 100644 --- a/tests/test_document_outline.py +++ b/tests/test_document_outline.py @@ -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() diff --git a/tests/test_document_preview_edit_button.py b/tests/test_document_preview_edit_button.py index ffe44f386..9eaa5708e 100644 --- a/tests/test_document_preview_edit_button.py +++ b/tests/test_document_preview_edit_button.py @@ -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() diff --git a/tests/test_document_rich_checklist_enter.py b/tests/test_document_rich_checklist_enter.py index 92154adb0..0e633e284 100644 --- a/tests/test_document_rich_checklist_enter.py +++ b/tests/test_document_rich_checklist_enter.py @@ -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(): diff --git a/tests/test_document_rich_color_reset_and_contrast.py b/tests/test_document_rich_color_reset_and_contrast.py index 7d13fc6c3..9a89e5764 100644 --- a/tests/test_document_rich_color_reset_and_contrast.py +++ b/tests/test_document_rich_color_reset_and_contrast.py @@ -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() diff --git a/tests/test_document_rich_docx_export.py b/tests/test_document_rich_docx_export.py index 61cc71ad2..6635051e7 100644 --- a/tests/test_document_rich_docx_export.py +++ b/tests/test_document_rich_docx_export.py @@ -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(): diff --git a/tests/test_document_rich_find_boundaries.py b/tests/test_document_rich_find_boundaries.py index ca2280455..dfc77ef20 100644 --- a/tests/test_document_rich_find_boundaries.py +++ b/tests/test_document_rich_find_boundaries.py @@ -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 diff --git a/tests/test_document_rich_font_color_controls.py b/tests/test_document_rich_font_color_controls.py index fa8691c2f..764e6152e 100644 --- a/tests/test_document_rich_font_color_controls.py +++ b/tests/test_document_rich_font_color_controls.py @@ -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 diff --git a/tests/test_document_rich_heading_enter.py b/tests/test_document_rich_heading_enter.py index 9a876ca9d..0bd4fe317 100644 --- a/tests/test_document_rich_heading_enter.py +++ b/tests/test_document_rich_heading_enter.py @@ -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 diff --git a/tests/test_document_rich_image_caption.py b/tests/test_document_rich_image_caption.py index b7e04101e..551b9da00 100644 --- a/tests/test_document_rich_image_caption.py +++ b/tests/test_document_rich_image_caption.py @@ -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 diff --git a/tests/test_document_rich_input_rules.py b/tests/test_document_rich_input_rules.py index cf714fe03..4d697e6c1 100644 --- a/tests/test_document_rich_input_rules.py +++ b/tests/test_document_rich_input_rules.py @@ -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() diff --git a/tests/test_document_rich_selection_toolbar.py b/tests/test_document_rich_selection_toolbar.py index b13977268..76f15a4f3 100644 --- a/tests/test_document_rich_selection_toolbar.py +++ b/tests/test_document_rich_selection_toolbar.py @@ -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() diff --git a/tests/test_document_rich_slash_menu.py b/tests/test_document_rich_slash_menu.py index 1ade47972..d4e8bd09d 100644 --- a/tests/test_document_rich_slash_menu.py +++ b/tests/test_document_rich_slash_menu.py @@ -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() diff --git a/tests/test_document_rich_structure_tools.py b/tests/test_document_rich_structure_tools.py index 049e7972b..1ff6f9a96 100644 --- a/tests/test_document_rich_structure_tools.py +++ b/tests/test_document_rich_structure_tools.py @@ -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 diff --git a/tests/test_document_rich_table_cell_alignment.py b/tests/test_document_rich_table_cell_alignment.py index 8f07d4231..ac2b850b2 100644 --- a/tests/test_document_rich_table_cell_alignment.py +++ b/tests/test_document_rich_table_cell_alignment.py @@ -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 diff --git a/tests/test_document_rich_table_header_preservation.py b/tests/test_document_rich_table_header_preservation.py index f08825dd0..378d02828 100644 --- a/tests/test_document_rich_table_header_preservation.py +++ b/tests/test_document_rich_table_header_preservation.py @@ -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] diff --git a/tests/test_document_rich_table_headers.py b/tests/test_document_rich_table_headers.py index fb52d4630..5d0d67693 100644 --- a/tests/test_document_rich_table_headers.py +++ b/tests/test_document_rich_table_headers.py @@ -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 diff --git a/tests/test_document_rich_table_merge_split.py b/tests/test_document_rich_table_merge_split.py index 6aeb76897..9269c154e 100644 --- a/tests/test_document_rich_table_merge_split.py +++ b/tests/test_document_rich_table_merge_split.py @@ -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] diff --git a/tests/test_document_rich_table_tab_history.py b/tests/test_document_rich_table_tab_history.py index ec7de0013..cbe116e0b 100644 --- a/tests/test_document_rich_table_tab_history.py +++ b/tests/test_document_rich_table_tab_history.py @@ -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] diff --git a/tests/test_document_rich_text_tools.py b/tests/test_document_rich_text_tools.py index 105b05322..d319efd11 100644 --- a/tests/test_document_rich_text_tools.py +++ b/tests/test_document_rich_text_tools.py @@ -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 diff --git a/tests/test_document_rich_toolbar_menus.py b/tests/test_document_rich_toolbar_menus.py index 6aa33b379..b6837d0c3 100644 --- a/tests/test_document_rich_toolbar_menus.py +++ b/tests/test_document_rich_toolbar_menus.py @@ -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() diff --git a/tests/test_document_run_format_switch.py b/tests/test_document_run_format_switch.py index ce1b5b87f..7350b83c7 100644 --- a/tests/test_document_run_format_switch.py +++ b/tests/test_document_run_format_switch.py @@ -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: diff --git a/tests/test_document_save_shortcut.py b/tests/test_document_save_shortcut.py index c1265071e..acf833def 100644 --- a/tests/test_document_save_shortcut.py +++ b/tests/test_document_save_shortcut.py @@ -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(): diff --git a/tests/test_document_source_test_hygiene.py b/tests/test_document_source_test_hygiene.py new file mode 100644 index 000000000..ab68844ac --- /dev/null +++ b/tests/test_document_source_test_hygiene.py @@ -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\b[A-Za-z_]\w*)\.split\(\s*\n?\s*(?P["\'])(?P(?:[^"\'\\]|\\.)+?)(?P=q1)' + r'\s*,\s*1\s*\)\[1\]\s*\n?\s*\.split\(\s*\n?\s*(?P["\'])(?P(?:[^"\'\\]|\\.)+?)(?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)}" + ) diff --git a/tests/test_document_stats.py b/tests/test_document_stats.py index 96aae9073..7c8bbe012 100644 --- a/tests/test_document_stats.py +++ b/tests/test_document_stats.py @@ -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() diff --git a/tests/test_document_suggestion_highlight.py b/tests/test_document_suggestion_highlight.py index f2272d66f..8fe6912ac 100644 --- a/tests/test_document_suggestion_highlight.py +++ b/tests/test_document_suggestion_highlight.py @@ -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() diff --git a/tests/test_document_suggestion_reopen_static.py b/tests/test_document_suggestion_reopen_static.py index 12735d8db..da2265289 100644 --- a/tests/test_document_suggestion_reopen_static.py +++ b/tests/test_document_suggestion_reopen_static.py @@ -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(): diff --git a/tests/test_document_toolbar_arrow_offset.py b/tests/test_document_toolbar_arrow_offset.py index 096586249..3df2232ea 100644 --- a/tests/test_document_toolbar_arrow_offset.py +++ b/tests/test_document_toolbar_arrow_offset.py @@ -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"') diff --git a/tests/test_document_toolbar_order.py b/tests/test_document_toolbar_order.py index 669422830..218067fbc 100644 --- a/tests/test_document_toolbar_order.py +++ b/tests/test_document_toolbar_order.py @@ -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(): diff --git a/tests/test_email_ai_reply_context_draft_static.py b/tests/test_email_ai_reply_context_draft_static.py index f15ea1ace..6bb370268 100644 --- a/tests/test_email_ai_reply_context_draft_static.py +++ b/tests/test_email_ai_reply_context_draft_static.py @@ -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] diff --git a/tests/test_email_attachment_search.py b/tests/test_email_attachment_search.py index 02470334b..5237dd577 100644 --- a/tests/test_email_attachment_search.py +++ b/tests/test_email_attachment_search.py @@ -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('
', toolbar_start) toolbar = document[toolbar_start:toolbar_end] diff --git a/tests/test_email_library_bulk_actions.py b/tests/test_email_library_bulk_actions.py index 3d196e915..0c31856d0 100644 --- a/tests/test_email_library_bulk_actions.py +++ b/tests/test_email_library_bulk_actions.py @@ -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 diff --git a/tests/test_email_open_dedup_js.py b/tests/test_email_open_dedup_js.py index 9e5bd9f1c..9af8020b0 100644 --- a/tests/test_email_open_dedup_js.py +++ b/tests/test_email_open_dedup_js.py @@ -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 diff --git a/tests/test_email_send_target_guard.py b/tests/test_email_send_target_guard.py index 4f1f8ec71..562bdbba6 100644 --- a/tests/test_email_send_target_guard.py +++ b/tests/test_email_send_target_guard.py @@ -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(): diff --git a/tests/test_email_ui_async_identity.py b/tests/test_email_ui_async_identity.py index 35e92501a..36eb43c66 100644 --- a/tests/test_email_ui_async_identity.py +++ b/tests/test_email_ui_async_identity.py @@ -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] diff --git a/tests/test_external_context_tool_gate.py b/tests/test_external_context_tool_gate.py index dc571e359..e949f88e0 100644 --- a/tests/test_external_context_tool_gate.py +++ b/tests/test_external_context_tool_gate.py @@ -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 diff --git a/tests/test_frontend_module_graph.py b/tests/test_frontend_module_graph.py new file mode 100644 index 000000000..75eb51194 --- /dev/null +++ b/tests/test_frontend_module_graph.py @@ -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}" + ) diff --git a/tests/test_markdown_dom_xss_helpers.py b/tests/test_markdown_dom_xss_helpers.py index db9ab9c9b..a2dd63b16 100644 --- a/tests/test_markdown_dom_xss_helpers.py +++ b/tests/test_markdown_dom_xss_helpers.py @@ -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] diff --git a/tests/test_markdown_lazy_lib_loading_js.py b/tests/test_markdown_lazy_lib_loading_js.py index ee334a828..d9fa31109 100644 --- a/tests/test_markdown_lazy_lib_loading_js.py +++ b/tests/test_markdown_lazy_lib_loading_js.py @@ -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) diff --git a/tests/test_notes_document_mutual_minimize.py b/tests/test_notes_document_mutual_minimize.py index b09ac943b..88315e5c8 100644 --- a/tests/test_notes_document_mutual_minimize.py +++ b/tests/test_notes_document_mutual_minimize.py @@ -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 diff --git a/tests/test_pdf_export_preserves_import_static.py b/tests/test_pdf_export_preserves_import_static.py index 03688594f..0488ffd2b 100644 --- a/tests/test_pdf_export_preserves_import_static.py +++ b/tests/test_pdf_export_preserves_import_static.py @@ -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 diff --git a/tests/test_preview_hides_import_action_static.py b/tests/test_preview_hides_import_action_static.py index 41635c867..8f70b222c 100644 --- a/tests/test_preview_hides_import_action_static.py +++ b/tests/test_preview_hides_import_action_static.py @@ -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 diff --git a/tests/test_review_docx_async_identity.py b/tests/test_review_docx_async_identity.py index 06cecd278..d4547dc70 100644 --- a/tests/test_review_docx_async_identity.py +++ b/tests/test_review_docx_async_identity.py @@ -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'; diff --git a/tests/test_richtext_format_selection_static.py b/tests/test_richtext_format_selection_static.py index c14be28a8..5259325cb 100644 --- a/tests/test_richtext_format_selection_static.py +++ b/tests/test_richtext_format_selection_static.py @@ -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 diff --git a/tests/test_richtext_preview_returns_to_editor_static.py b/tests/test_richtext_preview_returns_to_editor_static.py index 90ea870da..c9dfa007a 100644 --- a/tests/test_richtext_preview_returns_to_editor_static.py +++ b/tests/test_richtext_preview_returns_to_editor_static.py @@ -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 diff --git a/tests/test_selection_overlay_clear_static.py b/tests/test_selection_overlay_clear_static.py index 4761f4ff3..1e32c975c 100644 --- a/tests/test_selection_overlay_clear_static.py +++ b/tests/test_selection_overlay_clear_static.py @@ -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