From 7a732989fd44b20ade76c40f4de7fe2d5bb9064f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Tue, 29 Sep 2026 16:36:01 +0200 Subject: [PATCH 01/15] fix(css): collapse duplicate @keyframes names to the definition that wins Duplicate @keyframes names resolve last-wins across the whole cascade, so every definition but the last was dead code that still read as live at its call site. Two of the five duplicated names differed from the winner: fadeIn style.css had an opacity-only variant before the one that adds translateY, so every consumer was already sliding research-pulse a background-colour pulse sat before the opacity/scale one that actually runs The other three (spin, loading-bounce, pulse) were byte-identical repeats. Removing the losing definitions changes nothing rendered, which the computed style snapshot confirms: the committed baseline is reproduced exactly across all three pages and 24 variants. Deciding that a consumer wanted the plain fade, or the background pulse, would be a visual change and belongs in its own PR with screenshots. This also unblocks the mechanical stylesheet split. While two definitions of a name differed, neither could be moved: relocating either changes which one is last, and therefore changes behaviour. A regression test now fails on any duplicate name so the trap cannot come back. --- .../css/cookbook-research-memory-settings.css | 4 -- static/style.css | 18 ------- tests/test_stylesheet_keyframe_uniqueness.py | 52 +++++++++++++++++++ 3 files changed, 52 insertions(+), 22 deletions(-) create mode 100644 tests/test_stylesheet_keyframe_uniqueness.py diff --git a/static/css/cookbook-research-memory-settings.css b/static/css/cookbook-research-memory-settings.css index 3bcbb08cd..67f425dae 100644 --- a/static/css/cookbook-research-memory-settings.css +++ b/static/css/cookbook-research-memory-settings.css @@ -28,10 +28,6 @@ 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: 0.7; } } -@keyframes research-pulse { - 0%, 100% { background: color-mix(in srgb, var(--red) 12%, transparent); } - 50% { background: color-mix(in srgb, var(--red) 22%, transparent); } - } #cookbook-gguf-delete-overlay { background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); diff --git a/static/style.css b/static/style.css index 34711963c..94788c585 100644 --- a/static/style.css +++ b/static/style.css @@ -1480,7 +1480,6 @@ body.bg-pattern-ascii-fireflies { 0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } 100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.94); } } - @keyframes spin { to { transform: rotate(360deg); } } .row { display:flex; gap:6px; align-items:center; } .list-item:hover, .models-row:hover { @@ -6119,9 +6118,6 @@ body.bg-pattern-ascii-fireflies { #mobile-backdrop, #mobile-menu-btn { display:none !important; } #sidebar-backdrop { display:none !important; } /* ----- Loading spinner ----- */ - @keyframes spin { - to { transform: rotate(360deg); } - } .spinner { width: 24px; height: 24px; @@ -6240,14 +6236,6 @@ body.bg-pattern-ascii-fireflies { .session-skeleton-bubble.is-user { width: 72%; } .session-skeleton-line { height: 9px; margin-top: 7px; } } - @keyframes loading-bounce { - 0%, 80%, 100% { - transform: scale(0); - } - 40% { - transform: scale(1); - } - } /* Modal styling */ .modal { position:fixed; @@ -7555,11 +7543,6 @@ body.bg-pattern-ascii-fireflies { animation: pulse 1.5s infinite; } - @keyframes pulse { - 0% { opacity: 1; } - 50% { opacity: 0.7; } - 100% { opacity: 1; } - } #recording-indicator { position: fixed; @@ -8720,7 +8703,6 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; } } .compare-probe-start-anyway svg { color: currentColor; } .compare-probe-start-anyway span { font-weight: 700; } - @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes pane-shake { 0%, 100% { transform: translateX(0); } 15% { transform: translateX(-3px) rotate(-0.5deg); } diff --git a/tests/test_stylesheet_keyframe_uniqueness.py b/tests/test_stylesheet_keyframe_uniqueness.py new file mode 100644 index 000000000..9fe26337d --- /dev/null +++ b/tests/test_stylesheet_keyframe_uniqueness.py @@ -0,0 +1,52 @@ +"""No two @keyframes may share a name across the app's stylesheets. + +Duplicate names resolve last-wins across the whole cascade, so an earlier +definition is dead code that still looks live at its call site. Two of them +existed and differed from the definition that actually won, which also blocked +moving either animation during the stylesheet decomposition: relocating one +changes which is last and therefore changes rendered behaviour. +""" + +import collections +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / "static" + + +def _stylesheets() -> list[Path]: + """App-owned stylesheets, in no particular order. Vendored CSS is excluded.""" + sheets = [STATIC / "style.css"] + sheets.extend(sorted((STATIC / "css").glob("*.css"))) + return [s for s in sheets if s.exists()] + + +def _keyframe_names(text: str) -> list[str]: + without_comments = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.findall( + r"@(?:-webkit-)?keyframes\s+([A-Za-z0-9_-]+)\s*\{", without_comments + ) + + +def test_app_stylesheets_exist() -> None: + """Guard the guard: a rename must not turn this file into a no-op.""" + assert _stylesheets(), "no app stylesheets found to check" + + +def test_no_keyframes_name_is_declared_twice() -> None: + counts: collections.Counter[str] = collections.Counter() + where: dict[str, list[str]] = collections.defaultdict(list) + + for sheet in _stylesheets(): + for name in _keyframe_names(sheet.read_text(encoding="utf-8")): + counts[name] += 1 + where[name].append(sheet.relative_to(ROOT).as_posix()) + + duplicates = {name: where[name] for name, n in counts.items() if n > 1} + + assert duplicates == {}, ( + "duplicate @keyframes names resolve last-wins, so every definition but " + f"the last is dead: {duplicates}" + ) From 1323fab0bd2a301e93195e512c4301819eeb7e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Tue, 29 Sep 2026 17:05:29 +0200 Subject: [PATCH 02/15] refactor(tests): read the whole cascade instead of style.css alone static/style.css no longer holds every rule. 79 rules for the document, gallery and editor panels now live in static/css/, yet 27 browser tests still built their synthetic page with a single to style.css, and 51 more read that one file as though it were the whole cascade. Those tests kept passing while covering less: a rule that moved became invisible to the assertion that was meant to pin it. Python readers now call tests.helpers.stylesheets.app_css(), and synthetic pages are built from stylesheet_link_tags() so they load exactly what index.html loads, in the same order. The helper already existed; this moves the remaining callers onto it. test_portal_dropdown_z_js parametrised over a file list including style.css to assert an absence. Checking a negative against one file of a split stylesheet is how a moved rule escapes, so the CSS case now checks the concatenation. Two guards keep it from coming back: one fails on any test reading static/style.css directly, the other on any synthetic page linking it alone. Both name the helper to use. No production code changes. Suite is unchanged at 6 pre-existing failures. --- tests/test_agent_thread_chevron_static.py | 3 +- tests/test_agent_thread_dot_alignment_css.py | 5 +- ...st_background_chat_completion_ui_static.py | 3 +- tests/test_calendar_event_contrast.py | 5 +- tests/test_card_menu_style_contract.py | 3 +- tests/test_chat_mode_pill_static.py | 3 +- tests/test_chatgpt_reasoning_and_grouping.py | 3 +- tests/test_chatgpt_subscription_usage_js.py | 5 +- tests/test_compare_ask_user_routing.py | 9 ++-- tests/test_compare_mobile_tabs_static.py | 7 +-- tests/test_compare_shuffle_visual_static.py | 9 ++-- tests/test_dialog_aria.py | 3 +- tests/test_doc_library_open_orphaned.py | 4 +- tests/test_document_edit_reference_js.py | 3 +- tests/test_document_editor_scroll.py | 3 +- tests/test_document_history_controls.py | 4 +- tests/test_document_library_mobile_footer.py | 7 ++- tests/test_document_outline.py | 10 ++-- tests/test_document_preview_edit_button.py | 3 +- tests/test_document_rich_checklist_enter.py | 4 +- ..._document_rich_color_reset_and_contrast.py | 7 ++- tests/test_document_rich_docx_export.py | 7 ++- tests/test_document_rich_find_boundaries.py | 4 +- .../test_document_rich_font_color_controls.py | 4 +- tests/test_document_rich_heading_enter.py | 5 +- tests/test_document_rich_image_caption.py | 4 +- tests/test_document_rich_input_rules.py | 4 +- .../test_document_rich_keyboard_shortcuts.py | 4 +- tests/test_document_rich_selection_toolbar.py | 7 ++- tests/test_document_rich_slash_menu.py | 7 ++- tests/test_document_rich_smart_link_paste.py | 4 +- tests/test_document_rich_structure_tools.py | 7 ++- ...test_document_rich_table_cell_alignment.py | 4 +- ...document_rich_table_header_preservation.py | 4 +- tests/test_document_rich_table_headers.py | 4 +- tests/test_document_rich_table_merge_split.py | 4 +- tests/test_document_rich_table_tab_history.py | 4 +- tests/test_document_rich_toolbar_menus.py | 13 +++-- tests/test_document_save_shortcut.py | 4 +- tests/test_document_save_status.py | 4 +- tests/test_document_suggestion_highlight.py | 3 +- tests/test_document_toolbar_arrow_offset.py | 3 +- tests/test_document_toolbar_order.py | 4 +- tests/test_editor_layer_thumbnail_render.py | 3 +- tests/test_editor_quick_edit_static.py | 3 +- tests/test_editor_topbar_static.py | 3 +- tests/test_email_folder_chip_static.py | 3 +- tests/test_email_split_border_css.py | 3 +- tests/test_endpoint_card_ui_polish.py | 3 +- tests/test_escape_inner_layers.py | 5 +- tests/test_image_research_settings_static.py | 3 +- tests/test_interrupted_resume_label_static.py | 3 +- tests/test_mobile_search_position_static.py | 3 +- tests/test_mobile_welcome_keyboard_static.py | 5 +- tests/test_modal_dock_composer_clearance.py | 3 +- tests/test_notes_sidebar_new_note.py | 11 ++-- tests/test_portal_dropdown_z_js.py | 7 ++- tests/test_research_panel_ui.py | 3 +- tests/test_resend_message_nondestructive.py | 3 +- tests/test_select_dropdown_theme_css.py | 7 ++- tests/test_selection_overlay_clear_static.py | 3 +- tests/test_settings_shell_js_behavior.py | 7 ++- tests/test_shared_tag_strips_static.py | 3 +- tests/test_sidebar_chat_list_sizing_static.py | 3 +- tests/test_skill_deeplink_static.py | 11 ++-- ...test_stream_completion_scroll_stability.py | 3 +- tests/test_stylesheet_test_hygiene.py | 54 +++++++++++++++++++ tests/test_theme_sidebar_mobile.py | 7 ++- tests/test_toast_ui_polish.py | 5 +- tests/test_tool_approval_frontend_routing.py | 5 +- tests/test_tool_header_icons_static.py | 3 +- tests/test_visual_research_mode.py | 3 +- 72 files changed, 273 insertions(+), 116 deletions(-) create mode 100644 tests/test_stylesheet_test_hygiene.py diff --git a/tests/test_agent_thread_chevron_static.py b/tests/test_agent_thread_chevron_static.py index 62c0103f8..413506e23 100644 --- a/tests/test_agent_thread_chevron_static.py +++ b/tests/test_agent_thread_chevron_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_agent_thread_chevron_uses_css_shape_in_live_and_history_renderers(): live = (ROOT / "static/js/chat.js").read_text() history = (ROOT / "static/js/chatRenderer.js").read_text() - css = (ROOT / "static/style.css").read_text() + css = app_css() for src in (live, history): assert 'class="agent-thread-chevron" aria-hidden="true">' in src diff --git a/tests/test_agent_thread_dot_alignment_css.py b/tests/test_agent_thread_dot_alignment_css.py index 5c3558f7b..f0f394225 100644 --- a/tests/test_agent_thread_dot_alignment_css.py +++ b/tests/test_agent_thread_dot_alignment_css.py @@ -14,11 +14,10 @@ file identifies the breakpoint it belongs to. import re from pathlib import Path +from tests.helpers.stylesheets import app_css -CSS = (Path(__file__).resolve().parents[1] / "static" / "style.css").read_text( - encoding="utf-8" -) +CSS = app_css() THREAD = r"^[ \t]*\.agent-thread[ \t]*\{" RAIL = r"^[ \t]*\.agent-thread::before[ \t]*\{" diff --git a/tests/test_background_chat_completion_ui_static.py b/tests/test_background_chat_completion_ui_static.py index 3a7f50b51..a64f7fb47 100644 --- a/tests/test_background_chat_completion_ui_static.py +++ b/tests/test_background_chat_completion_ui_static.py @@ -1,10 +1,11 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] CHAT = (ROOT / "static/js/chat.js").read_text() SESSIONS = (ROOT / "static/js/sessions.js").read_text() -CSS = (ROOT / "static/style.css").read_text() +CSS = app_css() def test_queued_prompts_are_persisted_per_session_and_restored_on_return(): diff --git a/tests/test_calendar_event_contrast.py b/tests/test_calendar_event_contrast.py index 1558551a5..ac692e451 100644 --- a/tests/test_calendar_event_contrast.py +++ b/tests/test_calendar_event_contrast.py @@ -4,11 +4,12 @@ import subprocess from pathlib import Path import pytest +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] CALENDAR_JS = ROOT / "static" / "js" / "calendar.js" -STYLE_CSS = ROOT / "static" / "style.css" +STYLE_CSS_TEXT = app_css() UTILS_JS = ROOT / "static" / "js" / "calendar" / "utils.js" pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH") @@ -65,7 +66,7 @@ def test_calendar_readable_text_color_keeps_light_text_for_dark_colors(): def test_calendar_event_surfaces_use_computed_foreground_variable(): calendar_js = CALENDAR_JS.read_text(encoding="utf-8") - style_css = STYLE_CSS.read_text(encoding="utf-8") + style_css = STYLE_CSS_TEXT utils_js = UTILS_JS.read_text(encoding="utf-8") assert "_calReadableTextColor" in utils_js diff --git a/tests/test_card_menu_style_contract.py b/tests/test_card_menu_style_contract.py index f32fb3950..f073a56d2 100644 --- a/tests/test_card_menu_style_contract.py +++ b/tests/test_card_menu_style_contract.py @@ -1,8 +1,9 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() LIBRARY = (ROOT / "static/js/documentLibrary.js").read_text(encoding="utf-8") diff --git a/tests/test_chat_mode_pill_static.py b/tests/test_chat_mode_pill_static.py index 6ef49150b..8cf375bcf 100644 --- a/tests/test_chat_mode_pill_static.py +++ b/tests/test_chat_mode_pill_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_user_mode_pill_is_rendered_and_live_updated(): renderer = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8") chat = (ROOT / "static/js/chat.js").read_text(encoding="utf-8") - styles = (ROOT / "static/style.css").read_text(encoding="utf-8") + styles = app_css() routes = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8") helpers = (ROOT / "routes/chat_helpers.py").read_text(encoding="utf-8") diff --git a/tests/test_chatgpt_reasoning_and_grouping.py b/tests/test_chatgpt_reasoning_and_grouping.py index 9c36ad474..ebdbbc0fa 100644 --- a/tests/test_chatgpt_reasoning_and_grouping.py +++ b/tests/test_chatgpt_reasoning_and_grouping.py @@ -12,6 +12,7 @@ import subprocess import pytest from src import chatgpt_subscription, llm_core +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).parents[1] @@ -101,7 +102,7 @@ def test_model_picker_source_invariants(): def test_composer_reasoning_effort_ui_markup(): """Verify static/index.html and static/style.css include reasoning effort controls.""" html = (ROOT / "static/index.html").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() # HTML elements assert 'id="reasoning-effort-wrap"' in html assert 'id="reasoning-effort-btn"' in html diff --git a/tests/test_chatgpt_subscription_usage_js.py b/tests/test_chatgpt_subscription_usage_js.py index 401cef6de..6014a85e9 100644 --- a/tests/test_chatgpt_subscription_usage_js.py +++ b/tests/test_chatgpt_subscription_usage_js.py @@ -6,11 +6,12 @@ import subprocess from pathlib import Path import pytest +from tests.helpers.stylesheets import app_css _REPO = Path(__file__).resolve().parent.parent _MODULE = _REPO / "static" / "js" / "chatgptSubscriptionUsage.js" _ADMIN = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8") -_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8") +_STYLE = app_css() pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH") @@ -269,7 +270,7 @@ def test_refresh_and_reconnect_handlers_target_only_the_clicked_account(): def test_admin_renders_chatgpt_usage_collapsible_and_styled(): admin_source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8") - style_source = (_REPO / "static" / "style.css").read_text(encoding="utf-8") + style_source = app_css() load_block = admin_source[admin_source.index("async function loadEndpoints()"):admin_source.index("function initEndpointForm()")] assert "adm-chatgpt-controls" in load_block assert "adm-chatgpt-usage-toggle" in load_block diff --git a/tests/test_compare_ask_user_routing.py b/tests/test_compare_ask_user_routing.py index 6fb61cecc..e39ab5b78 100644 --- a/tests/test_compare_ask_user_routing.py +++ b/tests/test_compare_ask_user_routing.py @@ -1,5 +1,6 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css def test_compare_renders_ask_user_in_the_originating_pane(): @@ -80,7 +81,7 @@ def test_compare_pane_templates_hide_response_actions_until_response_exists(): root = Path(__file__).resolve().parents[1] index = (root / "static/js/compare/index.js").read_text(encoding="utf-8") panes = (root / "static/js/compare/panes.js").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert re.search(r"from './panes\.js\?v=[A-Za-z0-9_-]+'", index) assert re.search(r"from './selector\.js\?v=[A-Za-z0-9_-]+'", index) @@ -152,7 +153,7 @@ def test_compare_panes_have_visible_runtime_state_without_revealing_empty_action root = Path(__file__).resolve().parents[1] stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8") panes = (root / "static/js/compare/panes.js").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert "_paneEl.classList.remove('is-done', 'is-failed', 'is-awaiting-input');" in stream assert "_paneEl.classList.add('is-streaming');" in stream @@ -179,7 +180,7 @@ def test_compare_panes_surface_compact_result_summary(): index = (root / "static/js/compare/index.js").read_text(encoding="utf-8") panes = (root / "static/js/compare/panes.js").read_text(encoding="utf-8") stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert 'pane-header-row pane-header-secondary' in index assert 'class=\"pane-summary\" id=\"cmp-summary-' in index @@ -203,7 +204,7 @@ def test_compare_panes_surface_compact_result_summary(): def test_compare_selector_surfaces_duplicate_warning_without_blocking_start(): root = Path(__file__).resolve().parents[1] selector = (root / "static/js/compare/selector.js").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert "function _selectionKey(sel)" in selector assert "function _duplicateSelectionKeys()" in selector diff --git a/tests/test_compare_mobile_tabs_static.py b/tests/test_compare_mobile_tabs_static.py index 14270050b..55ad7ab5a 100644 --- a/tests/test_compare_mobile_tabs_static.py +++ b/tests/test_compare_mobile_tabs_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parent.parent @@ -32,7 +33,7 @@ def test_mobile_compare_reconciles_tabs_after_pane_lifecycle_changes(): def test_mobile_compare_css_shows_only_the_active_card(): - css = _read("static/style.css") + css = app_css() mobile = css[css.index("/* Compare uses one full-width response card on phones."):] assert ".compare-mobile-tabs" in mobile @@ -44,7 +45,7 @@ def test_mobile_compare_css_shows_only_the_active_card(): def test_probe_feedback_and_actions_use_the_shared_card_layout(): selector = _read("static/js/compare/selector.js") - css = _read("static/style.css") + css = app_css() assert "probeFeedback.className = 'compare-probe-feedback'" in selector assert "probeFeedback.appendChild(detail)" in selector @@ -66,7 +67,7 @@ def test_probe_feedback_and_actions_use_the_shared_card_layout(): def test_probe_swap_reopens_and_highlights_the_failed_model_slot(): selector = _read("static/js/compare/selector.js") - css = _read("static/style.css") + css = app_css() assert "function _expandModelSlot(slotIdx)" in selector assert "row.dataset.slotIndex = String(idx);" in selector diff --git a/tests/test_compare_shuffle_visual_static.py b/tests/test_compare_shuffle_visual_static.py index 1e2f00505..70768c70a 100644 --- a/tests/test_compare_shuffle_visual_static.py +++ b/tests/test_compare_shuffle_visual_static.py @@ -1,5 +1,6 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parent.parent @@ -11,7 +12,7 @@ def _read(path: str) -> str: def test_compare_shuffle_shows_center_notice_with_dice_icon(): panes = _read("static/js/compare/panes.js") - css = _read("static/style.css") + css = app_css() assert "ICON_DICE" in panes assert "compare-shuffle-notice" in panes @@ -23,7 +24,7 @@ def test_compare_shuffle_shows_center_notice_with_dice_icon(): def test_compare_chat_and_agent_panes_expose_per_pane_inference_settings(): index = _read("static/js/compare/index.js") panes = _read("static/js/compare/panes.js") - css = _read("static/style.css") + css = app_css() assert "pane-settings-btn" in panes assert "paneSettingsButtonHtml" in index @@ -41,7 +42,7 @@ def test_compare_chat_and_agent_panes_expose_per_pane_inference_settings(): def test_compare_probe_control_has_requested_vertical_alignment(): index = _read("static/js/compare/index.js") probe = _read("static/js/compare/probe.js") - css = _read("static/style.css") + css = app_css() assert 'class="compare-check-icon"' in index assert 'Probe' in index @@ -75,7 +76,7 @@ def test_compare_cache_key_bumped_for_shuffle_notice(): def test_compare_score_button_label_is_nudged_up(): vote = _read("static/js/compare/vote.js") - css = _read("static/style.css") + css = app_css() assert 'Score' in vote assert ".compare-score-label" in css diff --git a/tests/test_dialog_aria.py b/tests/test_dialog_aria.py index 3513f9b0d..5ce8ce427 100644 --- a/tests/test_dialog_aria.py +++ b/tests/test_dialog_aria.py @@ -10,6 +10,7 @@ of the other tests in this suite. """ import re from pathlib import Path +from tests.helpers.stylesheets import app_css _REPO = Path(__file__).resolve().parent.parent _INDEX = (_REPO / "static" / "index.html").read_text(encoding="utf-8") @@ -47,7 +48,7 @@ def test_styled_confirm_and_prompt_are_modal_dialogs(): def test_styled_confirm_cancel_or_close_label_is_shifted_without_moving_button(): - css = (_REPO / "static" / "style.css").read_text(encoding="utf-8") + css = app_css() assert "cancelLabel.textContent = cancelText;" in _UI assert "cancelBtn.appendChild(cancelLabel);" in _UI diff --git a/tests/test_doc_library_open_orphaned.py b/tests/test_doc_library_open_orphaned.py index 58e52d673..76e47b7cc 100644 --- a/tests/test_doc_library_open_orphaned.py +++ b/tests/test_doc_library_open_orphaned.py @@ -18,6 +18,7 @@ import re import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags SRC = Path(__file__).resolve().parent.parent / "static/js/documentLibrary.js" @@ -96,7 +97,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); const state = await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&mobile-library-open-test=1'); mod.init('/api'); @@ -134,6 +135,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock(): console.log(JSON.stringify(state)); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=SRC.parents[1], diff --git a/tests/test_document_edit_reference_js.py b/tests/test_document_edit_reference_js.py index 243d23406..65b607598 100644 --- a/tests/test_document_edit_reference_js.py +++ b/tests/test_document_edit_reference_js.py @@ -1,12 +1,13 @@ """Regression guards for document-selection references in chat bubbles.""" from pathlib import Path +from tests.helpers.stylesheets import app_css 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") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +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 89cbc7b81..1023cab2f 100644 --- a/tests/test_document_editor_scroll.py +++ b/tests/test_document_editor_scroll.py @@ -9,11 +9,12 @@ document.js is browser-coupled and not importable in pytest. """ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE_CSS = app_css() def test_document_textarea_scrollbar_is_visible(): diff --git a/tests/test_document_history_controls.py b/tests/test_document_history_controls.py index bd4b297ac..ddffe6e69 100644 --- a/tests/test_document_history_controls.py +++ b/tests/test_document_history_controls.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -24,7 +25,7 @@ def test_mobile_rich_text_history_state_and_document_switch(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&history-controls-test=1'); window.documentModuleForTest = mod; @@ -85,6 +86,7 @@ def test_mobile_rich_text_history_state_and_document_switch(): console.log(JSON.stringify({ initial, typed, undone, redone, switched, overflow })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_library_mobile_footer.py b/tests/test_document_library_mobile_footer.py index f5a9921ae..ad94861c9 100644 --- a/tests/test_document_library_mobile_footer.py +++ b/tests/test_document_library_mobile_footer.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] SOURCE = (ROOT / "static/js/documentLibrary.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_mobile_footer_exposes_delete_open_and_more(): @@ -53,7 +55,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); const state = await page.evaluate(async () => { let currentSession = 'current-chat'; let createDirectCalls = 0; @@ -106,6 +108,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session(): console.log(JSON.stringify(state)); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_outline.py b/tests/test_document_outline.py index 4ab98d680..63f4ebc90 100644 --- a/tests/test_document_outline.py +++ b/tests/test_document_outline.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def _run_node(script: str): @@ -41,7 +43,7 @@ def test_markdown_outline_parses_structure_and_ignores_fenced_code(): '#### Final `code` section', ].join('\n'); console.log(JSON.stringify(parseMarkdownOutline(source))); - """ + """.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) ) assert [(entry["level"], entry["text"]) for entry in data] == [ (1, "Overview"), @@ -71,7 +73,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&outline-test=1'); mod.init('/api'); @@ -126,7 +128,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile(): console.log(JSON.stringify({ markdownLabels, selected, liveLabels, mobileBox, richLabels, richCaretHeading })); await browser.close(); - """ + """.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) ) assert data["markdownLabels"] == ["Intro", "Details", "End"] assert data["selected"] == "## Details" diff --git a/tests/test_document_preview_edit_button.py b/tests/test_document_preview_edit_button.py index 52340f96d..ffe44f386 100644 --- a/tests/test_document_preview_edit_button.py +++ b/tests/test_document_preview_edit_button.py @@ -1,11 +1,12 @@ """Regression guards for the Markdown preview hover-to-edit control.""" from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE_CSS = app_css() def test_preview_installs_hover_edit_button(): diff --git a/tests/test_document_rich_checklist_enter.py b/tests/test_document_rich_checklist_enter.py index b36bdb67b..92154adb0 100644 --- a/tests/test_document_rich_checklist_enter.py +++ b/tests/test_document_rich_checklist_enter.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -26,7 +27,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&checklist-enter-test=1'); mod.init('/api'); @@ -78,6 +79,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly(): console.log(JSON.stringify({ afterFirstEnter, ...data })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_color_reset_and_contrast.py b/tests/test_document_rich_color_reset_and_contrast.py index 64c237a7b..287bef986 100644 --- a/tests/test_document_rich_color_reset_and_contrast.py +++ b/tests/test_document_rich_color_reset_and_contrast.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_color_controls_have_theme_reset_and_split_palettes(): @@ -27,7 +29,7 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit(): await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent(` + __ODY_STYLESHEETS__
`); await page.evaluate(async () => { const mod = await import('/static/js/document.js?rich-color-test=' + Date.now()); @@ -80,6 +82,7 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit(): console.log(JSON.stringify({ palette, highlighted, highlightUndone, defaultColor, changedThemeColor, colorUndone })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_docx_export.py b/tests/test_document_rich_docx_export.py index f5b6d5c6b..61cc71ad2 100644 --- a/tests/test_document_rich_docx_export.py +++ b/tests/test_document_rich_docx_export.py @@ -5,6 +5,7 @@ import subprocess import tempfile import zipfile from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -64,7 +65,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml(): contentType: 'image/png', body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), }})); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => {{ const mod = await import('/static/js/document.js?v=20260831richtexttools91&docx-export-test=1'); mod.init('/api'); @@ -89,6 +90,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml(): }})); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, @@ -158,7 +160,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting(): acceptDownloads: true, }}); await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => {{ const mod = await import('/static/js/document.js?v=20260831richtexttools91&markdown-docx-export-test=1'); mod.init('/api'); @@ -180,6 +182,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting(): console.log(JSON.stringify({{ failure: await download.failure() }})); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_find_boundaries.py b/tests/test_document_rich_find_boundaries.py index c4e293ad8..ca2280455 100644 --- a/tests/test_document_rich_find_boundaries.py +++ b/tests/test_document_rich_find_boundaries.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -25,7 +26,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&find-boundaries-test=1'); mod.init('/api'); @@ -73,6 +74,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac console.log(JSON.stringify({ crossParagraph, crossBreak, crossInline, ...data })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_font_color_controls.py b/tests/test_document_rich_font_color_controls.py index cfecf8699..fa8691c2f 100644 --- a/tests/test_document_rich_font_color_controls.py +++ b/tests/test_document_rich_font_color_controls.py @@ -6,6 +6,7 @@ import subprocess from pathlib import Path from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -94,7 +95,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile(): async function exercise(viewport, suffix) { const page = await browser.newPage({ viewport }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async suffix => { const mod = await import(`/static/js/document.js?v=20260831richtexttools91&font-color=${suffix}`); mod.init('/api'); @@ -190,6 +191,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile(): console.log(JSON.stringify({ desktop, mobile })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_heading_enter.py b/tests/test_document_rich_heading_enter.py index d2df9ca89..9a876ca9d 100644 --- a/tests/test_document_rich_heading_enter.py +++ b/tests/test_document_rich_heading_enter.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -29,7 +30,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&heading-enter=1'); mod.init('/api'); @@ -73,6 +74,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable(): console.log(JSON.stringify({ entered, undone, redone })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, @@ -152,6 +154,7 @@ def test_heading_enter_preserves_shift_middle_and_empty_heading_semantics(): console.log(JSON.stringify(state)); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_image_caption.py b/tests/test_document_rich_image_caption.py index 7c573dd03..b7e04101e 100644 --- a/tests/test_document_rich_image_caption.py +++ b/tests/test_document_rich_image_caption.py @@ -5,6 +5,7 @@ import subprocess from pathlib import Path from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -36,7 +37,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&image-caption=1'); mod.init('/api'); @@ -116,6 +117,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal(): console.log(JSON.stringify({ added, undone, redone, resized, removed, removalUndone, menuRect, overflow })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_input_rules.py b/tests/test_document_rich_input_rules.py index 0e93edc35..cf714fe03 100644 --- a/tests/test_document_rich_input_rules.py +++ b/tests/test_document_rich_input_rules.py @@ -5,6 +5,7 @@ import subprocess from pathlib import Path from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -28,7 +29,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&input-rules-test=1'); mod.init('/api'); @@ -101,6 +102,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text(): console.log(JSON.stringify(data)); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_keyboard_shortcuts.py b/tests/test_document_rich_keyboard_shortcuts.py index 89a09ec27..570daa87e 100644 --- a/tests/test_document_rich_keyboard_shortcuts.py +++ b/tests/test_document_rich_keyboard_shortcuts.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -16,7 +17,7 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths(): async function exercise(viewport, suffix) { const page = await browser.newPage({ viewport }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async suffix => { const mod = await import(`/static/js/document.js?v=20260831richtexttools91&keyboard-shortcuts=${suffix}`); mod.init('/api'); @@ -94,6 +95,7 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths(): console.log(JSON.stringify({ desktop, mobile })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_selection_toolbar.py b/tests/test_document_rich_selection_toolbar.py index 306d45cce..b13977268 100644 --- a/tests/test_document_rich_selection_toolbar.py +++ b/tests/test_document_rich_selection_toolbar.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_selection_toolbar_uses_shared_formatting_path_and_preserves_range(): @@ -26,7 +28,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&selection-toolbar-test=1'); mod.init('/api'); @@ -102,6 +104,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports console.log(JSON.stringify({ activeBold, desktopGeometry, formattedHtml, selectedAfterFormat, dismissed, mobileGeometry })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_slash_menu.py b/tests/test_document_rich_slash_menu.py index 7c191985d..1ade47972 100644 --- a/tests/test_document_rich_slash_menu.py +++ b/tests/test_document_rich_slash_menu.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_slash_menu_reuses_rich_text_actions_and_is_accessible(): @@ -41,7 +43,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&slash-menu-test=1'); mod.init('/api'); @@ -149,6 +151,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile(): console.log(JSON.stringify({ filtered, heading, noHeadingQuery, tableFiltered, table, noTableQuery, mobile, homeOptions, homeLabel, escaped, slashRemains, cleanedAria })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_smart_link_paste.py b/tests/test_document_rich_smart_link_paste.py index 8f8c675e7..51b844209 100644 --- a/tests/test_document_rich_smart_link_paste.py +++ b/tests/test_document_rich_smart_link_paste.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -14,7 +15,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&smart-link-paste=1'); mod.init('/api'); @@ -133,6 +134,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks console.log(JSON.stringify({ selectedLink, storedAfterPaste, afterUndo, afterRedoLinks, caretLink, plainResults, emailLink, crossBlock })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_structure_tools.py b/tests/test_document_rich_structure_tools.py index e6fd58922..049e7972b 100644 --- a/tests/test_document_rich_structure_tools.py +++ b/tests/test_document_rich_structure_tools.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_heading_levels_and_page_break_are_exposed_everywhere(): @@ -44,7 +46,7 @@ def test_mobile_headings_page_break_history_and_persistence(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&structure-tools-test=1'); window.__structureToolsDocModule = mod; @@ -152,6 +154,7 @@ def test_mobile_headings_page_break_history_and_persistence(): })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_table_cell_alignment.py b/tests/test_document_rich_table_cell_alignment.py index a410723e3..8f07d4231 100644 --- a/tests/test_document_rich_table_cell_alignment.py +++ b/tests/test_document_rich_table_cell_alignment.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -31,7 +32,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-cell-alignment=1'); mod.init('/api'); @@ -122,6 +123,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history(): })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_table_header_preservation.py b/tests/test_document_rich_table_header_preservation.py index 4c7c03626..f08825dd0 100644 --- a/tests/test_document_rich_table_header_preservation.py +++ b/tests/test_document_rich_table_header_preservation.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -36,7 +37,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-preservation-test=1'); mod.init('/api'); @@ -119,6 +120,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history(): })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_table_headers.py b/tests/test_document_rich_table_headers.py index cd77ba93c..fb52d4630 100644 --- a/tests/test_document_rich_table_headers.py +++ b/tests/test_document_rich_table_headers.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -40,7 +41,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-test=1'); mod.init('/api'); @@ -117,6 +118,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo(): console.log(JSON.stringify({ initial, menuRect, columnOn, undone, redone, bothOn, rowOff, overflow })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_table_merge_split.py b/tests/test_document_rich_table_merge_split.py index 85bb04387..6aeb76897 100644 --- a/tests/test_document_rich_table_merge_split.py +++ b/tests/test_document_rich_table_merge_split.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -34,7 +35,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history( const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-merge-split=1'); mod.init('/api'); @@ -138,6 +139,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history( })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_table_tab_history.py b/tests/test_document_rich_table_tab_history.py index abca1d02a..ec7de0013 100644 --- a/tests/test_document_rich_table_tab_history.py +++ b/tests/test_document_rich_table_tab_history.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -31,7 +32,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-tab-history-test=1'); mod.init('/api'); @@ -98,6 +99,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history(): console.log(JSON.stringify({ forward, backward, appended, undone, redone, overflow })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_toolbar_menus.py b/tests/test_document_rich_toolbar_menus.py index 178820293..6aa33b379 100644 --- a/tests/test_document_rich_toolbar_menus.py +++ b/tests/test_document_rich_toolbar_menus.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_rich_toolbar_menus_expose_keyboard_and_context_state(): @@ -58,7 +60,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&toggle-test=1'); mod.init('/api'); @@ -101,6 +103,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens(): console.log(JSON.stringify({ opened, closed, sliderOpened, sliderClosed, pickerOpened, colorClosed, before, scroll })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, @@ -127,7 +130,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-menu-test=1'); mod.init('/api'); @@ -204,6 +207,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus(): })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, @@ -242,7 +246,7 @@ def test_rich_toolbar_menus_track_live_formatting_values(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-state-test=1'); mod.init('/api'); @@ -322,6 +326,7 @@ def test_rich_toolbar_menus_track_live_formatting_values(): })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_save_shortcut.py b/tests/test_document_save_shortcut.py index 0b1fe4f9b..c1265071e 100644 --- a/tests/test_document_save_shortcut.py +++ b/tests/test_document_save_shortcut.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -38,7 +39,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status(): }); }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-shortcut-test=1'); mod.init('/api'); @@ -71,6 +72,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status(): console.log(JSON.stringify({ dirty, saved, badge, requests })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_save_status.py b/tests/test_document_save_status.py index f29698ff0..487667799 100644 --- a/tests/test_document_save_status.py +++ b/tests/test_document_save_status.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -32,7 +33,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures(): active -= 1; }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-status-test=1'); mod.init('/api'); @@ -81,6 +82,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures(): console.log(JSON.stringify({ initial, dirty, duringSave, afterSave, afterFailure, bodies, maxActive, footer, mobile })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_suggestion_highlight.py b/tests/test_document_suggestion_highlight.py index 3dd819bda..f2272d66f 100644 --- a/tests/test_document_suggestion_highlight.py +++ b/tests/test_document_suggestion_highlight.py @@ -1,11 +1,12 @@ """Regression guards for exact inline document-suggestion highlighting.""" from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE_CSS = app_css() def test_suggestion_highlight_measures_exact_referenced_range(): diff --git a/tests/test_document_toolbar_arrow_offset.py b/tests/test_document_toolbar_arrow_offset.py index 5a145b620..096586249 100644 --- a/tests/test_document_toolbar_arrow_offset.py +++ b/tests/test_document_toolbar_arrow_offset.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ 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() - styles = (ROOT / "static/style.css").read_text() + styles = app_css() leading = script.index('class="md-toolbar-leading-controls"') left_arrow = script.index('id="md-scroll-left"') diff --git a/tests/test_document_toolbar_order.py b/tests/test_document_toolbar_order.py index 82e883380..669422830 100644 --- a/tests/test_document_toolbar_order.py +++ b/tests/test_document_toolbar_order.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -38,7 +39,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile(): async function inspect(viewport, suffix) { const page = await browser.newPage({ viewport }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async suffix => { const mod = await import(`/static/js/document.js?v=20260831richtexttools91&toolbar-order=${suffix}`); mod.init('/api'); @@ -79,6 +80,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile(): console.log(JSON.stringify({ desktop, mobile })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_editor_layer_thumbnail_render.py b/tests/test_editor_layer_thumbnail_render.py index aa9e283e1..286042011 100644 --- a/tests/test_editor_layer_thumbnail_render.py +++ b/tests/test_editor_layer_thumbnail_render.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -49,7 +50,7 @@ def test_group_rows_support_keyboard_selection(): def test_keyboard_focus_is_visible_for_layer_and_group_rows(): - styles = (ROOT / "static/style.css").read_text() + styles = app_css() assert ".ge-layer-item:focus-visible," in styles assert ".ge-layer-group-row:focus-visible" in styles diff --git a/tests/test_editor_quick_edit_static.py b/tests/test_editor_quick_edit_static.py index 7f90d92c4..72b17115a 100644 --- a/tests/test_editor_quick_edit_static.py +++ b/tests/test_editor_quick_edit_static.py @@ -1,9 +1,10 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_quick_edit_has_persistent_expanded_identity_and_labeled_input(): diff --git a/tests/test_editor_topbar_static.py b/tests/test_editor_topbar_static.py index 8f2ed3d50..377e47cf8 100644 --- a/tests/test_editor_topbar_static.py +++ b/tests/test_editor_topbar_static.py @@ -1,11 +1,12 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] TOPBAR = (ROOT / "static/js/editor/build/topbar.js").read_text(encoding="utf-8") OVERFLOW = (ROOT / "static/js/editor/wire-topbar-overflow.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_primary_editor_text_actions_use_stacked_toolbar_contract(): diff --git a/tests/test_email_folder_chip_static.py b/tests/test_email_folder_chip_static.py index 193cfa6aa..4410414ab 100644 --- a/tests/test_email_folder_chip_static.py +++ b/tests/test_email_folder_chip_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_folder_chip_stays_with_date_and_moves_down(): source = (ROOT / "static" / "js" / "emailLibrary.js").read_text() - css = (ROOT / "static" / "style.css").read_text() + css = app_css() assert 'class="email-meta-date-group"' in source group_markup = source[source.index('class="email-meta-date-group"'):][:180] diff --git a/tests/test_email_split_border_css.py b/tests/test_email_split_border_css.py index cf34d51b9..78a11899c 100644 --- a/tests/test_email_split_border_css.py +++ b/tests/test_email_split_border_css.py @@ -1,7 +1,8 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css -CSS = (Path(__file__).parents[1] / "static" / "style.css").read_text(encoding="utf-8") +CSS = app_css() def _rule(selector: str) -> str: diff --git a/tests/test_endpoint_card_ui_polish.py b/tests/test_endpoint_card_ui_polish.py index afd160b25..6f31e2a06 100644 --- a/tests/test_endpoint_card_ui_polish.py +++ b/tests/test_endpoint_card_ui_polish.py @@ -5,11 +5,12 @@ import shutil import subprocess from pathlib import Path import pytest +from tests.helpers.stylesheets import app_css _REPO = Path(__file__).resolve().parent.parent _ADMIN_JS = _REPO / "static" / "js" / "admin.js" _ADMIN = _ADMIN_JS.read_text(encoding="utf-8") -_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8") +_STYLE = app_css() pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH") diff --git a/tests/test_escape_inner_layers.py b/tests/test_escape_inner_layers.py index f7818178d..d29dd5bb5 100644 --- a/tests/test_escape_inner_layers.py +++ b/tests/test_escape_inner_layers.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -14,7 +15,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None: const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=escape-regression-1'); mod.init('/api'); @@ -55,6 +56,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None: console.log(JSON.stringify({ before, afterOne, afterTwo })); await browser.close(); ''' + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ['node', '--input-type=module', '-e', script], cwd=ROOT, @@ -114,6 +116,7 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None: console.log(JSON.stringify({ settings, select, reading })); await browser.close(); ''' + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ['node', '--input-type=module', '-e', script], cwd=ROOT, diff --git a/tests/test_image_research_settings_static.py b/tests/test_image_research_settings_static.py index 1e07626e1..f56126469 100644 --- a/tests/test_image_research_settings_static.py +++ b/tests/test_image_research_settings_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -15,7 +16,7 @@ def test_image_settings_only_list_online_served_image_models(): def test_deep_research_fields_are_constrained_to_their_card(): - styles = (ROOT / "static/style.css").read_text(encoding="utf-8") + styles = app_css() rule = styles[styles.index("/* Deep Research uses long labels"):] assert ".admin-card:has(#set-researchSearch) .settings-row { min-width: 0; }" in rule diff --git a/tests/test_interrupted_resume_label_static.py b/tests/test_interrupted_resume_label_static.py index d4fddf388..b56119da6 100644 --- a/tests/test_interrupted_resume_label_static.py +++ b/tests/test_interrupted_resume_label_static.py @@ -1,11 +1,12 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] CHAT = (ROOT / "static/js/chat.js").read_text() RENDERER = (ROOT / "static/js/chatRenderer.js").read_text() -STYLE = (ROOT / "static/style.css").read_text() +STYLE = app_css() APP = (ROOT / "static/app.js").read_text() INDEX = (ROOT / "static/index.html").read_text() diff --git a/tests/test_mobile_search_position_static.py b/tests/test_mobile_search_position_static.py index 411244286..eb7d31bfb 100644 --- a/tests/test_mobile_search_position_static.py +++ b/tests/test_mobile_search_position_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_search_popup_freezes_offset_before_mobile_keyboard_focus() -> None: source = (ROOT / "static/js/search-chat.js").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() set_offset = source.index("--search-overlay-top") show_overlay = source.index("overlay.classList.remove('hidden')", set_offset) diff --git a/tests/test_mobile_welcome_keyboard_static.py b/tests/test_mobile_welcome_keyboard_static.py index c957724c8..9339f1563 100644 --- a/tests/test_mobile_welcome_keyboard_static.py +++ b/tests/test_mobile_welcome_keyboard_static.py @@ -1,11 +1,12 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] def test_mobile_keyboard_fades_welcome_without_moving_it() -> None: - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() init_js = (ROOT / "static/js/init.js").read_text(encoding="utf-8") kb_rule = css.split("#welcome-screen.kb-hidden {", 1)[1].split("}", 1)[0] @@ -22,7 +23,7 @@ def test_mobile_keyboard_fades_welcome_without_moving_it() -> None: def test_nobody_label_collapses_by_width_not_keyboard_height() -> None: - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() label_rule = css.rsplit(".incognito-btn .incognito-label {", 1)[0] media_header = label_rule.rsplit("@media", 1)[1].split("{", 1)[0] diff --git a/tests/test_modal_dock_composer_clearance.py b/tests/test_modal_dock_composer_clearance.py index 5dfcfe2c1..b9480e06e 100644 --- a/tests/test_modal_dock_composer_clearance.py +++ b/tests/test_modal_dock_composer_clearance.py @@ -1,7 +1,8 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css -CSS = Path("static/style.css").read_text(encoding="utf-8") +CSS = app_css() INIT_JS = Path("static/js/init.js").read_text(encoding="utf-8") diff --git a/tests/test_notes_sidebar_new_note.py b/tests/test_notes_sidebar_new_note.py index 6caf9f9a3..7c78818be 100644 --- a/tests/test_notes_sidebar_new_note.py +++ b/tests/test_notes_sidebar_new_note.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_notes_sidebar_new_action_matches_library_structure_and_animation(): html = (ROOT / "static/index.html").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert 'class="list-item-plus-btn sidebar-new-item-btn" id="notes-new-note-btn"' in html assert 'new' in html[html.index('id="notes-new-note-btn"'):] @@ -51,7 +52,7 @@ def test_checklist_preview_starts_at_first_unfinished_item(): def test_checklist_preview_releases_scroll_at_its_boundaries(): notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert "el.addEventListener('wheel'" in notes assert "el.closest('.notes-pane-body')" in notes @@ -60,7 +61,7 @@ def test_checklist_preview_releases_scroll_at_its_boundaries(): def test_notes_tags_toggle_is_nudged_down(): - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() rule = css[css.index(".notes-search-bar .notes-label-toggle {"):] rule = rule[:rule.index("}")] @@ -68,7 +69,7 @@ def test_notes_tags_toggle_is_nudged_down(): def test_notes_body_and_tag_arrow_are_nudged_down(): - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert css.count("padding: 10px 8px 8px") >= 2 assert ".notes-pane-body .doclib-chip-scroll-arrow.right" in css @@ -76,7 +77,7 @@ def test_notes_body_and_tag_arrow_are_nudged_down(): def test_notes_tag_strip_cannot_grow_into_blank_space(): - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() rule = css[css.index(".notes-pane-body > .doclib-chip-scroll-frame:has(> .notes-labels-bar) {"):] rule = rule[:rule.index("}")] diff --git a/tests/test_portal_dropdown_z_js.py b/tests/test_portal_dropdown_z_js.py index 71248ee7c..2e15549e9 100644 --- a/tests/test_portal_dropdown_z_js.py +++ b/tests/test_portal_dropdown_z_js.py @@ -16,6 +16,7 @@ import textwrap from pathlib import Path import pytest +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -100,9 +101,11 @@ def test_late_routed_dropdowns_use_top_portal_z(rel): assert "topPortalZ()" in src, f"{rel} must call topPortalZ() for its dropdown z" -@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js", "static/style.css"]) +@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js", "app-css"]) def test_no_hardcoded_portal_z_literals_remain(rel): - src = (ROOT / rel).read_text() + # "app-css" is the whole cascade: a moved rule must not escape this check + # by landing in a stylesheet this list does not name. + src = app_css() if rel == "app-css" else (ROOT / rel).read_text() # Match the exact 100000/100002 these dropdowns used; the trailing-digit # guard avoids false-matching an unrelated 1000000 elsewhere. hits = re.findall(r"z-index:\s*10000[02](?!\d)", src) diff --git a/tests/test_research_panel_ui.py b/tests/test_research_panel_ui.py index 304b273a7..ecec6db8a 100644 --- a/tests/test_research_panel_ui.py +++ b/tests/test_research_panel_ui.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_research_settings_use_custom_pickers_and_shared_provider_icons(): panel = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8") - style = (ROOT / "static/style.css").read_text(encoding="utf-8") + style = app_css() settings = (ROOT / "static/js/settings.js").read_text(encoding="utf-8") icons = (ROOT / "static/js/searchProviderIcons.js").read_text(encoding="utf-8") diff --git a/tests/test_resend_message_nondestructive.py b/tests/test_resend_message_nondestructive.py index ebd9bf435..a5274cc17 100644 --- a/tests/test_resend_message_nondestructive.py +++ b/tests/test_resend_message_nondestructive.py @@ -9,6 +9,7 @@ from pathlib import Path from types import SimpleNamespace from routes.history.history_routes import _keep_count_before_message +from tests.helpers.stylesheets import app_css _REPO = Path(__file__).resolve().parent.parent @@ -76,7 +77,7 @@ def test_footer_resend_uses_default_replacement_behavior(): def test_footer_resend_uses_round_svg_icon_not_text_glyph(): renderer = _CHAT_RENDERER_JS.read_text(encoding="utf-8") - style = (_REPO / "static" / "style.css").read_text(encoding="utf-8") + style = app_css() assert "const RESEND_ICON =" in renderer assert "resend-message-icon" in renderer diff --git a/tests/test_select_dropdown_theme_css.py b/tests/test_select_dropdown_theme_css.py index bcfdf23ec..80a59df8c 100644 --- a/tests/test_select_dropdown_theme_css.py +++ b/tests/test_select_dropdown_theme_css.py @@ -1,11 +1,10 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css -STYLE_CSS = Path(__file__).resolve().parents[1] / "static" / "style.css" - - +STYLE_CSS_TEXT = app_css() def _style_text() -> str: - return STYLE_CSS.read_text(encoding="utf-8") + return STYLE_CSS_TEXT def test_native_select_options_use_theme_tokens(): diff --git a/tests/test_selection_overlay_clear_static.py b/tests/test_selection_overlay_clear_static.py index d3671fc67..4761f4ff3 100644 --- a/tests/test_selection_overlay_clear_static.py +++ b/tests/test_selection_overlay_clear_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ 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") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert "function clearSelectionAt(index)" in js assert "className = 'doc-selection-overlay-clear'" in js diff --git a/tests/test_settings_shell_js_behavior.py b/tests/test_settings_shell_js_behavior.py index e353972a6..d1bb57635 100644 --- a/tests/test_settings_shell_js_behavior.py +++ b/tests/test_settings_shell_js_behavior.py @@ -14,6 +14,7 @@ import subprocess from pathlib import Path import pytest +from tests.helpers.stylesheets import app_css _REPO = Path(__file__).resolve().parent.parent @@ -22,11 +23,9 @@ _COORDINATOR_HELPER = ( _REPO / "tests" / "helpers" / "test_settings_shell_coordinator.mjs" ) _HAS_NODE = shutil.which("node") is not None -_STYLE = _REPO / "static" / "style.css" - - +_STYLE_TEXT = app_css() def test_settings_desktop_width_targets_settings_not_cookbook(): - source = _STYLE.read_text(encoding="utf-8") + source = _STYLE_TEXT settings_rule = re.search( r"(?ms)^\.settings-modal-content\s*\{[^}]*" diff --git a/tests/test_shared_tag_strips_static.py b/tests/test_shared_tag_strips_static.py index ba79b71ae..f58ee3b1b 100644 --- a/tests/test_shared_tag_strips_static.py +++ b/tests/test_shared_tag_strips_static.py @@ -1,8 +1,9 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8") CHIP_SCROLL = (ROOT / "static/js/chipScroll.js").read_text(encoding="utf-8") diff --git a/tests/test_sidebar_chat_list_sizing_static.py b/tests/test_sidebar_chat_list_sizing_static.py index aa923ba2c..488da68b9 100644 --- a/tests/test_sidebar_chat_list_sizing_static.py +++ b/tests/test_sidebar_chat_list_sizing_static.py @@ -1,11 +1,12 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parent.parent def test_sidebar_chat_list_uses_content_height_when_collapsed(): - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() desktop = css.split("#sessions-section #session-list {", 1)[1].split("}", 1)[0] mobile = css.split("#sessions-section #session-list {", 2)[2].split("}", 1)[0] diff --git a/tests/test_skill_deeplink_static.py b/tests/test_skill_deeplink_static.py index b391c6677..d7985c3b1 100644 --- a/tests/test_skill_deeplink_static.py +++ b/tests/test_skill_deeplink_static.py @@ -1,5 +1,6 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -102,7 +103,7 @@ def test_markdown_flattens_legacy_notes_more_details(): def test_markdown_expands_note_and_skill_more_links_without_details(): markdown_src = (ROOT / "static/js/markdown.js").read_text() - style_src = (ROOT / "static/style.css").read_text() + style_src = app_css() assert "function extractMoreListPayloads" in markdown_src assert "ody-more-(notes|skills|memories|events|sessions)" in markdown_src @@ -119,7 +120,7 @@ def test_markdown_expands_note_and_skill_more_links_without_details(): def test_terminal_skill_listing_uses_clickable_bounded_formatter(): agent_src = (ROOT / "src/agent_loop.py").read_text() - style_src = (ROOT / "static/style.css").read_text() + style_src = app_css() assert "_qwen_skills_terminal_summary = _skills_list_summary_from_tool_output(" in agent_src assert '.msg-ai .body a[href^="#skill-"]' in style_src @@ -127,7 +128,7 @@ def test_terminal_skill_listing_uses_clickable_bounded_formatter(): def test_list_links_share_compact_typography(): - style_src = (ROOT / "static/style.css").read_text() + style_src = app_css() assert 'a.chat-link[href^="#events-more-"]' in style_src assert "font-size: 11px;" in style_src @@ -145,7 +146,7 @@ def test_open_skills_also_persists_skill_list(): def test_memory_deeplink_scrolls_and_flashes_even_when_filtered(): memory_src = (ROOT / "static/js/memory.js").read_text() - style_src = (ROOT / "static/style.css").read_text() + style_src = app_css() assert "function _resetMemoryDeepLinkFilters()" in memory_src assert "activeCategory = 'all';" in memory_src @@ -158,7 +159,7 @@ def test_memory_deeplink_scrolls_and_flashes_even_when_filtered(): def test_skill_and_memory_links_use_polished_chat_link_style(): - style_src = (ROOT / "static/style.css").read_text() + style_src = app_css() assert '.msg-ai .body a[href^="#skill-"]' in style_src assert '.msg-ai .body a[href^="#memory-"]' in style_src diff --git a/tests/test_stream_completion_scroll_stability.py b/tests/test_stream_completion_scroll_stability.py index 752c107be..dc360a774 100644 --- a/tests/test_stream_completion_scroll_stability.py +++ b/tests/test_stream_completion_scroll_stability.py @@ -1,11 +1,12 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8") UI = (ROOT / "static/js/ui.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_terminal_and_canonical_renders_preserve_chat_scroll_anchor(): diff --git a/tests/test_stylesheet_test_hygiene.py b/tests/test_stylesheet_test_hygiene.py new file mode 100644 index 000000000..550a6c59a --- /dev/null +++ b/tests/test_stylesheet_test_hygiene.py @@ -0,0 +1,54 @@ +"""Tests must reason about the whole cascade, not one file of it. + +``static/style.css`` is being decomposed. A test that reads that file alone, +or builds a synthetic page linking only that file, silently loses every rule +that has moved: it keeps passing while covering less. Both mistakes existed +and are cheap to detect, so this fails on either. +""" + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SELF = Path(__file__).name + +# The helper module and the manifest/snapshot tests are about the stylesheet +# set itself, so naming the file is the point rather than a mistake. +ALLOWED = {SELF, "test_static_stylesheet_manifest.py", "test_css_computed_style_snapshot.py"} + +_DIRECT_READ = re.compile(r'["\']static/style\.css["\']|"static"\s*/\s*"style\.css"') +_LONE_LINK = re.compile(r']*href="/static/style\.css') + + +def _test_sources(): + return [p for p in sorted((ROOT / "tests").glob("*.py")) if p.name not in ALLOWED] + + +def test_sources_are_discoverable() -> None: + """Guard the guard: a layout change must not make this vacuous.""" + assert len(_test_sources()) > 100 + + +def test_no_test_reads_style_css_as_the_whole_cascade() -> None: + offenders = [ + p.name for p in _test_sources() + if _DIRECT_READ.search(p.read_text(encoding="utf-8")) + ] + + assert offenders == [], ( + "read the cascade with tests.helpers.stylesheets.app_css() instead of " + f"static/style.css alone: {offenders}" + ) + + +def test_no_synthetic_page_links_style_css_alone() -> None: + offenders = [ + p.name for p in _test_sources() + if _LONE_LINK.search(p.read_text(encoding="utf-8")) + ] + + assert offenders == [], ( + "build synthetic pages with tests.helpers.stylesheets.stylesheet_link_tags() " + f"so they get every stylesheet index.html loads: {offenders}" + ) diff --git a/tests/test_theme_sidebar_mobile.py b/tests/test_theme_sidebar_mobile.py index 9830adcb9..fa0aa0c5e 100644 --- a/tests/test_theme_sidebar_mobile.py +++ b/tests/test_theme_sidebar_mobile.py @@ -1,11 +1,10 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css -STYLE = Path(__file__).resolve().parents[1] / "static" / "style.css" - - +STYLE_TEXT = app_css() def test_mobile_sidebar_uses_the_sidebar_theme_surface(): - css = STYLE.read_text() + css = STYLE_TEXT mobile_drawer = css[css.index("/* Sidebar overlays chat on mobile */"):css.index("/* Backdrop behind sidebar */")] assert "background: var(--sidebar-bg, var(--panel)) !important;" in mobile_drawer assert "background: var(--panel) !important;" not in mobile_drawer diff --git a/tests/test_toast_ui_polish.py b/tests/test_toast_ui_polish.py index ef6ff1c39..c94528d6d 100644 --- a/tests/test_toast_ui_polish.py +++ b/tests/test_toast_ui_polish.py @@ -1,6 +1,7 @@ """Regression coverage for transient toast polish and accurate welcome tips.""" from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parent.parent @@ -9,7 +10,7 @@ ROOT = Path(__file__).resolve().parent.parent def test_notification_history_does_not_clutter_navigation_or_capture_toasts(): html = (ROOT / "static/index.html").read_text(encoding="utf-8") ui = (ROOT / "static/js/ui.js").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert 'id="rail-notifications"' not in html assert 'id="sidebar-notifications-btn"' not in html @@ -32,7 +33,7 @@ def test_action_hint_is_part_of_action_button_and_close_is_grouped_beside_it(): def test_welcome_tips_are_plain_and_brief(): html = (ROOT / "static/index.html").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert "Tip: Ctrl+K searches chats." in html assert "Tip: Ctrl+Alt+B toggles the sidebar." in html diff --git a/tests/test_tool_approval_frontend_routing.py b/tests/test_tool_approval_frontend_routing.py index 8b2f66d9a..3983b6330 100644 --- a/tests/test_tool_approval_frontend_routing.py +++ b/tests/test_tool_approval_frontend_routing.py @@ -1,5 +1,6 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css def test_tool_approval_bypasses_polymorphic_send_button_actions(): @@ -22,7 +23,7 @@ def test_tool_approval_bypasses_polymorphic_send_button_actions(): def test_ask_user_card_has_no_close_button_and_chat_scale_text(): root = Path(__file__).resolve().parents[1] renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert "closeBtn.className = 'modal-close ask-user-close';" not in renderer assert "closeBtn.setAttribute('aria-label', 'Dismiss question');" not in renderer @@ -64,7 +65,7 @@ def test_ask_user_card_has_no_close_button_and_chat_scale_text(): def test_scroll_bottom_button_uses_dropdown_caret_glyph(): root = Path(__file__).resolve().parents[1] html = (root / "static/index.html").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert 'class="scroll-nav-caret"' in html assert "▾" in html diff --git a/tests/test_tool_header_icons_static.py b/tests/test_tool_header_icons_static.py index 5479ea368..e9e6d2e87 100644 --- a/tests/test_tool_header_icons_static.py +++ b/tests/test_tool_header_icons_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_tool_header_has_semantic_icons_for_live_and_saved_calls(): chat = (ROOT / "static/js/chat.js").read_text(encoding="utf-8") renderer = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert "manage_calendar" in renderer assert "manage_memory" in renderer diff --git a/tests/test_visual_research_mode.py b/tests/test_visual_research_mode.py index bdcd6da37..53c175c3c 100644 --- a/tests/test_visual_research_mode.py +++ b/tests/test_visual_research_mode.py @@ -5,6 +5,7 @@ import pytest from src.deep_research import CATEGORY_PROMPTS, _infer_research_category from src.tool_schemas import FUNCTION_TOOL_SCHEMAS from src.visual_report import _standard_visual_variant +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -20,7 +21,7 @@ def _tool_schema(name: str) -> dict: def test_visual_research_mode_is_removed_from_ui_prompt_and_tool_schema(): panel_source = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8") - style_source = (ROOT / "static/style.css").read_text(encoding="utf-8") + style_source = app_css() routes_source = (ROOT / "routes/research/research_routes.py").read_text(encoding="utf-8") category_schema = _tool_schema("trigger_research")["parameters"]["properties"]["category"] rounds_schema = _tool_schema("trigger_research")["parameters"]["properties"]["max_rounds"] From b5d1505582f8c8f02cdf8264aa3730c9d826b323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Tue, 29 Sep 2026 18:00:12 +0200 Subject: [PATCH 03/15] docs(tests): record the known full-suite failures specs/testing-devops.md lists "no canonical full-suite known-failing/flaky ledger" as a gap. Without one a first local run is uninterpretable: you cannot tell a regression from a platform artifact, so you either chase a non-bug or ignore a real one. Six failures on macOS against lab@c499c01b, each with its cause and a verdict rather than a blanket "environmental": - three compare an unresolved /tmp path against a resolved /private/tmp one. Those are test bugs and the file says so. - one asserts ffmpeg exit 0 for a .webp still, which is a build option Homebrew does not always carry. Needs a skip or a PNG fallback. - one opens real sockets and needs a fast connection refusal. Environmental. - one Playwright colour-contrast test had been written off as a flake. It is not: three consecutive runs failed identically at ~31s. Recorded as unexplained and possibly a real defect, because calling it noise is what stopped anyone looking. Also documents the prerequisites, since most surprise failures are a missing npm ci rather than anything here, and the CHROMADB_PORT precaution: the client reaches Chroma over HTTP regardless of the data directory, so a test run can attach to a store holding real data. --- tests/KNOWN_FAILURES.md | 106 ++++++++++++++++++++++++++++++++++++++++ tests/README.md | 2 + 2 files changed, 108 insertions(+) create mode 100644 tests/KNOWN_FAILURES.md diff --git a/tests/KNOWN_FAILURES.md b/tests/KNOWN_FAILURES.md new file mode 100644 index 000000000..414c82059 --- /dev/null +++ b/tests/KNOWN_FAILURES.md @@ -0,0 +1,106 @@ +# Known full-suite failures + +`python -m pytest -q` does not come back clean on every machine, and it never +has. Without a list of which failures are expected, a first local run is +uninterpretable: you cannot tell "you broke something" from "you are on a Mac", +so the usual result is either chasing a non-bug or ignoring a real one. + +This is that list. It is a record of observation, not a permission slip: a test +here is still a test that does not pass, and three of the six below are +defects someone should fix. + +Last measured: `lab @ c499c01b`, macOS 15 on Apple Silicon, Python 3.11. + +``` +6 failed, 10658 passed, 6 skipped +``` + +## Get the prerequisites right first + +Most "surprise" failures are a missing dependency rather than anything in this +file. A clean run needs all of: + +```bash +python3.11 -m venv venv +./venv/bin/python -m pip install -r requirements.txt +npm ci # the browser tests shell out to node +npx playwright install chromium # ~30 tests drive a real browser +mkdir -p data # SQLite lives at ./data/app.db +``` + +plus `ffmpeg` on `PATH` for the media tests. + +If you already have a ChromaDB running, point `CHROMADB_PORT` at a closed port +for the run. The client reaches Chroma over HTTP regardless of the data +directory, so a test run will otherwise attach to whatever store is listening, +including one holding real data. + +Miss `npm ci` and roughly 36 browser tests fail on `Cannot find package +'playwright'`. That is not a regression, it is the missing install. + +## The six + +### Test bugs: comparing an unresolved path against a resolved one + +- `tests/test_code_nav_tools.py::test_read_file_extracts_structured_documents` +- `tests/test_code_nav_tools.py::test_read_file_extracts_legacy_word_documents` +- `tests/test_workspace_confine.py::test_glob_confined_e2e` + +``` +assert [('/private/tmp/codenav_.../report.docx', ...)] + == [('/tmp/codenav_.../report.docx', ...)] +``` + +On macOS `/tmp` is a symlink to `/private/tmp`. The code under test resolves +the path and the assertion does not, so the two disagree about a file they both +found. Nothing is wrong with the behaviour. + +**These are fixable and should be fixed**: resolve both sides before comparing. +They are listed as known rather than environmental because the platform is only +what exposes them. + +### Optional dependency: ffmpeg without a WebP encoder + +- `tests/test_inspect_media_tool.py::test_inspect_media_exports_final_decodable_frame_at_exact_duration` + +``` +ffmpeg still extraction failed: Automatic encoder selection failed ... +Error opening output files: Encoder not found +``` + +The test asks ffmpeg for a `.webp` still and asserts `exit_code == 0`. WebP +encoding is a build option, and Homebrew's ffmpeg does not always carry it. CI +installs a build that does, which is why this is green there. + +**Needs a decision**: skip when the encoder is absent, or fall back to PNG. The +current shape asserts success from a codec that is not guaranteed present. + +### Environmental: real sockets + +- `tests/test_integration_api_call_ssrf.py::test_real_socket_falls_back_from_dead_first_to_live_second` + +``` +httpcore.ConnectTimeout / httpx.ConnectTimeout +``` + +Opens real sockets and depends on a connection to a dead address being refused +quickly rather than hanging. Sandboxed and restricted-network machines time out +instead. Genuinely environmental. + +### Unexplained: rich-text colour contrast + +- `tests/test_document_rich_color_reset_and_contrast.py::test_rich_colors_follow_theme_and_undo_as_one_edit` + +A Playwright run times out waiting for `#doc-email-richbody p` to contain a +`span` after a colour is applied. + +**This one is not flaky.** Three consecutive runs failed identically, each at +about 31 seconds. It was previously written off as timing noise and that was +wrong. The cause is not established, and until it is, treat it as a possible +real defect in the rich-text colour path rather than a platform artifact. + +## Keeping this current + +Re-measure on a clean checkout of `lab` with the prerequisites above, and +update the header revision, the counts and any entry that changed. A failure +that appears and is not listed here is a regression until shown otherwise. diff --git a/tests/README.md b/tests/README.md index 085cb5f84..9d5d1f80a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,6 +33,8 @@ the sub-area. The `area_*` names are registered in `pyproject.toml`; the dynamic `sub_*` names are registered before collection by `pytest_configure` in `tests/conftest.py`, so unknown-mark warnings still flag genuine typos. +The full suite does not come back clean on every machine. [KNOWN_FAILURES.md](KNOWN_FAILURES.md) lists which failures are expected, which are test bugs worth fixing, and the prerequisites a clean run needs; anything not on that list is a regression until shown otherwise. + For common focused runs, use `tests/run_focus.py`. It validates area and sub-area names, accepts sub-areas with or without the `sub_` prefix, and passes extra pytest arguments after `--`: From 345ce0a9ece20e26041a28d723c4d8fdad9638b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 09:39:05 +0200 Subject: [PATCH 04/15] test(document): read the editor through a module-set helper before it is split static/js/document.js is 17,579 lines and is about to be decomposed behind a re-export wrapper. 51 test files read it off disk and grep it as text, and 28 of those slice it with `src.split("function a", 1)[1].split("function b", 1)[0]` -- "the region between a and b", which only means what the test intends while a and b are neighbours in one file. Several also hard-code the file's two-space indentation, which no extracted module reproduces. Left alone, the first extraction makes those assertions cover the wrong region, and an `x in region` check passes while covering more than it was written for. tests/helpers/document_source is the one place that names the file now: - document_source() is the entry plus everything under static/js/document/, so a membership assertion keeps finding its subject wherever it lands; - function_body()/declaration() locate a construct by name in whichever module defines it and end at its real closing brace, so neither moving it nor moving its neighbour changes the region. The rewrite only collapses a slice when the old terminator sat at the construct's end. 28 slices deliberately span a whole family of functions -- everything from _docxHexColor to exportAsDocx -- and collapsing one to its first member drops what the assertions look for, so those stay as they are and are listed in KNOWN_ADJACENCY_SLICES, to be converted as each family becomes a module. That list may only shrink. Two guards come with it: - test_document_source_test_hygiene fails on a direct read of the entry file and on any new adjacency slice; - test_frontend_module_graph resolves every relative import under static/ (718 of them, none broken today) and requires the document module set to stay in the sw.js precache, since the worker fetches the URLs it lists and not what they import. test_document_module_api pins the 38 default-export keys and 29 named exports by loading the module in a browser and reading what it actually exports, rather than grepping for the literal object -- after extraction that object may be assembled from imports, and a source-shape check would pass while the export was broken. No JavaScript moves here. static/ is untouched. --- tests/helpers/document_source.py | 288 ++++++++++++++++++ ...est_active_document_visibility_contract.py | 7 +- tests/test_document_active_restore.py | 13 +- tests/test_document_ai_preview_refresh_js.py | 4 +- tests/test_document_deeplink.py | 5 +- ...test_document_diff_discard_on_update_js.py | 3 +- tests/test_document_edit_reference_js.py | 3 +- tests/test_document_editor_scroll.py | 3 +- tests/test_document_history_controls.py | 3 +- tests/test_document_module_api.py | 166 ++++++++++ tests/test_document_outline.py | 3 +- tests/test_document_preview_edit_button.py | 3 +- tests/test_document_rich_checklist_enter.py | 3 +- ..._document_rich_color_reset_and_contrast.py | 3 +- tests/test_document_rich_docx_export.py | 3 +- tests/test_document_rich_find_boundaries.py | 7 +- .../test_document_rich_font_color_controls.py | 7 +- tests/test_document_rich_heading_enter.py | 7 +- tests/test_document_rich_image_caption.py | 11 +- tests/test_document_rich_input_rules.py | 3 +- tests/test_document_rich_selection_toolbar.py | 3 +- tests/test_document_rich_slash_menu.py | 3 +- tests/test_document_rich_structure_tools.py | 15 +- ...test_document_rich_table_cell_alignment.py | 11 +- ...document_rich_table_header_preservation.py | 7 +- tests/test_document_rich_table_headers.py | 11 +- tests/test_document_rich_table_merge_split.py | 7 +- tests/test_document_rich_table_tab_history.py | 7 +- tests/test_document_rich_text_tools.py | 47 +-- tests/test_document_rich_toolbar_menus.py | 3 +- tests/test_document_run_format_switch.py | 3 +- tests/test_document_save_shortcut.py | 3 +- tests/test_document_source_test_hygiene.py | 200 ++++++++++++ tests/test_document_stats.py | 3 +- tests/test_document_suggestion_highlight.py | 3 +- .../test_document_suggestion_reopen_static.py | 3 +- tests/test_document_toolbar_arrow_offset.py | 3 +- tests/test_document_toolbar_order.py | 3 +- ...est_email_ai_reply_context_draft_static.py | 5 +- tests/test_email_attachment_search.py | 7 +- tests/test_email_library_bulk_actions.py | 4 +- tests/test_email_open_dedup_js.py | 5 +- tests/test_email_send_target_guard.py | 5 +- tests/test_email_ui_async_identity.py | 3 +- tests/test_external_context_tool_gate.py | 5 +- tests/test_frontend_module_graph.py | 102 +++++++ tests/test_markdown_dom_xss_helpers.py | 3 +- tests/test_markdown_lazy_lib_loading_js.py | 3 +- tests/test_notes_document_mutual_minimize.py | 3 +- ...test_pdf_export_preserves_import_static.py | 3 +- ...test_preview_hides_import_action_static.py | 3 +- tests/test_review_docx_async_identity.py | 3 +- .../test_richtext_format_selection_static.py | 3 +- ...chtext_preview_returns_to_editor_static.py | 3 +- tests/test_selection_overlay_clear_static.py | 3 +- 55 files changed, 895 insertions(+), 148 deletions(-) create mode 100644 tests/helpers/document_source.py create mode 100644 tests/test_document_module_api.py create mode 100644 tests/test_document_source_test_hygiene.py create mode 100644 tests/test_frontend_module_graph.py 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 243d23406..9d255933c 100644 --- a/tests/test_document_edit_reference_js.py +++ b/tests/test_document_edit_reference_js.py @@ -1,11 +1,12 @@ """Regression guards for document-selection references in chat bubbles.""" from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8") -DOCUMENT = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOCUMENT = document_source() STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8") APP = (ROOT / "static/app.js").read_text(encoding="utf-8") diff --git a/tests/test_document_editor_scroll.py b/tests/test_document_editor_scroll.py index 89cbc7b81..a205131dc 100644 --- a/tests/test_document_editor_scroll.py +++ b/tests/test_document_editor_scroll.py @@ -9,10 +9,11 @@ document.js is browser-coupled and not importable in pytest. """ from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") diff --git a/tests/test_document_history_controls.py b/tests/test_document_history_controls.py index bd4b297ac..5b4894e5c 100644 --- a/tests/test_document_history_controls.py +++ b/tests/test_document_history_controls.py @@ -3,10 +3,11 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_history_buttons_start_disabled_and_follow_native_history(): 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 4ab98d680..9cf365231 100644 --- a/tests/test_document_outline.py +++ b/tests/test_document_outline.py @@ -3,10 +3,11 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") diff --git a/tests/test_document_preview_edit_button.py b/tests/test_document_preview_edit_button.py index 52340f96d..2ceb077a2 100644 --- a/tests/test_document_preview_edit_button.py +++ b/tests/test_document_preview_edit_button.py @@ -1,10 +1,11 @@ """Regression guards for the Markdown preview hover-to-edit control.""" from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") diff --git a/tests/test_document_rich_checklist_enter.py b/tests/test_document_rich_checklist_enter.py index b36bdb67b..7f886d21b 100644 --- a/tests/test_document_rich_checklist_enter.py +++ b/tests/test_document_rich_checklist_enter.py @@ -3,10 +3,11 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_checklist_enter_uses_native_edit_commands_and_resets_state(): diff --git a/tests/test_document_rich_color_reset_and_contrast.py b/tests/test_document_rich_color_reset_and_contrast.py index 64c237a7b..1b3971e42 100644 --- a/tests/test_document_rich_color_reset_and_contrast.py +++ b/tests/test_document_rich_color_reset_and_contrast.py @@ -3,10 +3,11 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") diff --git a/tests/test_document_rich_docx_export.py b/tests/test_document_rich_docx_export.py index f5b6d5c6b..0f9d634be 100644 --- a/tests/test_document_rich_docx_export.py +++ b/tests/test_document_rich_docx_export.py @@ -5,10 +5,11 @@ import subprocess import tempfile import zipfile from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_rich_docx_converter_maps_editor_structure_instead_of_raw_html(): diff --git a/tests/test_document_rich_find_boundaries.py b/tests/test_document_rich_find_boundaries.py index c4e293ad8..f45176b67 100644 --- a/tests/test_document_rich_find_boundaries.py +++ b/tests/test_document_rich_find_boundaries.py @@ -3,16 +3,15 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_find_index_inserts_boundaries_without_flattening_inline_spans(): - section = DOC_JS.split("function _buildRichFindRanges", 1)[1].split( - "function _renderRichFindRanges", 1 - )[0] + section = function_body("_buildRichFindRanges") assert "const blockSelector = 'p,div,h1,h2,h3,h4,h5,h6,li,blockquote,pre,td,th'" in section assert "block !== previousBlock" in section assert "between.cloneContents().querySelector?.('br')" in section diff --git a/tests/test_document_rich_font_color_controls.py b/tests/test_document_rich_font_color_controls.py index cfecf8699..9cd2df299 100644 --- a/tests/test_document_rich_font_color_controls.py +++ b/tests/test_document_rich_font_color_controls.py @@ -6,10 +6,11 @@ import subprocess from pathlib import Path from tests.helpers.stylesheets import app_css +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = app_css() @@ -47,8 +48,8 @@ def test_horizontal_rule_is_ordered_after_clear_formatting(): def test_image_options_are_hidden_until_a_rich_image_is_selected(): - clear_fn = DOC_JS.split("function _clearRichImageSelection()", 1)[1].split("function _selectRichImage", 1)[0] - select_fn = DOC_JS.split("function _selectRichImage", 1)[1].split("function _selectedRichImage", 1)[0] + clear_fn = function_body("_clearRichImageSelection") + select_fn = function_body("_selectRichImage") assert "imageButton.style.display = 'none';" in clear_fn assert "imageButton.style.display = '';" in select_fn diff --git a/tests/test_document_rich_heading_enter.py b/tests/test_document_rich_heading_enter.py index d2df9ca89..2c1c03b78 100644 --- a/tests/test_document_rich_heading_enter.py +++ b/tests/test_document_rich_heading_enter.py @@ -3,16 +3,15 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_heading_enter_uses_single_native_history_commands(): - helper = DOC_JS.split("function _handleRichHeadingEnter", 1)[1].split( - "let _richInlineCodeTypingArmed", 1 - )[0] + helper = function_body("_handleRichHeadingEnter") assert "selection.isCollapsed" in helper assert "h1, h2, h3, h4, h5, h6" in helper diff --git a/tests/test_document_rich_image_caption.py b/tests/test_document_rich_image_caption.py index 7c573dd03..c09316d4c 100644 --- a/tests/test_document_rich_image_caption.py +++ b/tests/test_document_rich_image_caption.py @@ -5,20 +5,17 @@ import subprocess from pathlib import Path from tests.helpers.stylesheets import app_css +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = app_css() def test_image_caption_uses_semantic_figure_and_structured_export_paths(): - caption = DOC_JS.split("async function _editRichImageCaption", 1)[1].split( - "function _applyRichImageAction", 1 - )[0] - converter = DOC_JS.split("function _docxFigureBlocks", 1)[1].split( - "function _docxBlocksFromNodes", 1 - )[0] + caption = function_body("_editRichImageCaption") + converter = function_body("_docxFigureBlocks") assert "function _promptImageCaption" in DOC_JS assert "function _replaceRichImageFigure" in DOC_JS diff --git a/tests/test_document_rich_input_rules.py b/tests/test_document_rich_input_rules.py index 0e93edc35..aae84456e 100644 --- a/tests/test_document_rich_input_rules.py +++ b/tests/test_document_rich_input_rules.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_rich_selection_toolbar.py b/tests/test_document_rich_selection_toolbar.py index 306d45cce..93d861d6a 100644 --- a/tests/test_document_rich_selection_toolbar.py +++ b/tests/test_document_rich_selection_toolbar.py @@ -3,10 +3,11 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") diff --git a/tests/test_document_rich_slash_menu.py b/tests/test_document_rich_slash_menu.py index 7c191985d..0466721c0 100644 --- a/tests/test_document_rich_slash_menu.py +++ b/tests/test_document_rich_slash_menu.py @@ -3,10 +3,11 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") diff --git a/tests/test_document_rich_structure_tools.py b/tests/test_document_rich_structure_tools.py index e6fd58922..e7d5090ef 100644 --- a/tests/test_document_rich_structure_tools.py +++ b/tests/test_document_rich_structure_tools.py @@ -3,26 +3,21 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import declaration, document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") def test_heading_levels_and_page_break_are_exposed_everywhere(): - slash = DOC_JS.split("const _RICH_SLASH_COMMANDS", 1)[1].split( - "const _RICH_BLOCK_INPUT_RULES", 1 - )[0] - rules = DOC_JS.split("const _RICH_BLOCK_INPUT_RULES", 1)[1].split( - "function _applyRichBlockInputRule", 1 - )[0] + slash = declaration("_RICH_SLASH_COMMANDS") + rules = declaration("_RICH_BLOCK_INPUT_RULES") dropdown = DOC_JS.split("function _showMdDropdown", 1)[1].split( "function initMdToolbar", 1 )[0] - exporter = DOC_JS.split("function _richTextExportCss", 1)[1].split( - "function exportAsHtml", 1 - )[0] + exporter = function_body("_richTextExportCss") for level in (5, 6): assert f"action: 'h{level}'" in slash diff --git a/tests/test_document_rich_table_cell_alignment.py b/tests/test_document_rich_table_cell_alignment.py index a410723e3..4f875b5c1 100644 --- a/tests/test_document_rich_table_cell_alignment.py +++ b/tests/test_document_rich_table_cell_alignment.py @@ -3,19 +3,16 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_table_cell_alignment_uses_contextual_undoable_table_path(): - actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split( - "function _insertRichPageBreak", 1 - )[0] - state = DOC_JS.split("function _richDropdownCurrentActions", 1)[1].split( - "function _showMdDropdown", 1 - )[0] + actions = function_body("_applyRichTableAction") + state = function_body("_richDropdownCurrentActions") for alignment in ("top", "middle", "bottom"): assert f"table:cell-align:{alignment}" in DOC_JS diff --git a/tests/test_document_rich_table_header_preservation.py b/tests/test_document_rich_table_header_preservation.py index 4c7c03626..4d0a90384 100644 --- a/tests/test_document_rich_table_header_preservation.py +++ b/tests/test_document_rich_table_header_preservation.py @@ -3,19 +3,18 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_table_structure_uses_central_header_mode_normalization(): helpers = DOC_JS.split("function _richTableHeaderModes", 1)[1].split( "function _replaceRichTable", 1 )[0] - append = DOC_JS.split("function _appendRichTableRow", 1)[1].split( - "function _insertRichTable", 1 - )[0] + append = function_body("_appendRichTableRow") actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split( "function applyMdFormat", 1 )[0] diff --git a/tests/test_document_rich_table_headers.py b/tests/test_document_rich_table_headers.py index cd77ba93c..e5c8fed16 100644 --- a/tests/test_document_rich_table_headers.py +++ b/tests/test_document_rich_table_headers.py @@ -3,19 +3,18 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_table_header_controls_use_tag_replacement_and_native_history(): table_actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split( "function applyMdFormat", 1 )[0] - menu_state = DOC_JS.split("function _richDropdownCurrentActions", 1)[1].split( - "function _showMdDropdown", 1 - )[0] + menu_state = function_body("_richDropdownCurrentActions") assert "function _replaceRichTableCellTag" in DOC_JS assert "table:toggle-header-row" in table_actions @@ -24,9 +23,7 @@ def test_table_header_controls_use_tag_replacement_and_native_history(): assert "headerModes.headerRow = !headerModes.headerRow" in table_actions assert "headerModes.headerColumn = !headerModes.headerColumn" in table_actions assert "_applyRichTableHeaderModes(clone, headerModes)" in table_actions - header_normalizer = DOC_JS.split("function _applyRichTableHeaderModes", 1)[1].split( - "function _replaceRichTable", 1 - )[0] + header_normalizer = function_body("_applyRichTableHeaderModes") assert "_replaceRichTableCellTag" in header_normalizer assert "_replaceRichTable(rich, original, clone" in table_actions assert "current.add('table:toggle-header-row')" in menu_state diff --git a/tests/test_document_rich_table_merge_split.py b/tests/test_document_rich_table_merge_split.py index 85bb04387..abecf2b66 100644 --- a/tests/test_document_rich_table_merge_split.py +++ b/tests/test_document_rich_table_merge_split.py @@ -3,16 +3,15 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_merge_split_commands_use_contextual_undoable_table_path(): - actions = DOC_JS.split("function _applyRichTableAction", 1)[1].split( - "function _insertRichPageBreak", 1 - )[0] + actions = function_body("_applyRichTableAction") menu = DOC_JS.split("function _showMdDropdown", 1)[1].split( "function initMdToolbar", 1 )[0] diff --git a/tests/test_document_rich_table_tab_history.py b/tests/test_document_rich_table_tab_history.py index abca1d02a..a9af656bb 100644 --- a/tests/test_document_rich_table_tab_history.py +++ b/tests/test_document_rich_table_tab_history.py @@ -3,16 +3,15 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source, function_body ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_last_cell_tab_uses_the_undoable_table_replacement_path(): - helper = DOC_JS.split("function _appendRichTableRow", 1)[1].split( - "function _insertRichTable", 1 - )[0] + helper = function_body("_appendRichTableRow") key_handler = DOC_JS.split("if (e.key === 'Tab')", 1)[1].split( "let inList = false", 1 )[0] 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 178820293..eafc997fb 100644 --- a/tests/test_document_rich_toolbar_menus.py +++ b/tests/test_document_rich_toolbar_menus.py @@ -3,10 +3,11 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") 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 0b1fe4f9b..d03edaee1 100644 --- a/tests/test_document_save_shortcut.py +++ b/tests/test_document_save_shortcut.py @@ -3,10 +3,11 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_save_shortcut_uses_manual_version_path_and_cancels_autosave(): 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 3dd819bda..653773f1d 100644 --- a/tests/test_document_suggestion_highlight.py +++ b/tests/test_document_suggestion_highlight.py @@ -1,10 +1,11 @@ """Regression guards for exact inline document-suggestion highlighting.""" from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") 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 5a145b620..6f95f8fb7 100644 --- a/tests/test_document_toolbar_arrow_offset.py +++ b/tests/test_document_toolbar_arrow_offset.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_toolbar_arrows_have_real_flex_slots_outside_icon_scroller(): - script = (ROOT / "static/js/document.js").read_text() + script = document_source() styles = (ROOT / "static/style.css").read_text() leading = script.index('class="md-toolbar-leading-controls"') diff --git a/tests/test_document_toolbar_order.py b/tests/test_document_toolbar_order.py index 82e883380..683a29aa7 100644 --- a/tests/test_document_toolbar_order.py +++ b/tests/test_document_toolbar_order.py @@ -3,10 +3,11 @@ import json import subprocess from pathlib import Path +from tests.helpers.document_source import document_source ROOT = Path(__file__).resolve().parents[1] -DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") +DOC_JS = document_source() def test_toolbar_groups_define_writing_actions_before_view_controls(): 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 d3671fc67..4dd8ece57 100644 --- a/tests/test_selection_overlay_clear_static.py +++ b/tests/test_selection_overlay_clear_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_selection_overlays_have_individual_clear_controls(): - js = (ROOT / "static/js/document.js").read_text(encoding="utf-8") + js = document_source() css = (ROOT / "static/style.css").read_text(encoding="utf-8") assert "function clearSelectionAt(index)" in js From eabdf84669179d2fe88e239f39b2762369e09d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 10:36:00 +0200 Subject: [PATCH 05/15] fix(tests): stop test_auth_regressions leaking stub modules Running test_auth_regressions.py before the email modules failed 23 tests that pass in isolation: pytest -p no:randomly tests/test_auth_regressions.py \ tests/test_email_urgency_checkpoint.py 23 failed, 15 passed (38 passed in the reverse order) Every failure was ImportError "cannot import name X (unknown location)" against a module already in sys.modules, which is what an empty stub module looks like to a later import. Two writes leaked, both in this file: - test_pop_notifications_owner_filtered inserted five empty stub modules with a bare sys.modules[name] = mod and never removed them. - _ensure_stub wrote its stub into sys.modules itself, so the autouse fixture's monkeypatch.setitem three lines later captured that stub as the value to restore. The fixture looked like it cleaned up and could not. Both now go through monkeypatch, including the parent-package stub and the attribute wiring, so everything is undone at teardown. The redundant setitem calls in the fixture are gone: re-setting a key whose stub is already installed is what made the leak invisible. Pre-existing on lab, not introduced by any open PR. Verified against the email subpackage branch too: identical numbers there before this fix. --- tests/test_auth_regressions.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/tests/test_auth_regressions.py b/tests/test_auth_regressions.py index 62b479748..b7403f4ca 100644 --- a/tests/test_auth_regressions.py +++ b/tests/test_auth_regressions.py @@ -21,7 +21,7 @@ from unittest.mock import MagicMock # (Same trick as test_null_owner_gates.py — the real modules instantiate # SQLAlchemy declarative classes at import-time which blow up under the # conftest's `sqlalchemy.*` MagicMock stubs.) -def _ensure_stub(name: str, **attrs): +def _ensure_stub(monkeypatch, name: str, **attrs): """Create or augment a stub module with the given attributes. Augments existing entries because earlier-run tests may have already stubbed the same module with a different attribute set. @@ -48,7 +48,7 @@ def _ensure_stub(name: str, **attrs): *parent_name.split("."), ) parent.__path__ = [real_path] if os.path.isdir(real_path) else [] - sys.modules[parent_name] = parent + monkeypatch.setitem(sys.modules, parent_name, parent) else: parent = sys.modules[parent_name] else: @@ -58,17 +58,17 @@ def _ensure_stub(name: str, **attrs): mod = sys.modules.get(name) if mod is None: mod = types.ModuleType(name) - sys.modules[name] = mod + monkeypatch.setitem(sys.modules, name, mod) for k, v in attrs.items(): if not hasattr(mod, k): - setattr(mod, k, v) + monkeypatch.setattr(mod, k, v, raising=False) if parent is not None and not hasattr(parent, child_name): - setattr(parent, child_name, mod) + monkeypatch.setattr(parent, child_name, mod, raising=False) return mod @pytest.fixture(autouse=True) def _auth_regressions_stubs(monkeypatch): - db = _ensure_stub("core.database", + db = _ensure_stub(monkeypatch, "core.database", SessionLocal=MagicMock(), ScheduledTask=MagicMock(), TaskRun=MagicMock(), ModelEndpoint=MagicMock(), Session=MagicMock(), ChatMessage=MagicMock(), CalendarCal=MagicMock(), CalendarEvent=MagicMock(), @@ -76,17 +76,18 @@ def _auth_regressions_stubs(monkeypatch): GalleryImage=MagicMock(), GalleryAlbum=MagicMock(), Note=MagicMock(), McpServer=MagicMock(), ) - auth = _ensure_stub("core.auth", AuthManager=MagicMock()) - ep = _ensure_stub("src.endpoint_resolver", + auth = _ensure_stub(monkeypatch, "core.auth", AuthManager=MagicMock()) + ep = _ensure_stub(monkeypatch, "src.endpoint_resolver", resolve_endpoint=MagicMock(return_value=("", "", {})), normalize_base=MagicMock(), build_chat_url=MagicMock(), build_models_url=MagicMock(), build_headers=MagicMock(), ) - monkeypatch.setitem(sys.modules, "core.database", db) - monkeypatch.setitem(sys.modules, "core.auth", auth) - monkeypatch.setitem(sys.modules, "src.endpoint_resolver", ep) + # _ensure_stub now registers each stub through monkeypatch itself, so the + # whole set is undone at teardown. Re-setting them here would capture the + # stub as the restore target and leave it behind for the rest of the run. + assert db and auth and ep from fastapi import HTTPException @@ -293,7 +294,7 @@ def test_research_spinoff_rejects_wrong_owner(): # pop_notifications owner filter # --------------------------------------------------------------------------- -def test_pop_notifications_owner_filtered(): +def test_pop_notifications_owner_filtered(monkeypatch): """pop_notifications(owner='alice') must return only alice's items. bob's and legacy ownerless items stay behind in the queue.""" # Build a minimal scheduler instance that we can hit directly. @@ -302,11 +303,15 @@ def test_pop_notifications_owner_filtered(): import sys, types from unittest.mock import MagicMock as _MM # `task_scheduler` pulls in lots of helpers — stub the ones it uses. + # monkeypatch.setitem, not a bare assignment: a plain write leaves these + # empty stubs in sys.modules for the rest of the session, and every later + # test that imports a real name from one of them fails with + # "cannot import name ... (unknown location)". The stubs above in this file + # already use monkeypatch for the same reason. for s in ["src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver", "src.agent_loop", "src.session_manager"]: if s not in sys.modules: - mod = types.ModuleType(s) - sys.modules[s] = mod + monkeypatch.setitem(sys.modules, s, types.ModuleType(s)) from src.task_scheduler import TaskScheduler sch = TaskScheduler.__new__(TaskScheduler) # bypass __init__ network etc. sch._pending_notifications = [] From 32d9dbc267eb81ca18032a79ed47de6910d4c252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 10:56:58 +0200 Subject: [PATCH 06/15] fix(tests): resolve temp paths consistently on macOS Three of the six recorded failures were the same test bug: an unresolved /tmp path compared against a resolved /private/tmp one. macOS makes /tmp a symlink, so a fixture built with tempfile.mkdtemp(dir="/tmp") and a code path that resolves what it reports disagree about a file both found correctly. test_code_nav_tools builds its fixture unresolved and compares it against the reported path. One realpath fixes both of its failures. test_glob_confined_e2e is the same cause through a longer route: it mixed os.path.realpath(ws) with an unresolved secret directory, so relpath emitted "../../../../tmp/" and the assertion that the absolute path was absent from the output matched it as a substring. Resolving the secret directory puts both sides in one tree and the relative path stays short. macOS full suite goes from 6 failures to 3. The remaining three are an ffmpeg build without a WebP encoder, a socket test that needs a fast connection refusal, and the rich-text colour test that is still unexplained. The ledger is updated in the same change so it does not describe failures that no longer happen. --- tests/KNOWN_FAILURES.md | 34 ++++++++++++++++----------------- tests/test_code_nav_tools.py | 5 ++++- tests/test_workspace_confine.py | 9 +++++++-- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/tests/KNOWN_FAILURES.md b/tests/KNOWN_FAILURES.md index 414c82059..4af06c7dc 100644 --- a/tests/KNOWN_FAILURES.md +++ b/tests/KNOWN_FAILURES.md @@ -9,10 +9,10 @@ This is that list. It is a record of observation, not a permission slip: a test here is still a test that does not pass, and three of the six below are defects someone should fix. -Last measured: `lab @ c499c01b`, macOS 15 on Apple Silicon, Python 3.11. +Last measured: `lab @ c499c01b` plus the fixes in this change, macOS 15 on Apple Silicon, Python 3.11. ``` -6 failed, 10658 passed, 6 skipped +3 failed, 10658 passed, 6 skipped ``` ## Get the prerequisites right first @@ -38,26 +38,24 @@ including one holding real data. Miss `npm ci` and roughly 36 browser tests fail on `Cannot find package 'playwright'`. That is not a regression, it is the missing install. -## The six +## The three -### Test bugs: comparing an unresolved path against a resolved one +### Test bugs: fixed -- `tests/test_code_nav_tools.py::test_read_file_extracts_structured_documents` -- `tests/test_code_nav_tools.py::test_read_file_extracts_legacy_word_documents` -- `tests/test_workspace_confine.py::test_glob_confined_e2e` +Three failures compared an unresolved `/tmp` path against a resolved +`/private/tmp` one, and are fixed rather than listed: -``` -assert [('/private/tmp/codenav_.../report.docx', ...)] - == [('/tmp/codenav_.../report.docx', ...)] -``` +- `tests/test_code_nav_tools.py` (two tests) built a fixture under + `tempfile.mkdtemp(dir="/tmp")` and compared it against the path the code + reports, which it resolves. +- `tests/test_workspace_confine.py::test_glob_confined_e2e` mixed + `os.path.realpath(ws)` with an unresolved secret directory, so `relpath` + produced `../../../../tmp/` and the assertion that the + absolute path was absent matched it as a substring. -On macOS `/tmp` is a symlink to `/private/tmp`. The code under test resolves -the path and the assertion does not, so the two disagree about a file they both -found. Nothing is wrong with the behaviour. - -**These are fixable and should be fixed**: resolve both sides before comparing. -They are listed as known rather than environmental because the platform is only -what exposes them. +Both now resolve consistently. They are recorded here because the shape recurs: +on macOS, mixing a resolved and an unresolved temp path is a test bug that +looks like a platform failure. ### Optional dependency: ffmpeg without a WebP encoder diff --git a/tests/test_code_nav_tools.py b/tests/test_code_nav_tools.py index 2c472be9f..33fd4c8d8 100644 --- a/tests/test_code_nav_tools.py +++ b/tests/test_code_nav_tools.py @@ -17,7 +17,10 @@ def _run(tool, content): @pytest.fixture def repo(): # Built under /tmp, which is on the default tool-path allowlist. - root = tempfile.mkdtemp(dir="/tmp", prefix="codenav_") + # realpath because the code under test resolves the path it reports, and on + # macOS /tmp is a symlink to /private/tmp: comparing the unresolved path + # against the resolved one fails on a file both sides found correctly. + root = os.path.realpath(tempfile.mkdtemp(dir="/tmp", prefix="codenav_")) try: with open(os.path.join(root, "a.py"), "w") as f: f.write("import os\n# needle here\nprint('x')\n") diff --git a/tests/test_workspace_confine.py b/tests/test_workspace_confine.py index 3d746d7d2..25ca7c192 100644 --- a/tests/test_workspace_confine.py +++ b/tests/test_workspace_confine.py @@ -225,8 +225,13 @@ async def test_glob_confined_e2e(ws, admin): assert ws not in r["output"] assert "/workspace/found.py" in r["output"] - # a secret outside the workspace must not be discoverable via glob - outside = tempfile.mkdtemp() + # a secret outside the workspace must not be discoverable via glob. + # realpath so this directory and os.path.realpath(ws) below sit in the same + # resolved tree. On macOS /tmp is a symlink to /private/tmp, and mixing a + # resolved workspace with an unresolved secret makes relpath emit + # "../../../../tmp/", which trivially contains the absolute path + # the assertion is checking for. + outside = os.path.realpath(tempfile.mkdtemp()) secret = os.path.join(outside, "secret.txt") with open(secret, "w") as f: f.write("nope") From 30ef7652c0222206d067949a84e5b34497832071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 12:11:42 +0200 Subject: [PATCH 07/15] fix(cookbook): skip the pid sweep when the host has no procfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_cookbook_kill_session` kills the tmux session, then sweeps /proc for model servers that survived the SIGHUP. The sweep had no guard, so on macOS and Windows `os.listdir("/proc")` raised FileNotFoundError after the kill had already succeeded. The function's outer except turned that into `{"error": "...No such file or directory: '/proc'", "exit_code": 1}` and skipped the state write that marks the session stopped — the agent is told a stop failed that actually worked. Guard the sweep with a procfs check, the way `_scan_running_model_processes` already does a few hundred lines up. The root and the check now live in `core/platform_compat`, which is where OS differences belong and which makes both branches patchable from a test on either kind of host. Adds a class-level guard test: the third instance of this defect, and two of the three were found by reading source rather than by a test. --- core/platform_compat.py | 13 ++ src/tools/cookbook.py | 19 ++- tests/test_cookbook_stop_without_procfs.py | 166 +++++++++++++++++++ tests/test_procfs_scan_guard.py | 178 +++++++++++++++++++++ 4 files changed, 369 insertions(+), 7 deletions(-) create mode 100644 tests/test_cookbook_stop_without_procfs.py create mode 100644 tests/test_procfs_scan_guard.py diff --git a/core/platform_compat.py b/core/platform_compat.py index efa496ac6..e667c1e96 100644 --- a/core/platform_compat.py +++ b/core/platform_compat.py @@ -36,6 +36,19 @@ IS_APPLE_SILICON = ( ) +# ── procfs ────────────────────────────────────────────────────────────────── +# Linux exposes one directory per pid under /proc; macOS and Windows have no +# procfs at all. Any code that walks it must skip the walk rather than raise. +# Kept as a module attribute so both branches stay testable on either kind of +# host. +PROC_ROOT = Path("/proc") + + +def has_procfs() -> bool: + """True when the host exposes a procfs pid tree that can be scanned.""" + return PROC_ROOT.is_dir() + + # ── File permissions ──────────────────────────────────────────────────────── def safe_chmod(path, mode: int) -> bool: """``os.chmod`` that is a harmless no-op on Windows. diff --git a/src/tools/cookbook.py b/src/tools/cookbook.py index 96529d94e..c2def959b 100644 --- a/src/tools/cookbook.py +++ b/src/tools/cookbook.py @@ -17,6 +17,7 @@ import re from typing import Any, Dict, List, Optional from fastapi import HTTPException +from core import platform_compat from routes._validators import validate_remote_host, validate_ssh_port from src.tools._common import _parse_tool_args @@ -676,17 +677,17 @@ def _scan_running_model_processes() -> List[Dict[str, Any]]: a dict shaped like a cookbook task so the caller can merge cleanly. """ import os - if not os.path.isdir("/proc"): + if not platform_compat.has_procfs(): return [] + proc_root = platform_compat.PROC_ROOT out: List[Dict[str, Any]] = [] seen_keys = set() try: - for pid_dir in os.listdir("/proc"): + for pid_dir in os.listdir(proc_root): if not pid_dir.isdigit(): continue try: - with open(f"/proc/{pid_dir}/cmdline", "rb") as f: - raw = f.read() + raw = (proc_root / pid_dir / "cmdline").read_bytes() except (OSError, PermissionError): continue if not raw: @@ -1124,12 +1125,16 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "", import signal tracked_cmd = str((matched.get("payload") or {}).get("_cmd") or "").strip() matched_pids: list[int] = [] - if tracked_cmd: - for pid_name in os.listdir("/proc"): + # No procfs means no way to match a survivor by its command line. + # The tmux kill above already stopped the session, so skip the + # sweep instead of failing a stop that worked. + if tracked_cmd and platform_compat.has_procfs(): + proc_root = platform_compat.PROC_ROOT + for pid_name in os.listdir(proc_root): if not pid_name.isdigit() or int(pid_name) == os.getpid(): continue try: - raw = open(f"/proc/{pid_name}/cmdline", "rb").read() + raw = (proc_root / pid_name / "cmdline").read_bytes() process_cmd = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip() except (OSError, PermissionError): continue diff --git a/tests/test_cookbook_stop_without_procfs.py b/tests/test_cookbook_stop_without_procfs.py new file mode 100644 index 000000000..ad6e225a8 --- /dev/null +++ b/tests/test_cookbook_stop_without_procfs.py @@ -0,0 +1,166 @@ +"""Stopping a Cookbook server must succeed on a host with no procfs. + +The tmux kill is what actually stops the server; the pid sweep that follows +it only catches model servers that survive the session's SIGHUP. On macOS and +Windows there is no ``/proc`` to sweep, and letting that raise turned a +successful stop into a reported failure *and* skipped the state write that +marks the session stopped for the Cookbook UI. +""" +import asyncio +import json +import signal + +import pytest + +from core import platform_compat +from src import tool_implementations as tools + + +class FakeResponse: + def __init__(self, data=None, status_code=200): + self._data = data or {} + self.status_code = status_code + self.text = json.dumps(self._data) + + def json(self): + return self._data + + +def _tracked_state(session_id="serve-abc123", cmd="python -m vllm.entrypoints.openai.api_server"): + return { + "tasks": [ + { + "sessionId": session_id, + "model": "org/model", + "type": "serve", + "status": "running", + "payload": {"_cmd": cmd}, + } + ] + } + + +def _install_httpx_client(monkeypatch, state): + """Serve cookbook state over a fake httpx and record every POST body.""" + import httpx + + posts = [] + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url, **kwargs): + return FakeResponse(state) + + async def post(self, url, json=None, **kwargs): + posts.append((url, json)) + return FakeResponse({"ok": True}) + + monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient) + return posts + + +def _install_successful_tmux_kill(monkeypatch): + """Replace the real ``tmux kill-session`` with a process that succeeds.""" + + class FakeProc: + returncode = 0 + + async def communicate(self): + return b"", b"" + + async def fake_exec(*argv, **kwargs): + assert argv[:2] == ("tmux", "kill-session") + return FakeProc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + + +def _stopped_statuses(posts, session_id): + out = [] + for _url, body in posts: + for task in (body or {}).get("tasks") or []: + if task.get("sessionId") == session_id: + out.append(task.get("status")) + return out + + +@pytest.mark.asyncio +async def test_stop_marks_session_stopped_when_the_host_has_no_procfs( + monkeypatch, tmp_path +): + state = _tracked_state() + posts = _install_httpx_client(monkeypatch, state) + _install_successful_tmux_kill(monkeypatch) + monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "no-procfs") + + import os + + def _unexpected_listdir(*args, **kwargs): + raise AssertionError("the pid sweep must not run without procfs") + + monkeypatch.setattr(os, "listdir", _unexpected_listdir) + + result = await tools.do_stop_served_model( + json.dumps({"session_id": "serve-abc123"}) + ) + + assert result == {"output": "Stopped server serve-abc123", "exit_code": 0} + assert _stopped_statuses(posts, "serve-abc123") == ["stopped"] + + +@pytest.mark.asyncio +async def test_stop_sweeps_surviving_pids_when_procfs_is_present( + monkeypatch, tmp_path +): + tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model" + state = _tracked_state(cmd=tracked_cmd) + posts = _install_httpx_client(monkeypatch, state) + _install_successful_tmux_kill(monkeypatch) + + proc = tmp_path / "proc" + + def _write_pid(pid, cmdline): + entry = proc / pid + entry.mkdir(parents=True) + (entry / "cmdline").write_bytes(cmdline.replace(" ", "\0").encode()) + + _write_pid("101", tracked_cmd) + _write_pid("202", "python -m http.server") + (proc / "self").mkdir() + monkeypatch.setattr(platform_compat, "PROC_ROOT", proc) + + signalled = [] + import os + + monkeypatch.setattr(os, "kill", lambda pid, sig: signalled.append((pid, sig))) + + result = await tools.do_stop_served_model( + json.dumps({"session_id": "serve-abc123"}) + ) + + assert result["exit_code"] == 0 + assert (101, signal.SIGTERM) in signalled + assert not any(pid == 202 for pid, _sig in signalled) + assert _stopped_statuses(posts, "serve-abc123") == ["stopped"] + + +def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path): + """The other procfs scan in the same module already guards; pin it.""" + monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "no-procfs") + + import os + + def _unexpected_listdir(*args, **kwargs): + raise AssertionError("the model-process scan must not run without procfs") + + monkeypatch.setattr(os, "listdir", _unexpected_listdir) + + assert tools._scan_running_model_processes() == [] diff --git a/tests/test_procfs_scan_guard.py b/tests/test_procfs_scan_guard.py new file mode 100644 index 000000000..9d3f91ef1 --- /dev/null +++ b/tests/test_procfs_scan_guard.py @@ -0,0 +1,178 @@ +"""Every procfs pid scan in the app tree must be guarded by an existence check. + +This is the third instance of the same defect: code walks ``/proc`` on a host +that has no procfs, and the resulting ``FileNotFoundError`` breaks a path that +had otherwise succeeded. Two of the three were found by reading source, so +this pins the class rather than the instances. + +Deliberate AST assertion under the narrow exception in +``tests/TESTING_STANDARD.md``: the invariant is "no *other* module grows an +unguarded scan", which cannot be driven at runtime without importing and +exercising every procfs-touching code path on both a Linux and a non-Linux +host. ``test_cookbook_stop_without_procfs.py`` covers the behaviour itself. +""" +import ast +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Trees that ship as the application. Tests and vendored code are excluded. +APP_TREES = ( + "app.py", + "core", + "routes", + "src", + "services", + "scripts", + "mcp_servers", + "integrations", + "companion", +) + +# Calls that enumerate a directory's entries. Reading one known file under +# /proc is a different shape — it fails per-file and callers already handle +# that — so only the enumerating calls are in scope here. +_SCAN_FUNCS = {"listdir", "scandir"} +_SCAN_METHODS = {"iterdir", "glob", "rglob"} + +# Calls that prove the scan is conditional on procfs being present. +_GUARD_FUNCS = {"has_procfs", "isdir", "is_dir", "exists"} + +PROCFS_ROOT = "/proc" + + +def _is_procfs_root(node: ast.AST) -> bool: + """True if ``node`` evaluates to the procfs root directory.""" + if isinstance(node, ast.Constant) and node.value == PROCFS_ROOT: + return True + # Path("/proc") + if isinstance(node, ast.Call): + return any(_is_procfs_root(arg) for arg in node.args) + # PROC_ROOT / _PROC_ROOT / proc_root / platform_compat.PROC_ROOT + name = None + if isinstance(node, ast.Name): + name = node.id + elif isinstance(node, ast.Attribute): + name = node.attr + return bool(name) and name.lower().lstrip("_") == "proc_root" + + +def _scan_sites(tree: ast.AST) -> list[ast.Call]: + sites = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Attribute) and func.attr in _SCAN_FUNCS: + if node.args and _is_procfs_root(node.args[0]): + sites.append(node) + elif isinstance(func, ast.Name) and func.id in _SCAN_FUNCS: + if node.args and _is_procfs_root(node.args[0]): + sites.append(node) + elif isinstance(func, ast.Attribute) and func.attr in _SCAN_METHODS: + if _is_procfs_root(func.value): + sites.append(node) + return sites + + +def _guard_lines(tree: ast.AST) -> list[int]: + """Line numbers of calls that test whether procfs is present.""" + lines = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "") + if name not in _GUARD_FUNCS: + continue + subject_is_procfs = ( + name in {"has_procfs", "is_wsl"} + or (isinstance(func, ast.Attribute) and _is_procfs_root(func.value)) + or any(_is_procfs_root(arg) for arg in node.args) + ) + if subject_is_procfs: + lines.append(node.lineno) + return lines + + +def _enclosing_scope(tree: ast.AST, node: ast.AST) -> ast.AST: + """Smallest function/module scope containing ``node``.""" + best = tree + for candidate in ast.walk(tree): + if not isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + end = getattr(candidate, "end_lineno", None) or candidate.lineno + if candidate.lineno <= node.lineno <= end: + if best is tree or candidate.lineno > best.lineno: + best = candidate + return best + + +def _app_python_files() -> list[Path]: + files = [] + for entry in APP_TREES: + target = REPO_ROOT / entry + if target.is_file(): + files.append(target) + elif target.is_dir(): + files.extend( + p for p in target.rglob("*.py") if "__pycache__" not in p.parts + ) + return sorted(files) + + +def _collect_sites() -> tuple[list[str], list[str]]: + """Return (guarded, unguarded) ``path:line`` labels for procfs scans.""" + guarded, unguarded = [], [] + for path in _app_python_files(): + try: + source = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + # Cheap pre-filter: a scan has to name the root somehow. + if PROCFS_ROOT not in source and "proc_root" not in source.lower(): + continue + try: + tree = ast.parse(source) + except SyntaxError: + continue + sites = _scan_sites(tree) + if not sites: + continue + guards = _guard_lines(tree) + for site in sites: + scope = _enclosing_scope(tree, site) + start = getattr(scope, "lineno", 0) + label = f"{path.relative_to(REPO_ROOT)}:{site.lineno}" + if any(start <= g < site.lineno for g in guards): + guarded.append(label) + else: + unguarded.append(label) + return guarded, unguarded + + +def test_every_procfs_scan_is_guarded_by_an_existence_check(): + _guarded, unguarded = _collect_sites() + assert not unguarded, ( + "procfs pid scans with no existence check in the enclosing function — " + "these raise FileNotFoundError on macOS and Windows: " + + ", ".join(unguarded) + ) + + +def test_the_guard_detector_still_sees_the_known_scans(): + """A rename must not silently turn the assertion above into a no-op. + + Lower bound, not an exact count: a new *guarded* scan is fine and should + not fail this. What must not happen is the detector going blind, which + shows up as sites disappearing. + """ + guarded, unguarded = _collect_sites() + found = set(guarded) | set(unguarded) + files = {label.rsplit(":", 1)[0] for label in found} + known = {"src/agent_tools/web_tools.py", "src/tools/cookbook.py"} + assert known <= files, ( + "the detector no longer sees a known procfs scan — check whether the " + f"root was renamed. Found: {sorted(found)}" + ) + assert len(found) >= 3, f"expected at least 3 procfs scans, found {sorted(found)}" From f8269a829f8f9cd0d916cf79f155cbb21e996d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 12:14:03 +0200 Subject: [PATCH 08/15] fix(discovery): cache a successful but empty Tailscale lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host cache was gated on the list being non-empty, so "queried fine, no eligible peers" looked exactly like a cold cache and every caller paid for another `tailscale status --json` — a subprocess with a 5s timeout. Gate on the timestamp instead. Failures still leave the timestamp unset, so a missing binary, a non-zero exit or unparseable output stays retryable rather than being cached for the full TTL. Ported from public `dev` (`affaee1e`, #6228 upstream), with its test. --- src/model_discovery.py | 5 +- tests/test_tailscale_discovery_cache.py | 69 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/test_tailscale_discovery_cache.py diff --git a/src/model_discovery.py b/src/model_discovery.py index 4d67502c5..116951f9d 100644 --- a/src/model_discovery.py +++ b/src/model_discovery.py @@ -38,7 +38,10 @@ def discover_tailscale_hosts() -> List[str]: global _hosts_cache, _hosts_cache_time now = time.time() - if _hosts_cache and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL: + # Gate on the timestamp, not the list: a successful query that found no + # eligible peers is a real answer, and testing the list's truthiness made + # that case re-run `tailscale status` (up to a 5s timeout) on every call. + if _hosts_cache_time and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL: return list(_hosts_cache) hosts = [] diff --git a/tests/test_tailscale_discovery_cache.py b/tests/test_tailscale_discovery_cache.py new file mode 100644 index 000000000..80c2f60d4 --- /dev/null +++ b/tests/test_tailscale_discovery_cache.py @@ -0,0 +1,69 @@ +"""A successful Tailscale query with no eligible hosts is still cached knowledge. + +`discover_tailscale_hosts` gated its cache on the host list being non-empty, so a +valid "nothing to see here" answer looked identical to a cold cache and every +caller paid for another `tailscale status --json` (up to a 5s timeout). Failures +stay uncached so a peer coming online is still picked up promptly. +""" + +import pytest + +from src import model_discovery + + +class _Result: + def __init__(self, returncode, stdout): + self.returncode = returncode + self.stdout = stdout + + +@pytest.fixture +def tailscale(monkeypatch): + """Count `tailscale status` invocations and start from a cold cache.""" + calls = [] + + def _record(result): + def _run(*_args, **_kwargs): + calls.append(1) + if isinstance(result, Exception): + raise result + return result + monkeypatch.setattr(model_discovery.subprocess, "run", _run) + return calls + + monkeypatch.setattr(model_discovery, "_hosts_cache", []) + monkeypatch.setattr(model_discovery, "_hosts_cache_time", 0) + return _record + + +def test_empty_but_successful_discovery_is_only_run_once(tailscale): + calls = tailscale(_Result(0, '{"Self":{},"Peer":{}}')) + + assert model_discovery.discover_tailscale_hosts() == [] + assert model_discovery.discover_tailscale_hosts() == [] + assert len(calls) == 1 + + +def test_nonempty_discovery_is_still_cached(tailscale): + calls = tailscale(_Result(0, '{"Self":{"TailscaleIPs":["100.1.1.1"]},"Peer":{}}')) + + assert model_discovery.discover_tailscale_hosts() == ["100.1.1.1"] + assert model_discovery.discover_tailscale_hosts() == ["100.1.1.1"] + assert len(calls) == 1 + + +@pytest.mark.parametrize( + "result", + [ + _Result(1, ""), # tailscale installed but logged out + _Result(0, "not json"), # unparseable output + FileNotFoundError("tailscale"), # not installed + ], + ids=["nonzero_exit", "bad_json", "not_installed"], +) +def test_failures_stay_retryable(tailscale, result): + calls = tailscale(result) + + assert model_discovery.discover_tailscale_hosts() == [] + assert model_discovery.discover_tailscale_hosts() == [] + assert len(calls) == 2 From 01b8ac5feadf9ec2cd88a0f9319081a6dd2d1980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 12:54:09 +0200 Subject: [PATCH 09/15] fix(mcp): reject malformed Args on Add MCP Server instead of defaulting to [] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `add_server` wrapped `json.loads(args)` in a bare `except` that fell back to `[]`, so an Args value that is not JSON — a bare path, which is what the form's placeholder invites people to type — registered the server and spawned the stdio subprocess with an empty argv. Nothing surfaced the loss: the POST returned 200 and the row persisted with `"args": []`. The route now returns 400 for an unparseable value, and also for valid JSON of the wrong shape: `args=5` reached `StdioServerParameters(args=5)` and raised an unhandled TypeError in the error formatter's `" ".join(...)`. Both form clients mirror the guard instead of leaving the user to read a 400 they cannot see. `settings.js` stops silently defaulting a bad Args value, and `admin.js` gains the same client-side parse check plus a `res.ok` branch so a server-side rejection is not reported as a connection failure. Ported from public `dev` (`9d5c0319`, #6215 upstream, fixing #6211), with its test. The `admin.js` hunks are inert on `lab` — `initMcpForm` early-returns because that form's markup is not in this build — and are carried anyway to keep the two lines from diverging further. --- routes/mcp/mcp_routes.py | 15 +- static/js/admin.js | 5 + static/js/settings.js | 6 +- tests/test_mcp_add_server_args_validation.py | 147 +++++++++++++++++++ 4 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 tests/test_mcp_add_server_args_validation.py diff --git a/routes/mcp/mcp_routes.py b/routes/mcp/mcp_routes.py index 94c83f8dd..0e2b30276 100644 --- a/routes/mcp/mcp_routes.py +++ b/routes/mcp/mcp_routes.py @@ -181,10 +181,17 @@ def setup_mcp_routes(mcp_manager: McpManager): if transport == "http" and not url: raise HTTPException(400, "url is required for HTTP transport") - # Parse JSON fields - try: - parsed_args = json.loads(args) if args else [] - except json.JSONDecodeError: + # Parse JSON fields. args is not defaulted on a parse failure: an + # unparseable value is silently discarded downstream (stdio spawns + # with an empty argv), so the caller must be told instead. + if args: + try: + parsed_args = json.loads(args) + except json.JSONDecodeError: + raise HTTPException(400, "args must be valid JSON, e.g. [\"-y\", \"pkg\"]") + if not isinstance(parsed_args, list): + raise HTTPException(400, "args must be a JSON array, e.g. [\"-y\", \"pkg\"]") + else: parsed_args = [] try: parsed_env = json.loads(env) if env else {} diff --git a/static/js/admin.js b/static/js/admin.js index 3e3463e9e..9a7ac8bf9 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -3142,6 +3142,7 @@ function initMcpForm() { if (transport === 'stdio' && !command) { msg.textContent = 'Command is required for stdio'; msg.className = 'admin-error'; return; } if (transport === 'sse' && !url) { msg.textContent = 'URL is required for SSE'; msg.className = 'admin-error'; return; } try { JSON.parse(env); } catch { msg.textContent = 'Env must be valid JSON'; msg.className = 'admin-error'; return; } + try { JSON.parse(args); } catch { msg.textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; msg.className = 'admin-error'; return; } const fd = new FormData(); fd.append('name', name); fd.append('transport', transport); fd.append('command', command); fd.append('args', args); fd.append('env', env); fd.append('url', url); // If preset has oauthFile config, send credentials for file generation @@ -3162,6 +3163,10 @@ function initMcpForm() { try { const res = await fetch('/api/mcp/servers', { method: 'POST', body: fd, credentials: 'same-origin' }); const data = await res.json(); + if (!res.ok) { + msg.textContent = data.detail || `Failed (${res.status})`; msg.className = 'admin-error'; + return; + } if (data.needs_oauth) { msg.innerHTML = `Added ${esc(name)} — Authorize with Google to connect`; msg.className = 'admin-success'; diff --git a/static/js/settings.js b/static/js/settings.js index d6c13558c..431545975 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -5102,7 +5102,11 @@ async function initUnifiedIntegrations() { fd.append('transport', transport); if (transport === 'stdio') { fd.append('command', el('uf-mcp-cmd').value); - let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {} + // Unlike env below, an unparseable args value is not silently + // defaulted: it would spawn the subprocess with an empty argv. + let args; + try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } + catch (_) { el('uf-mcp-msg').textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; return; } let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {} fd.append('args', args); fd.append('env', env); diff --git a/tests/test_mcp_add_server_args_validation.py b/tests/test_mcp_add_server_args_validation.py new file mode 100644 index 000000000..3550c85dc --- /dev/null +++ b/tests/test_mcp_add_server_args_validation.py @@ -0,0 +1,147 @@ +"""Regression test for issue #6211: a malformed Args value on the "Add MCP +Server" form must not be silently discarded into an empty argv. + +routes/mcp/mcp_routes.py's add_server() wrapped json.loads(args) in a bare +except that fell back to `[]`, so a non-JSON Args value registered the +server as "Connected" while forwarding no arguments to the spawned stdio +subprocess at all, with no error surfaced anywhere. +""" +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException + +from routes.mcp import mcp_routes + + +class _FakeSession: + """Stands in for core.database.SessionLocal(); add_server only adds+commits.""" + + def __init__(self): + self.added = [] + + def add(self, obj): + self.added.append(obj) + + def commit(self): + pass + + def close(self): + pass + + +def _add_server(monkeypatch): + """Register add_server on the shared module-level router and return the + freshly-added route's raw endpoint function, bypassing HTTP/Form parsing + (require_admin is the only other thing the function touches via `request`). + + Callers must pass every Form(...) parameter add_server reads past the args + check (url, oauth_file, oauth_config): calling the endpoint directly skips + FastAPI's dependency resolution, so an omitted one arrives as the Form + marker object itself rather than its declared default, and later code + (e.g. `if oauth_file:`) reads that marker as truthy. + """ + monkeypatch.setattr(mcp_routes, "require_admin", lambda request: None) + manager = MagicMock() + manager.connect_server = AsyncMock(return_value=True) + manager.get_server_status = MagicMock(return_value={"status": "connected", "tool_count": 1}) + router = mcp_routes.setup_mcp_routes(manager) + # setup_mcp_routes appends new APIRoute objects to the shared router on + # every call, so take the LAST "add_server" route: the one just registered + # with our fake manager, not an earlier registration from importing app.py. + route = [r for r in router.routes if getattr(r, "name", None) == "add_server"][-1] + return route.endpoint, manager + + +def test_add_server_rejects_malformed_args_instead_of_defaulting(monkeypatch): + add_server, manager = _add_server(monkeypatch) + monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: (_ for _ in ()).throw( + AssertionError("must not reach the DB when args is rejected"))) + + with pytest.raises(HTTPException) as exc: + asyncio.run(add_server( + request=None, + name="filesystem", + transport="stdio", + command="mcp-server-filesystem", + args="/app/data/jarvis-files", # the exact value from issue #6211 + env="{}", + url=None, + oauth_file=None, + oauth_config=None, + )) + + assert exc.value.status_code == 400 + manager.connect_server.assert_not_called() + + +def test_add_server_still_accepts_valid_json_args(monkeypatch): + add_server, manager = _add_server(monkeypatch) + fake_session = _FakeSession() + monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: fake_session) + + result = asyncio.run(add_server( + request=None, + name="filesystem", + transport="stdio", + command="mcp-server-filesystem", + args=json.dumps(["/app/data/jarvis-files"]), + env="{}", + url=None, + oauth_file=None, + oauth_config=None, + )) + + assert result["connected"] is True + manager.connect_server.assert_awaited_once() + assert manager.connect_server.call_args.kwargs["args"] == ["/app/data/jarvis-files"] + assert fake_session.added[0].args == json.dumps(["/app/data/jarvis-files"]) + + +def test_add_server_rejects_valid_json_args_that_is_not_a_list(monkeypatch): + """Valid JSON that is not a list (e.g. args=5) must not reach + StdioServerParameters(args=5), which raises an unhandled TypeError when + the error formatter later does " ".join([command, *args]).""" + add_server, manager = _add_server(monkeypatch) + monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: (_ for _ in ()).throw( + AssertionError("must not reach the DB when args has the wrong shape"))) + + with pytest.raises(HTTPException) as exc: + asyncio.run(add_server( + request=None, + name="filesystem", + transport="stdio", + command="mcp-server-filesystem", + args="5", + env="{}", + url=None, + oauth_file=None, + oauth_config=None, + )) + + assert exc.value.status_code == 400 + manager.connect_server.assert_not_called() + + +def test_add_server_still_defaults_empty_args_to_empty_list(monkeypatch): + """No behavior change for the common case of an empty Args field.""" + add_server, manager = _add_server(monkeypatch) + fake_session = _FakeSession() + monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: fake_session) + + result = asyncio.run(add_server( + request=None, + name="no-args-server", + transport="stdio", + command="some-command", + args="", + env="{}", + url=None, + oauth_file=None, + oauth_config=None, + )) + + assert result["connected"] is True + assert manager.connect_server.call_args.kwargs["args"] == [] From 24e428d9cb31b2ad67d7e7f744bb31325347b03d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 11:26:38 +0200 Subject: [PATCH 10/15] test(document): use platform-correct input in the rich color test test_rich_colors_follow_theme_and_undo_as_one_edit fails identically on every macOS run, timing out after 30s waiting for a span that never appears. It was written off as timing noise twice. It is not flaky - it is two Linux-only input conventions, and the product code is fine. Control+click: macOS delivers a Control-modified primary click as contextmenu, not click. Instrumenting the Lemon swatch shows the button receiving pointerdown, mousedown, contextmenu, pointerup, mouseup - and no click, so the menu item's handler never runs and no highlight is applied. Control was never meaningful here anyway; the palette item has no modifier behaviour. The two calls now use a plain click. Control+Z: the editor's undo accelerator is Cmd+Z on macOS. With the clicks fixed, both undo assertions still failed until the presses became ControlOrMeta+Z, which Playwright maps per platform. Both fixes are portable - a plain click and ControlOrMeta are unchanged on Linux, where this test already passes. pytest tests/test_document_rich_color_reset_and_contrast.py 2 passed in 2.11s (1 failed, 1 passed before) Not verified: Linux. I only have macOS here, so the claim that this stays green on CI rests on the modifier being a no-op there, not on a run. --- .../test_document_rich_color_reset_and_contrast.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_document_rich_color_reset_and_contrast.py b/tests/test_document_rich_color_reset_and_contrast.py index 64c237a7b..c74f3576b 100644 --- a/tests/test_document_rich_color_reset_and_contrast.py +++ b/tests/test_document_rich_color_reset_and_contrast.py @@ -20,6 +20,12 @@ def test_color_controls_have_theme_reset_and_split_palettes(): def test_rich_colors_follow_theme_and_undo_as_one_edit(): + # Two input conventions in here are platform-sensitive and must stay that + # way. Palette entries are opened with a plain click: on macOS a + # Control+click is delivered as `contextmenu`, so the menu item's `click` + # handler never runs and nothing is applied. Undo uses Playwright's + # `ControlOrMeta` alias because the editor's undo accelerator is Cmd+Z on + # macOS and Ctrl+Z everywhere else. script = r""" import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); @@ -58,24 +64,24 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit(): labels: [...document.querySelectorAll('.rich-color-palette-label')].map(item => item.textContent), reset: document.querySelector('.rich-color-reset')?.textContent.trim(), })); - await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Lemon' }).click({ modifiers: ['Control'] }); + await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Lemon' }).click(); const highlighted = await page.locator('#doc-email-richbody p').nth(0).locator('span').evaluate(span => ({ color: getComputedStyle(span).color, background: getComputedStyle(span).backgroundColor, })); - await page.locator('#doc-email-richbody').press('Control+z'); + await page.locator('#doc-email-richbody').press('ControlOrMeta+z'); const highlightUndone = await page.locator('#doc-email-richbody p').nth(0).innerHTML(); await selectParagraph(1); await openMenu('color'); - await page.locator('.rich-color-reset').click({ modifiers: ['Control'] }); + await page.locator('.rich-color-reset').click(); const defaultColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => ({ style: span.getAttribute('style'), color: getComputedStyle(span).color, })); await page.evaluate(() => document.documentElement.style.setProperty('--fg', '#88cc44')); const changedThemeColor = await page.locator('#doc-email-richbody p').nth(1).locator('span').evaluate(span => getComputedStyle(span).color); - await page.locator('#doc-email-richbody').press('Control+z'); + await page.locator('#doc-email-richbody').press('ControlOrMeta+z'); const colorUndone = await page.locator('#doc-email-richbody p').nth(1).innerHTML(); console.log(JSON.stringify({ palette, highlighted, highlightUndone, defaultColor, changedThemeColor, colorUndone })); await browser.close(); From eb98aa6dc2fba7735d63d868463712ce3cc7b0de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 11:26:38 +0200 Subject: [PATCH 11/15] test(media): stop asserting an optional ffmpeg webp encoder test_inspect_media_exports_final_decodable_frame_at_exact_duration exported to /workspace/final.webp and asserted exit_code == 0. WebP encoding is an ffmpeg build option, not something this project requires - Homebrew's macOS ffmpeg is built without it: ffmpeg -encoders | grep -ic webp -> 0 so the tool returns "ffmpeg still extraction failed: ... Encoder not found" and the test fails on the build rather than on the code under test. The test's subject is the final frame being decodable at the exact duration, which has nothing to do with the container. It now writes a PNG, and the two things the WebP path was implicitly covering are split out and each guarded on what is actually present: - test_inspect_media_exports_a_webp_still - skipped unless ffmpeg reports a webp encoder, and now asserts the file really is WebP rather than merely non-empty. - test_inspect_media_reports_a_missing_encoder_instead_of_crashing - runs only where the encoder is absent, pinning the behaviour that surfaced this: the tool reports ffmpeg's failure as a tool error and writes no partial file. The product code has the same assumption and I left it alone. inspect_media accepts any suffix in _IMAGE_SUFFIXES and hands the path to ffmpeg, so a .webp request on a build without libwebp fails with ffmpeg's own message. That is a poor message, not a crash or a corrupt file, and pre-validating the encoder list is a separate change. pytest tests/test_inspect_media_tool.py 68 passed, 2 skipped in 22.92s (1 failed, 66 passed, 1 skipped before) Not verified: the WebP success path. This machine has no webp encoder, so test_inspect_media_exports_a_webp_still skips here and has only been checked for collection, not for a passing run. --- tests/test_inspect_media_tool.py | 91 +++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/tests/test_inspect_media_tool.py b/tests/test_inspect_media_tool.py index f6ebc64ae..4190a2dc4 100644 --- a/tests/test_inspect_media_tool.py +++ b/tests/test_inspect_media_tool.py @@ -1,5 +1,6 @@ import asyncio import base64 +import functools import io import json from pathlib import Path @@ -20,6 +21,28 @@ from src.tool_execution import _active_workspace from src.tool_schemas import FUNCTION_TOOL_SCHEMAS +@functools.lru_cache(maxsize=None) +def _ffmpeg_has_encoder(name: str) -> bool: + """Whether the ffmpeg on PATH was built with the named encoder. + + Codec support is a build option, not something the project requires. The + Homebrew ffmpeg on macOS ships without libwebp, for instance, so a test + that asserts a successful `.webp` export there fails on the build rather + than on the tool. + """ + if not shutil.which("ffmpeg"): + return False + listed = subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-encoders"], + check=False, capture_output=True, text=True, + ) + return any( + line.split()[1:2] == [name] + for line in listed.stdout.splitlines() + if line.strip() + ) + + def test_media_timestamp_parser_accepts_units_and_four_field_timecodes(): assert _parse_seconds("0m", default=-1) == 0 assert _parse_seconds("30m", default=-1) == 1800 @@ -491,13 +514,16 @@ def test_inspect_media_exports_final_decodable_frame_at_exact_duration(tmp_path: result = asyncio.run(InspectMediaTool().execute(json.dumps({ "path": "/workspace/video.mp4", "timestamp": "end", - "output_path": "/workspace/final.webp", + # PNG, not WebP: this asserts that the *final* frame is decodable at + # the exact duration, so it must not also depend on an optional + # ffmpeg encoder. WebP export is covered separately below. + "output_path": "/workspace/final.png", }), {})) finally: _active_workspace.reset(token) assert result["exit_code"] == 0, result - assert (tmp_path / "final.webp").stat().st_size > 0 + assert (tmp_path / "final.png").stat().st_size > 0 token = _active_workspace.set(str(tmp_path)) try: @@ -514,6 +540,67 @@ def test_inspect_media_exports_final_decodable_frame_at_exact_duration(tmp_path: assert Image.open(io.BytesIO(base64.b64decode(high_detail["images"][0]["data"]))).size == (768, 432) +@pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg required") +@pytest.mark.skipif(not _ffmpeg_has_encoder("webp"), reason="ffmpeg built without a webp encoder") +def test_inspect_media_exports_a_webp_still(tmp_path: Path): + """A `.webp` output_path is passed straight through to ffmpeg. + + Guarded on the encoder rather than asserted unconditionally: WebP is a + build option (Homebrew's macOS ffmpeg omits it) and the project does not + require it. When the encoder is missing the tool reports ffmpeg's failure + with `exit_code` 1, which is covered by + `test_inspect_media_reports_a_missing_encoder_instead_of_crashing`. + """ + video = tmp_path / "video.mp4" + subprocess.run([ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", + "-i", "testsrc2=size=320x180:rate=4:duration=2", "-pix_fmt", "yuv420p", + "-y", str(video), + ], check=True) + token = _active_workspace.set(str(tmp_path)) + try: + result = asyncio.run(InspectMediaTool().execute(json.dumps({ + "path": "/workspace/video.mp4", + "timestamp": "end", + "output_path": "/workspace/final.webp", + }), {})) + finally: + _active_workspace.reset(token) + + assert result["exit_code"] == 0, result + assert Image.open(tmp_path / "final.webp").format == "WEBP" + + +@pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg required") +@pytest.mark.skipif(_ffmpeg_has_encoder("webp"), reason="needs an ffmpeg built without webp") +def test_inspect_media_reports_a_missing_encoder_instead_of_crashing(tmp_path: Path): + """An export in a format this ffmpeg cannot encode fails as a tool error. + + The tool does not probe the encoder list, so the only contract it can keep + is to surface ffmpeg's own failure rather than raise or write a truncated + file. Asserted only on builds that actually lack the encoder. + """ + video = tmp_path / "video.mp4" + subprocess.run([ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", + "-i", "testsrc2=size=320x180:rate=4:duration=2", "-pix_fmt", "yuv420p", + "-y", str(video), + ], check=True) + token = _active_workspace.set(str(tmp_path)) + try: + result = asyncio.run(InspectMediaTool().execute(json.dumps({ + "path": "/workspace/video.mp4", + "timestamp": "end", + "output_path": "/workspace/final.webp", + }), {})) + finally: + _active_workspace.reset(token) + + assert result["exit_code"] == 1 + assert "ffmpeg still extraction failed" in result["error"] + assert not (tmp_path / "final.webp").exists() + + @pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg required") def test_inspect_media_rejects_ambiguous_multi_frame_single_image_export(tmp_path: Path): video = tmp_path / "video.mp4" From fba6f73260226d07900b7598e287a5724b83d669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 11:26:54 +0200 Subject: [PATCH 12/15] test: fail the test that leaks a bare src/core module stub #25 fixed two sys.modules writes in test_auth_regressions.py that left empty stub modules behind for the rest of the session, breaking 23 tests under one collection order while the full suite stayed green. The class is wider than that file, and an audit is the wrong answer to it: nothing stops the next one, and the failure it causes lands on an unrelated test in a different file. So this is a guard instead. An autouse fixture in the root conftest snapshots which src.* / core.* names are bound to a bare ModuleType, and fails any test that adds one. "Bare" is the same test the clear_fake_* helpers already use - a plain types.ModuleType with no on-disk __file__. MagicMock stand-ins are out of scope: they answer every attribute, so they fail at the point of use rather than silently, and several files install them deliberately. Three details that matter: - It lives in the root conftest, so it is set up before any test-module fixture and torn down after all of them. A stub a test's own teardown removes is not reported. - It drops the leaked entries as well as reporting them, so the failure stays on the test that introduced it instead of cascading through the rest of the run. - It only reports stubs added during the test. Import state the session starts with, including this conftest's own src.database stub, is left alone. It found one beyond #25 on the first full run: _stub_heavy in test_scheduler_restart_doublefire.py leaks the same five src.* modules as the test #25 fixed, via sys.modules.setdefault. It already receives monkeypatch, so the fix is to register through it. Fixed here because the guard has to land green. Full suite, macOS, default collection order: this branch 10655 passed, 6 failed, 6 skipped 406s lab 10655 passed, 6 failed, 6 skipped 371s Same six either way, which is the point - none of this is visible in the default order. Four are pre-existing macOS environment failures: test_glob_confined_e2e and the two test_code_nav_tools document cases resolve /tmp to /private/tmp, and test_real_socket_falls_back_from_dead_first_to_live_second is connect-refused timing on real sockets. The other two are the rich-colour and ffmpeg items from the same ledger, fixed on their own branches. Not verified: Linux, and any collection order other than the default. The guard is order-independent by construction - it compares before and after within a single test - but I have only run the default order. --- tests/conftest.py | 40 ++++++++++++++++++++++ tests/helpers/import_state.py | 31 +++++++++++++++++ tests/test_scheduler_restart_doublefire.py | 15 ++++++-- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5fcf02113..97e249b41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -159,3 +159,43 @@ def _serve_test_static(): os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = previous_origin server.shutdown() server.server_close() + + +@pytest.fixture(autouse=True) +def _no_leaked_module_stubs(): + """Fail the test that leaves a bare ``src.*``/``core.*`` stub behind. + + Several test modules install empty stand-in modules so an import-heavy + production module can be loaded under the mocks above. When one of those + writes is not undone, the stub stays in ``sys.modules`` for the rest of the + session and every later test that imports the real module silently gets an + empty one instead. The suite still passes as a whole, because the victims + usually run before the leak; it only breaks under a different collection + order, which is why this class of bug reaches CI green. + + This fixture is declared in the root conftest, so it is set up before any + test-module fixture and torn down after all of them — a stub that a test's + own teardown removes is not reported. The leaked entries are dropped here + as well as reported, so the failure stays attributed to the test that + introduced it instead of cascading into the rest of the run. + + Bare stubs present before the test starts are ignored: this guards against + new leaks, it does not police import state the session began with. + """ + from tests.helpers.import_state import bare_module_stubs, clear_module + + before = bare_module_stubs() + yield + leaked = sorted(bare_module_stubs() - before) + if not leaked: + return + for name in leaked: + clear_module(name) + pytest.fail( + "test left bare module stub(s) in sys.modules: " + + ", ".join(leaked) + + ". Register the stub through monkeypatch.setitem(sys.modules, ...) " + "or tests.helpers.import_state.preserve_import_state so it is undone " + "at teardown.", + pytrace=False, + ) diff --git a/tests/helpers/import_state.py b/tests/helpers/import_state.py index 0eea62d9d..f58c86c37 100644 --- a/tests/helpers/import_state.py +++ b/tests/helpers/import_state.py @@ -31,6 +31,7 @@ safe for callers that pass both a parent package and a child module. """ import sys +import types from contextlib import contextmanager _ABSENT = object() @@ -167,3 +168,33 @@ def preserve_import_state(*module_names): # Phase 2: restore all parent-package attributes. for name, (_, saved_attr) in saved.items(): _restore_parent_attr(name, saved_attr) + + +# Names under these prefixes are the ones a leaked stub actually breaks: a +# later test doing ``import src.x`` or ``import core.x`` silently gets the +# empty stub instead of the real module. +_GUARDED_PREFIXES = ("src.", "core.") + + +def bare_module_stubs(): + """Return the ``src.*``/``core.*`` names currently bound to a bare stub. + + A bare stub is a plain :class:`types.ModuleType` with no on-disk + ``__file__`` — the object ``types.ModuleType(name)`` produces. That is the + same "is this a fake?" test the ``clear_fake_*`` helpers above use, so a + module imported from disk is never reported. + + ``MagicMock`` stand-ins are deliberately out of scope: they answer every + attribute, so they fail loudly at use rather than silently, and several + test modules install them on purpose. + """ + found = set() + for name, mod in list(sys.modules.items()): + if not name.startswith(_GUARDED_PREFIXES): + continue + if type(mod) is not types.ModuleType: + continue + if getattr(mod, "__file__", None): + continue + found.add(name) + return found diff --git a/tests/test_scheduler_restart_doublefire.py b/tests/test_scheduler_restart_doublefire.py index 9f0c87372..ca90c55bc 100644 --- a/tests/test_scheduler_restart_doublefire.py +++ b/tests/test_scheduler_restart_doublefire.py @@ -21,12 +21,21 @@ def _test_utcnow(): return datetime.now(timezone.utc).replace(tzinfo=None) -def _stub_heavy(): +def _stub_heavy(monkeypatch): + """Stub the heavy modules ``task_scheduler`` imports, for this test only. + + Registered through ``monkeypatch.setitem`` so every entry is removed at + teardown. A bare ``sys.modules[name] = ...`` leaves an empty module behind + for the rest of the session, and any later test that imports the real one + silently gets the stub instead - a failure that only shows up under a + different collection order. + """ for name in [ "src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver", "src.agent_loop", "src.session_manager", ]: - sys.modules.setdefault(name, types.ModuleType(name)) + if name not in sys.modules: + monkeypatch.setitem(sys.modules, name, types.ModuleType(name)) def _setup_isolated_db(): @@ -74,7 +83,7 @@ def test_scheduler_utcnow_preserves_naive_utc_contract(): def _drive_scheduler(monkeypatch, pre_start_setup=None): """Build a TaskScheduler bypassing __init__ and run start() + two polls.""" - _stub_heavy() + _stub_heavy(monkeypatch) cd, ScheduledTask, TaskRun = _setup_isolated_db() from src.task_scheduler import TaskScheduler From 5f18767528bd56ae8c9ab74f0d29f9a2b8f811d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 17:15:52 +0200 Subject: [PATCH 13/15] fix(mcp): show the route's rejection reason on the Integrations form too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route now answers 400 with a message naming the expected shape. admin.js was taught to print `data.detail`; the Unified Integrations form in settings.js still printed `Failed (400)` and dropped it. That gap is exactly where the new validation bites. The client-side JSON.parse guard added alongside it catches unparseable input, so the only values that reach the route's 400 are ones that parse but are not a list — `"npx"`, `{}`, `null` — and for those the status code alone tells the user nothing about what is wrong with what they typed. Adds source-level coverage for both forms; the PR changed two JS files with no test on either. --- static/js/settings.js | 7 ++++- tests/test_mcp_add_server_args_validation.py | 27 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/static/js/settings.js b/static/js/settings.js index 431545975..c59b751e1 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -5128,7 +5128,12 @@ async function initUnifiedIntegrations() { } else if (r.ok) { el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList(); } else { - el('uf-mcp-msg').textContent = `Failed (${r.status})`; + // Surface the server's reason. The Args validation above rejects + // unparseable JSON, but `"x"` and `{}` parse and are refused by + // routes/mcp/mcp_routes.py with a message naming the expected + // shape; a bare status code sends the user looking in the wrong + // place. Matches what admin.js shows for the same endpoint. + el('uf-mcp-msg').textContent = data.detail || `Failed (${r.status})`; } } catch (_) { el('uf-mcp-msg').textContent = 'Failed'; } finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; } diff --git a/tests/test_mcp_add_server_args_validation.py b/tests/test_mcp_add_server_args_validation.py index 3550c85dc..7550a99a0 100644 --- a/tests/test_mcp_add_server_args_validation.py +++ b/tests/test_mcp_add_server_args_validation.py @@ -8,6 +8,7 @@ subprocess at all, with no error surfaced anywhere. """ import asyncio import json +from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest @@ -145,3 +146,29 @@ def test_add_server_still_defaults_empty_args_to_empty_list(monkeypatch): assert result["connected"] is True assert manager.connect_server.call_args.kwargs["args"] == [] + + +# --------------------------------------------------------------------------- +# The two forms that post to this endpoint +# --------------------------------------------------------------------------- +# +# The route now answers 400 with a message naming the expected shape. That is +# only worth anything if the form the user is looking at prints it, and the two +# forms did not agree: admin.js reads `data.detail`, settings.js printed the +# bare status code. Read as source, because the artifact under test is the +# string in the file and neither form is reachable without a browser. + +_REPO = Path(__file__).resolve().parents[1] + + +def test_admin_form_reports_the_reason_the_route_gave(): + source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8") + assert "msg.textContent = data.detail || `Failed (${res.status})`;" in source + + +def test_unified_integrations_form_reports_the_reason_the_route_gave(): + source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8") + assert ( + "el('uf-mcp-msg').textContent = data.detail || `Failed (${r.status})`;" + in source + ), "settings.js drops the route's message and prints only the status code" From 97384053106564fb4b8abbce2e249f4c58505844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 17:35:06 +0200 Subject: [PATCH 14/15] fix(tests): bind the docker-socket fixtures somewhere sun_path fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four more tests in the same family as the /tmp ones this change already fixes, and they hide for the same reason: the failure depends on how long $TMPDIR happens to be. tests/test_shell_routes.py::TestHostDockerAccess (three) and tests/test_cookbook_docker_access.py::test_container_opt_in_with_unix_ socket_is_allowed each bind an AF_UNIX socket at tmp_path/"docker.sock". macOS gives sun_path 104 bytes including the terminator. pytest roots tmp_path at $TMPDIR, which on a stock Mac is a 49-character /var/folders/<2>/<30>/T/; add pytest-of-/pytest-/ and the test's own name and the bind path is 115 bytes before the filename. OSError: AF_UNIX path too long Linux allows 108 and roots $TMPDIR at /tmp, so CI never sees it. Under a shortened $TMPDIR the path lands at exactly 103 and passes — until pytest's run counter reaches two digits and it becomes 104. That is why the ledger's counts did not include these: they were measured somewhere the path fit. Adds tests/helpers/unix_sockets.bound_unix_socket, which binds under a short directory and asserts the length before it tries, so the next socket fixture fails with a sentence rather than an errno. Records the trap in KNOWN_FAILURES.md along with the instruction to re-measure with the default $TMPDIR. --- tests/KNOWN_FAILURES.md | 39 ++++++++++++++++++++--- tests/helpers/unix_sockets.py | 46 ++++++++++++++++++++++++++++ tests/test_cookbook_docker_access.py | 11 +++---- tests/test_shell_routes.py | 17 ++++------ 4 files changed, 91 insertions(+), 22 deletions(-) create mode 100644 tests/helpers/unix_sockets.py diff --git a/tests/KNOWN_FAILURES.md b/tests/KNOWN_FAILURES.md index 4af06c7dc..aa7f22fc7 100644 --- a/tests/KNOWN_FAILURES.md +++ b/tests/KNOWN_FAILURES.md @@ -6,10 +6,12 @@ uninterpretable: you cannot tell "you broke something" from "you are on a Mac", so the usual result is either chasing a non-bug or ignoring a real one. This is that list. It is a record of observation, not a permission slip: a test -here is still a test that does not pass, and three of the six below are -defects someone should fix. +here is still a test that does not pass, and the three that remain below are +all still worth someone's time. -Last measured: `lab @ c499c01b` plus the fixes in this change, macOS 15 on Apple Silicon, Python 3.11. +Last measured: `lab @ c499c01b` plus the fixes in this change, macOS 15 on +Apple Silicon, Python 3.11, with the **default** `$TMPDIR` — see the socket +entry below for why that qualifier is load-bearing. ``` 3 failed, 10658 passed, 6 skipped @@ -38,9 +40,9 @@ including one holding real data. Miss `npm ci` and roughly 36 browser tests fail on `Cannot find package 'playwright'`. That is not a regression, it is the missing install. -## The three +## The three that remain, and the seven that no longer do -### Test bugs: fixed +### Test bugs: comparing an unresolved path against a resolved one Three failures compared an unresolved `/tmp` path against a resolved `/private/tmp` one, and are fixed rather than listed: @@ -57,6 +59,33 @@ Both now resolve consistently. They are recorded here because the shape recurs: on macOS, mixing a resolved and an unresolved temp path is a test bug that looks like a platform failure. +### Test bugs: a temp path too long to bind a socket to + +Four more, same family, invisible unless `$TMPDIR` is long enough: + +- `tests/test_shell_routes.py::TestHostDockerAccess` (three tests) +- `tests/test_cookbook_docker_access.py::test_container_opt_in_with_unix_socket_is_allowed` + +``` +OSError: AF_UNIX path too long +``` + +Each bound an `AF_UNIX` socket at `tmp_path / "docker.sock"`. macOS gives +`sun_path` 104 bytes including the terminator, and pytest's `tmp_path` is +rooted at `$TMPDIR`, which on a stock Mac is a 49-character +`/var/folders/<2>/<30>/T/`. Add `pytest-of-/pytest-/` and the test's +own name and the bind path is 115 bytes before the filename. + +This is why the counts above depend on where you run from: under a shortened +`$TMPDIR` the path lands at 103 and the tests pass, and it tips over the moment +pytest's run counter reaches two digits. Linux allows 108 bytes and roots +`$TMPDIR` at `/tmp`, so it never bites there and CI stays green. + +They now bind through `tests/helpers/unix_sockets.bound_unix_socket`, which +puts the socket under a short directory. **Measure with the default `$TMPDIR`** +— `env -u TMPDIR` or an explicit `/var/folders/...` — or this whole file +records a run nobody else has. + ### Optional dependency: ffmpeg without a WebP encoder - `tests/test_inspect_media_tool.py::test_inspect_media_exports_final_decodable_frame_at_exact_duration` diff --git a/tests/helpers/unix_sockets.py b/tests/helpers/unix_sockets.py new file mode 100644 index 000000000..c8ddcb2cc --- /dev/null +++ b/tests/helpers/unix_sockets.py @@ -0,0 +1,46 @@ +"""Bind an AF_UNIX socket at a path the kernel will actually accept. + +``sun_path`` is 104 bytes on macOS, terminator included, so a bind path longer +than 103 characters fails with ``OSError: AF_UNIX path too long``. pytest's +``tmp_path`` is rooted at ``$TMPDIR``, which on stock macOS is a 49-character +``/var/folders/<2>/<30>/T/`` path; adding ``pytest-of-/pytest-/`` and +the test's own (truncated) name spends the rest of the budget before the +filename is appended. + +That is why this reads as flaky rather than broken. Linux allows 108 bytes and +roots ``$TMPDIR`` at ``/tmp``, so it never bites there; on macOS whether it +bites depends on the length of ``$TMPDIR``, the test's name, and how many +digits pytest's run counter is currently using. A run under a shortened +``$TMPDIR`` passes, the same checkout under the default one does not. + +The path is resolved before it is handed back, for the same reason the rest of +this change resolves temp paths: on macOS ``/tmp`` is a symlink to +``/private/tmp``, and a test that binds one spelling and asserts on the other +is comparing two names for the same socket. +""" + +import os +import shutil +import socket +import tempfile +from contextlib import contextmanager + +# Short enough to leave room for the socket's own name under every platform's +# sun_path budget. A relative root would depend on the working directory. +_SHORT_ROOT = os.path.realpath(tempfile.gettempdir() if os.name == "nt" else "/tmp") + + +@contextmanager +def bound_unix_socket(name="docker.sock"): + """Yield the path of a listening AF_UNIX socket, cleaned up on exit.""" + directory = os.path.realpath(tempfile.mkdtemp(prefix="odysseus-sock-", dir=_SHORT_ROOT)) + path = os.path.join(directory, name) + if len(path) > 103: # pragma: no cover - guards the guard + raise AssertionError(f"socket path is {len(path)} bytes, over the limit: {path}") + sock = socket.socket(socket.AF_UNIX) + try: + sock.bind(path) + yield path + finally: + sock.close() + shutil.rmtree(directory, ignore_errors=True) diff --git a/tests/test_cookbook_docker_access.py b/tests/test_cookbook_docker_access.py index 47110b04d..5acf49e0a 100644 --- a/tests/test_cookbook_docker_access.py +++ b/tests/test_cookbook_docker_access.py @@ -1,4 +1,3 @@ -import socket from unittest.mock import AsyncMock import pytest @@ -9,6 +8,7 @@ from starlette.requests import Request import routes.cookbook_routes as cookbook_routes from routes.cookbook_helpers import ServeRequest, _validate_serve_cmd from src.host_docker_access import HOST_DOCKER_ACCESS_HINT +from tests.helpers.unix_sockets import bound_unix_socket def _model_serve_endpoint(): @@ -57,19 +57,18 @@ async def test_container_cli_only_is_rejected(monkeypatch, tmp_path): @pytest.mark.asyncio -async def test_container_opt_in_with_unix_socket_is_allowed(monkeypatch, tmp_path): +async def test_container_opt_in_with_unix_socket_is_allowed(monkeypatch): monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker") - socket_path = tmp_path / "docker.sock" - with socket.socket(socket.AF_UNIX) as unix_socket: - unix_socket.bind(str(socket_path)) + # Not tmp_path: binding under $TMPDIR overruns sun_path on macOS. + with bound_unix_socket() as socket_path: available = await cookbook_routes._binary_available( "docker", None, None, in_container=True, environ={"ODYSSEUS_ENABLE_HOST_DOCKER": "true"}, - socket_path=str(socket_path), + socket_path=socket_path, ) assert available is True diff --git a/tests/test_shell_routes.py b/tests/test_shell_routes.py index 6ee7bbe15..072a13d96 100644 --- a/tests/test_shell_routes.py +++ b/tests/test_shell_routes.py @@ -5,7 +5,6 @@ import importlib import importlib.util import json import os -import socket import sys from pathlib import Path from types import SimpleNamespace @@ -28,6 +27,7 @@ from routes.shell_routes import ( _venv_activate_prefix, DOCKER_IN_CONTAINER_HINT, ) +from tests.helpers.unix_sockets import bound_unix_socket def test_shell_routes_import_without_posix_pty_modules(monkeypatch): @@ -294,30 +294,25 @@ class TestHostDockerAccess: def test_socket_without_explicit_opt_in_is_disabled( self, monkeypatch, - tmp_path, flag, ): - socket_path = tmp_path / "docker.sock" - with socket.socket(socket.AF_UNIX) as unix_socket: - unix_socket.bind(str(socket_path)) + # Not tmp_path: binding under $TMPDIR overruns sun_path on macOS. + with bound_unix_socket() as socket_path: if flag is None: monkeypatch.delenv("ODYSSEUS_ENABLE_HOST_DOCKER", raising=False) else: monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", flag) - assert _host_docker_access_enabled(str(socket_path)) is False + assert _host_docker_access_enabled(socket_path) is False def test_explicit_opt_in_with_unix_socket_is_enabled( self, monkeypatch, - tmp_path, ): - socket_path = tmp_path / "docker.sock" - with socket.socket(socket.AF_UNIX) as unix_socket: - unix_socket.bind(str(socket_path)) + with bound_unix_socket() as socket_path: monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true") - assert _host_docker_access_enabled(str(socket_path)) is True + assert _host_docker_access_enabled(socket_path) is True class TestPackageProbeStatus: From 56484df7378c7df28ee5abf484478ee714968573 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:16:29 +0100 Subject: [PATCH 15/15] test(media): detect ffmpeg encoders by codec alias --- tests/test_inspect_media_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_inspect_media_tool.py b/tests/test_inspect_media_tool.py index 4190a2dc4..b00a41baf 100644 --- a/tests/test_inspect_media_tool.py +++ b/tests/test_inspect_media_tool.py @@ -37,7 +37,7 @@ def _ffmpeg_has_encoder(name: str) -> bool: check=False, capture_output=True, text=True, ) return any( - line.split()[1:2] == [name] + line.split()[1:2] == [name] or f"(codec {name})" in line for line in listed.stdout.splitlines() if line.strip() )