From 9b2185f1ccb0afa0c27381ac1fb87b06108cff58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Fri, 25 Sep 2026 10:45:58 +0200 Subject: [PATCH] test(harness): bind the static test server to an ephemeral port The session-scoped autouse fixture bound 127.0.0.1:7011 and raised when the port was taken. Because it is autouse, that raise errored every collected test rather than the browser ones: a second worktree running its own suite produced 10,612 errors, none of them about the code under test. 7011 is also the application's own default port, so the suite could not run while a local instance was up. Bind port 0 instead and publish the resulting origin as ODYSSEUS_TEST_STATIC_ORIGIN. The browser tests shell out to node, which inherits the environment, so the snippets read process.env rather than hardcoding a port. ODYSSEUS_TEST_STATIC_PORT still pins one when something outside pytest has to reach the server; that is the only path that can now fail to bind, and it fails with a message that says so. Two concurrent full runs from one checkout now both pass. Only the docx export snippet is an rf-string, so it is the only one whose JS braces needed doubling. --- tests/conftest.py | 44 ++++++++++----- tests/test_doc_library_open_orphaned.py | 2 +- tests/test_document_history_controls.py | 2 +- tests/test_document_library_mobile_footer.py | 2 +- tests/test_document_outline.py | 2 +- tests/test_document_rich_checklist_enter.py | 2 +- ..._document_rich_color_reset_and_contrast.py | 2 +- tests/test_document_rich_docx_export.py | 4 +- tests/test_document_rich_find_boundaries.py | 2 +- .../test_document_rich_font_color_controls.py | 2 +- tests/test_document_rich_heading_enter.py | 4 +- tests/test_document_rich_image_caption.py | 2 +- tests/test_document_rich_input_rules.py | 2 +- .../test_document_rich_keyboard_shortcuts.py | 2 +- tests/test_document_rich_selection_toolbar.py | 2 +- tests/test_document_rich_slash_menu.py | 2 +- tests/test_document_rich_smart_link_paste.py | 2 +- tests/test_document_rich_structure_tools.py | 2 +- ...test_document_rich_table_cell_alignment.py | 2 +- ...document_rich_table_header_preservation.py | 2 +- tests/test_document_rich_table_headers.py | 2 +- tests/test_document_rich_table_merge_split.py | 2 +- tests/test_document_rich_table_tab_history.py | 2 +- tests/test_document_rich_toolbar_menus.py | 6 +-- tests/test_document_save_shortcut.py | 2 +- tests/test_document_save_status.py | 2 +- tests/test_document_toolbar_order.py | 2 +- tests/test_escape_inner_layers.py | 4 +- tests/test_static_test_server.py | 53 +++++++++++++++++++ 29 files changed, 116 insertions(+), 45 deletions(-) create mode 100644 tests/test_static_test_server.py diff --git a/tests/conftest.py b/tests/conftest.py index 3dab8dbd1..5fcf02113 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -98,22 +98,23 @@ def pytest_collection_modifyitems(config, items): @pytest.fixture(scope="session", autouse=True) def _serve_test_static(): - """Ensure static assets are available on loopback port 7011 for browser integration tests.""" - import socket + """Serve static assets on loopback for the browser integration tests. + + Binds an ephemeral port so several worktrees can run their own suite at the + same time, and publishes the resulting origin through + ``ODYSSEUS_TEST_STATIC_ORIGIN``. The browser tests shell out to node, which + inherits the environment, so the snippets read the origin from + ``process.env`` instead of hardcoding a port. + + Set ``ODYSSEUS_TEST_STATIC_PORT`` to pin a specific port when something + outside pytest has to reach this server. + """ + import os import threading import http.server import socketserver from pathlib import Path - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - is_bound = (sock.connect_ex(("127.0.0.1", 7011)) == 0) - finally: - sock.close() - - if is_bound: - raise RuntimeError("port 7011 is already in use; browser tests require this worktree's static server") - root_dir = Path(__file__).resolve().parent.parent class _Handler(http.server.SimpleHTTPRequestHandler): @@ -133,11 +134,28 @@ def _serve_test_static(): class _Server(socketserver.TCPServer): allow_reuse_address = True - server = _Server(("127.0.0.1", 7011), _Handler) + requested = int(os.environ.get("ODYSSEUS_TEST_STATIC_PORT") or 0) + try: + server = _Server(("127.0.0.1", requested), _Handler) + except OSError as exc: + # Port 0 cannot collide, so this only fires for an explicit pin. + raise RuntimeError( + f"ODYSSEUS_TEST_STATIC_PORT={requested} is not bindable; unset it to " + "let the browser tests pick an ephemeral port" + ) from exc + + origin = f"http://127.0.0.1:{server.server_address[1]}" + previous_origin = os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN") + os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = origin + thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: - yield + yield origin finally: + if previous_origin is None: + os.environ.pop("ODYSSEUS_TEST_STATIC_ORIGIN", None) + else: + os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] = previous_origin server.shutdown() server.server_close() diff --git a/tests/test_doc_library_open_orphaned.py b/tests/test_doc_library_open_orphaned.py index 74c806938..58e52d673 100644 --- a/tests/test_doc_library_open_orphaned.py +++ b/tests/test_doc_library_open_orphaned.py @@ -95,7 +95,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); const state = await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&mobile-library-open-test=1'); diff --git a/tests/test_document_history_controls.py b/tests/test_document_history_controls.py index ed966b11b..bd4b297ac 100644 --- a/tests/test_document_history_controls.py +++ b/tests/test_document_history_controls.py @@ -23,7 +23,7 @@ def test_mobile_rich_text_history_state_and_document_switch(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&history-controls-test=1'); diff --git a/tests/test_document_library_mobile_footer.py b/tests/test_document_library_mobile_footer.py index e690ecc30..f5a9921ae 100644 --- a/tests/test_document_library_mobile_footer.py +++ b/tests/test_document_library_mobile_footer.py @@ -52,7 +52,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); const state = await page.evaluate(async () => { let currentSession = 'current-chat'; diff --git a/tests/test_document_outline.py b/tests/test_document_outline.py index 19f99c547..4ab98d680 100644 --- a/tests/test_document_outline.py +++ b/tests/test_document_outline.py @@ -70,7 +70,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&outline-test=1'); diff --git a/tests/test_document_rich_checklist_enter.py b/tests/test_document_rich_checklist_enter.py index ddb188a5f..b36bdb67b 100644 --- a/tests/test_document_rich_checklist_enter.py +++ b/tests/test_document_rich_checklist_enter.py @@ -25,7 +25,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&checklist-enter-test=1'); diff --git a/tests/test_document_rich_color_reset_and_contrast.py b/tests/test_document_rich_color_reset_and_contrast.py index d0cad9c75..64c237a7b 100644 --- a/tests/test_document_rich_color_reset_and_contrast.py +++ b/tests/test_document_rich_color_reset_and_contrast.py @@ -24,7 +24,7 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent(` diff --git a/tests/test_document_rich_docx_export.py b/tests/test_document_rich_docx_export.py index e9f4d8f93..f5b6d5c6b 100644 --- a/tests/test_document_rich_docx_export.py +++ b/tests/test_document_rich_docx_export.py @@ -58,7 +58,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml(): viewport: {{ width: 900, height: 700 }}, acceptDownloads: true, }}); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`); await page.route('**/api/upload/docx-image-test', route => route.fulfill({{ status: 200, contentType: 'image/png', @@ -157,7 +157,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting(): viewport: {{ width: 900, height: 700 }}, acceptDownloads: true, }}); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => {{ const mod = await import('/static/js/document.js?v=20260831richtexttools91&markdown-docx-export-test=1'); diff --git a/tests/test_document_rich_find_boundaries.py b/tests/test_document_rich_find_boundaries.py index cff7beb8d..c4e293ad8 100644 --- a/tests/test_document_rich_find_boundaries.py +++ b/tests/test_document_rich_find_boundaries.py @@ -24,7 +24,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&find-boundaries-test=1'); diff --git a/tests/test_document_rich_font_color_controls.py b/tests/test_document_rich_font_color_controls.py index 7db242e97..f85466ae3 100644 --- a/tests/test_document_rich_font_color_controls.py +++ b/tests/test_document_rich_font_color_controls.py @@ -91,7 +91,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('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async suffix => { const mod = await import(`/static/js/document.js?v=20260831richtexttools91&font-color=${suffix}`); diff --git a/tests/test_document_rich_heading_enter.py b/tests/test_document_rich_heading_enter.py index 4d91a2c79..d2df9ca89 100644 --- a/tests/test_document_rich_heading_enter.py +++ b/tests/test_document_rich_heading_enter.py @@ -28,7 +28,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&heading-enter=1'); @@ -96,7 +96,7 @@ def test_heading_enter_preserves_shift_middle_and_empty_heading_semantics(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&heading-enter-boundaries=1'); diff --git a/tests/test_document_rich_image_caption.py b/tests/test_document_rich_image_caption.py index 65d504bd1..be094d45b 100644 --- a/tests/test_document_rich_image_caption.py +++ b/tests/test_document_rich_image_caption.py @@ -33,7 +33,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&image-caption=1'); diff --git a/tests/test_document_rich_input_rules.py b/tests/test_document_rich_input_rules.py index 07a8e527b..8d22a1404 100644 --- a/tests/test_document_rich_input_rules.py +++ b/tests/test_document_rich_input_rules.py @@ -25,7 +25,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&input-rules-test=1'); diff --git a/tests/test_document_rich_keyboard_shortcuts.py b/tests/test_document_rich_keyboard_shortcuts.py index 532a58b5c..89a09ec27 100644 --- a/tests/test_document_rich_keyboard_shortcuts.py +++ b/tests/test_document_rich_keyboard_shortcuts.py @@ -15,7 +15,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('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async suffix => { const mod = await import(`/static/js/document.js?v=20260831richtexttools91&keyboard-shortcuts=${suffix}`); diff --git a/tests/test_document_rich_selection_toolbar.py b/tests/test_document_rich_selection_toolbar.py index 13c1020b9..306d45cce 100644 --- a/tests/test_document_rich_selection_toolbar.py +++ b/tests/test_document_rich_selection_toolbar.py @@ -25,7 +25,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&selection-toolbar-test=1'); diff --git a/tests/test_document_rich_slash_menu.py b/tests/test_document_rich_slash_menu.py index c7a2fafea..7c191985d 100644 --- a/tests/test_document_rich_slash_menu.py +++ b/tests/test_document_rich_slash_menu.py @@ -40,7 +40,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentOutline.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&slash-menu-test=1'); diff --git a/tests/test_document_rich_smart_link_paste.py b/tests/test_document_rich_smart_link_paste.py index 0aeb7d9aa..8f8c675e7 100644 --- a/tests/test_document_rich_smart_link_paste.py +++ b/tests/test_document_rich_smart_link_paste.py @@ -13,7 +13,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&smart-link-paste=1'); diff --git a/tests/test_document_rich_structure_tools.py b/tests/test_document_rich_structure_tools.py index 912722b84..e6fd58922 100644 --- a/tests/test_document_rich_structure_tools.py +++ b/tests/test_document_rich_structure_tools.py @@ -43,7 +43,7 @@ def test_mobile_headings_page_break_history_and_persistence(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&structure-tools-test=1'); diff --git a/tests/test_document_rich_table_cell_alignment.py b/tests/test_document_rich_table_cell_alignment.py index 8edca1773..a410723e3 100644 --- a/tests/test_document_rich_table_cell_alignment.py +++ b/tests/test_document_rich_table_cell_alignment.py @@ -30,7 +30,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-cell-alignment=1'); diff --git a/tests/test_document_rich_table_header_preservation.py b/tests/test_document_rich_table_header_preservation.py index 5ae3af364..4c7c03626 100644 --- a/tests/test_document_rich_table_header_preservation.py +++ b/tests/test_document_rich_table_header_preservation.py @@ -35,7 +35,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-preservation-test=1'); diff --git a/tests/test_document_rich_table_headers.py b/tests/test_document_rich_table_headers.py index 4b98b46e1..cd77ba93c 100644 --- a/tests/test_document_rich_table_headers.py +++ b/tests/test_document_rich_table_headers.py @@ -39,7 +39,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-test=1'); diff --git a/tests/test_document_rich_table_merge_split.py b/tests/test_document_rich_table_merge_split.py index f8f0a0719..85bb04387 100644 --- a/tests/test_document_rich_table_merge_split.py +++ b/tests/test_document_rich_table_merge_split.py @@ -33,7 +33,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history( import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-merge-split=1'); diff --git a/tests/test_document_rich_table_tab_history.py b/tests/test_document_rich_table_tab_history.py index 7543b404f..abca1d02a 100644 --- a/tests/test_document_rich_table_tab_history.py +++ b/tests/test_document_rich_table_tab_history.py @@ -30,7 +30,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-tab-history-test=1'); diff --git a/tests/test_document_rich_toolbar_menus.py b/tests/test_document_rich_toolbar_menus.py index 658d1b83f..178820293 100644 --- a/tests/test_document_rich_toolbar_menus.py +++ b/tests/test_document_rich_toolbar_menus.py @@ -57,7 +57,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&toggle-test=1'); @@ -126,7 +126,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-menu-test=1'); @@ -241,7 +241,7 @@ def test_rich_toolbar_menus_track_live_formatting_values(): import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-state-test=1'); diff --git a/tests/test_document_save_shortcut.py b/tests/test_document_save_shortcut.py index 221d37d0d..0b1fe4f9b 100644 --- a/tests/test_document_save_shortcut.py +++ b/tests/test_document_save_shortcut.py @@ -37,7 +37,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status(): body: JSON.stringify({ version_count: 2 }), }); }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-shortcut-test=1'); diff --git a/tests/test_document_save_status.py b/tests/test_document_save_status.py index 4b0de54ab..f29698ff0 100644 --- a/tests/test_document_save_status.py +++ b/tests/test_document_save_status.py @@ -31,7 +31,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures(): }); active -= 1; }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-status-test=1'); diff --git a/tests/test_document_toolbar_order.py b/tests/test_document_toolbar_order.py index ddf6355ab..82e883380 100644 --- a/tests/test_document_toolbar_order.py +++ b/tests/test_document_toolbar_order.py @@ -37,7 +37,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('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async suffix => { const mod = await import(`/static/js/document.js?v=20260831richtexttools91&toolbar-order=${suffix}`); diff --git a/tests/test_escape_inner_layers.py b/tests/test_escape_inner_layers.py index e63d3fc6c..f7818178d 100644 --- a/tests/test_escape_inner_layers.py +++ b/tests/test_escape_inner_layers.py @@ -13,7 +13,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None: import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=escape-regression-1'); @@ -75,7 +75,7 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None: import { chromium } from 'playwright'; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); - await page.goto('http://127.0.0.1:7011/static/js/documentStats.js'); + await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent('
'); await page.evaluate(async () => { window.fetch = async () => new Response(JSON.stringify({ diff --git a/tests/test_static_test_server.py b/tests/test_static_test_server.py new file mode 100644 index 000000000..f55546e9b --- /dev/null +++ b/tests/test_static_test_server.py @@ -0,0 +1,53 @@ +"""Contract for the loopback static server the browser tests run against. + +The server used to bind a fixed port and raise if it was taken, which errored +every collected test rather than the browser ones — so a second worktree +running its own suite took the whole session down with it. +""" + +import os +import re +import urllib.request +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_static_origin_is_published_for_node_subprocesses() -> None: + origin = os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN") + + assert origin, "the session fixture must publish the origin it bound" + assert re.fullmatch(r"http://127\.0\.0\.1:\d+", origin) + + +def test_static_origin_does_not_reuse_the_application_port() -> None: + """An ephemeral port keeps the suite and a running instance independent.""" + + origin = os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] + + assert not origin.endswith(":7011") + + +def test_static_server_serves_this_worktree() -> None: + origin = os.environ["ODYSSEUS_TEST_STATIC_ORIGIN"] + + with urllib.request.urlopen(f"{origin}/static/js/documentStats.js", timeout=5) as r: + assert r.status == 200 + assert r.headers.get_content_type() == "application/javascript" + + +def test_no_test_hardcodes_the_static_server_origin() -> None: + """Regression guard: a hardcoded port reintroduces the collision.""" + + needle = "page.goto(" + "'http://127.0.0.1:" + offenders = [] + for path in sorted((ROOT / "tests").glob("*.py")): + if path.name == Path(__file__).name: + continue + if needle in path.read_text(encoding="utf-8"): + offenders.append(path.name) + + assert offenders == [], ( + "browser tests must read ODYSSEUS_TEST_STATIC_ORIGIN rather than a fixed port" + )