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 3788df5b7..cfecf8699 100644 --- a/tests/test_document_rich_font_color_controls.py +++ b/tests/test_document_rich_font_color_controls.py @@ -93,7 +93,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 0488e375c..7c573dd03 100644 --- a/tests/test_document_rich_image_caption.py +++ b/tests/test_document_rich_image_caption.py @@ -35,7 +35,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 8382b9085..0e93edc35 100644 --- a/tests/test_document_rich_input_rules.py +++ b/tests/test_document_rich_input_rules.py @@ -27,7 +27,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" + )