import urllib.request import pytest import subprocess import time import signal import os import fcntl def _counter_add(delta: int, count_file: str, lock_file: str) -> int: """Atomically add `delta` to the worker counter and return the new value.""" with open(lock_file, "w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) try: try: with open(count_file) as f: value = int(f.read().strip() or "0") except FileNotFoundError: value = 0 value += delta with open(count_file, "w") as f: f.write(str(value)) return value finally: fcntl.flock(lock, fcntl.LOCK_UN) def _counter_read(count_file: str, lock_file: str) -> int: """Read the current worker counter value without modifying it.""" with open(lock_file, "w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) try: try: with open(count_file) as f: return int(f.read().strip() or "0") except FileNotFoundError: return 0 finally: fcntl.flock(lock, fcntl.LOCK_UN) @pytest.fixture(scope="session") def app_url(tmp_path_factory, worker_id): """Start the Vite dev server and return its URL. When running under pytest-xdist, only the first worker (gw0) starts the server and writes the URL to a shared temp file. All other workers wait for that file to appear and then read the URL from it. The server is only stopped once every worker has finished (tracked via a shared counter file). When running without xdist (worker_id == "master") the behaviour is identical to the original single-worker case. Shared files are placed inside tmp_path_factory.getbasetemp() so that two concurrent pytest invocations never collide (each invocation gets a unique basetemp directory from pytest's per-run counter). """ basetemp = tmp_path_factory.getbasetemp() # In xdist each worker gets its own subdirectory; step up to the shared parent if worker_id != "master": basetemp = basetemp.parent server_url_file = str(basetemp / "trainus_server_url") worker_count_file = str(basetemp / "trainus_worker_count") worker_count_lock = str(basetemp / "trainus_worker_count.lock") url = "http://localhost:5199" is_controller = worker_id in ("master", "gw0") # Register this worker in the shared counter _counter_add(1, worker_count_file, worker_count_lock) server_error_file = str(basetemp / "trainus_server_error") if is_controller: # Remove stale files from a previous run if present for stale in (server_url_file, server_error_file): try: os.remove(stale) except FileNotFoundError: pass project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) process = subprocess.Popen( ["npm", "run", "dev", "--", "--port", "5199", "--strictPort"], cwd=project_root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, preexec_fn=os.setsid, ) # Wait for the server to be ready (up to 60 s) for _ in range(120): try: urllib.request.urlopen(url, timeout=1) break except Exception: time.sleep(0.5) else: process.kill() # Signal secondary workers to stop waiting with open(server_error_file, "w") as f: f.write("Vite dev server failed to start within 60s") raise RuntimeError("Vite dev server failed to start within 60s") # Publish the URL so other workers can pick it up with open(server_url_file, "w") as f: f.write(url) yield url # Mark the controller itself as done, then wait for all other workers # to finish before killing the server. We decrement exactly once here # and then only *read* the counter so we never accidentally modify it # inside the polling loop. _counter_add(-1, worker_count_file, worker_count_lock) for _ in range(240): if _counter_read(worker_count_file, worker_count_lock) <= 0: break time.sleep(0.5) # Cleanup os.killpg(os.getpgid(process.pid), signal.SIGTERM) else: # Secondary workers: wait until gw0 has written the URL file (up to 90 s) for _ in range(180): if os.path.exists(server_url_file): break if os.path.exists(server_error_file): with open(server_error_file) as f: msg = f.read().strip() raise RuntimeError(f"gw0 failed to start the Vite dev server: {msg}") time.sleep(0.5) else: raise RuntimeError("Timed out waiting for Vite dev server URL from gw0") with open(server_url_file) as f: server_url = f.read().strip() yield server_url # Signal this worker is done _counter_add(-1, worker_count_file, worker_count_lock) @pytest.fixture(scope="session") def browser_type_launch_args(): """Configure browser launch args.""" return {} @pytest.fixture(scope="session") def browser_context_args(): """Configure browser context.""" return { "viewport": {"width": 1280, "height": 720}, } # Every test runs in a fresh browser context, i.e. a fresh (OPFS) database, which # is exactly the "first launch" condition that shows the first-load warning modal # (see src/components/FirstLoadWarning.vue). Auto-dismiss it for every test except # the ones that specifically exercise it, so it doesn't block interaction with the # rest of the app. A MutationObserver is used (rather than a one-off wait) so it # reliably clicks the button whenever the modal appears, including after a # mid-test transition such as the Step 16 migration flow. _AUTO_DISMISS_FIRST_LOAD_WARNING = """ (() => { function tryDismiss() { const btn = document.querySelector('[data-testid="first-load-warning-dismiss"]'); if (btn) btn.click(); } const observer = new MutationObserver(tryDismiss); const start = () => { observer.observe(document.body, { childList: true, subtree: true }); tryDismiss(); }; if (document.body) start(); else document.addEventListener('DOMContentLoaded', start); })(); """ @pytest.fixture def page(page, request): """Wrap pytest-playwright's `page` fixture to auto-dismiss the first-load warning modal, unless the test is marked `keep_first_load_warning`.""" if not request.node.get_closest_marker("keep_first_load_warning"): page.add_init_script(_AUTO_DISMISS_FIRST_LOAD_WARNING) yield page