from __future__ import annotations import os import socket import subprocess import time from urllib.error import URLError from urllib.request import urlopen import pytest from quantum_learning.config import project_root try: from playwright.sync_api import TimeoutError as PlaywrightTimeoutError from playwright.sync_api import sync_playwright except ImportError: # pragma: no cover - test environment specific sync_playwright = None PlaywrightTimeoutError = RuntimeError def _find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: try: sock.bind(("127.0.0.1", 0)) except PermissionError: pytest.skip("Browser UX tests need localhost ports, which this sandbox blocks.") return int(sock.getsockname()[1]) def _wait_for_server(url: str, process: subprocess.Popen[str], timeout_seconds: int = 90) -> None: deadline = time.time() + timeout_seconds while time.time() < deadline: if process.poll() is not None: stdout = process.stdout.read() if process.stdout is not None else "" raise RuntimeError(f"Jupyter server exited early.\n{stdout}") try: with urlopen(url, timeout=2): return except URLError: time.sleep(1) raise TimeoutError(f"Timed out waiting for JupyterLab at {url}") def _wait_for_notebook(browser_page, notebook_filename: str, heading: str) -> None: browser_page.locator(".lm-TabBar-tabLabel", has_text=notebook_filename).last.wait_for( timeout=60000 ) browser_page.locator(".jp-RenderedHTMLCommon h1", has_text=heading).last.wait_for( timeout=60000 ) def _open_notebook_from_directory( browser_page, jupyter_lab_server, directory: str, notebook_filename: str, heading: str, ) -> None: url = f"{jupyter_lab_server['base_url']}/lab/tree/{directory}?token={jupyter_lab_server['token']}" browser_page.goto(url, wait_until="networkidle") browser_page.locator(".jp-DirListing-itemText", has_text=notebook_filename).first.wait_for( timeout=60000 ) browser_page.locator(".jp-DirListing-itemText", has_text=notebook_filename).first.dblclick() _wait_for_notebook(browser_page, notebook_filename, heading) def _run_all_cells(browser_page) -> None: browser_page.locator("text=Run").first.click() browser_page.locator("text=Run All Cells").first.click() try: browser_page.get_by_role("button", name="Select").click(timeout=5000) except PlaywrightTimeoutError: pass def _find_code_editor(browser_page): textareas = browser_page.locator("textarea") for index in range(textareas.count()): candidate = textareas.nth(index) if not candidate.is_visible(): continue try: value = candidate.input_value() except PlaywrightTimeoutError: # pragma: no cover - widget race continue if "QuantumCircuit" in value: return candidate raise AssertionError("Could not locate an editable circuit code editor.") def _wait_for_text_in_body(browser_page, expected_text: str, *, timeout_seconds: int = 120) -> None: deadline = time.time() + timeout_seconds last_body = "" while time.time() < deadline: last_body = browser_page.locator("body").inner_text() if expected_text in last_body: return time.sleep(1) raise AssertionError( f"Did not find {expected_text!r} in rendered page text.\nLast body text excerpt:\n" f"{last_body[-4000:]}" ) @pytest.fixture(scope="module") def jupyter_lab_server(): if sync_playwright is None: pytest.skip("Playwright is not installed in this environment.") root = project_root() browser_root = root / ".playwright-browsers" if not browser_root.exists(): pytest.skip("Project-local Playwright browsers are not installed.") port = _find_free_port() token = "quantum-learning-browser-test" runtime_root = root / ".tmp_test_artifacts" / "browser_jupyter" config_dir = runtime_root / "config" data_dir = runtime_root / "data" runtime_dir = runtime_root / "runtime" ipython_dir = runtime_root / "ipython" mpl_dir = runtime_root / "matplotlib" for path in (config_dir, data_dir, runtime_dir, ipython_dir, mpl_dir): path.mkdir(parents=True, exist_ok=True) env = os.environ.copy() env.update( { "PATH": f"{root / '.venv' / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin", "JUPYTER_CONFIG_DIR": str(config_dir), "JUPYTER_DATA_DIR": str(data_dir), "JUPYTER_RUNTIME_DIR": str(runtime_dir), "IPYTHONDIR": str(ipython_dir), "MPLCONFIGDIR": str(mpl_dir), "PLAYWRIGHT_BROWSERS_PATH": str(browser_root), } ) command = [ str(root / ".venv" / "bin" / "jupyter"), "lab", "--no-browser", "--ip=127.0.0.1", f"--port={port}", f"--IdentityProvider.token={token}", f"--ServerApp.root_dir={root}", ] process = subprocess.Popen( command, cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) base_url = f"http://127.0.0.1:{port}" _wait_for_server(f"{base_url}/lab?token={token}", process) try: yield {"base_url": base_url, "token": token, "browser_root": browser_root} finally: process.terminate() try: process.wait(timeout=10) except subprocess.TimeoutExpired: # pragma: no cover - cleanup path process.kill() @pytest.fixture() def browser_page(jupyter_lab_server): os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(jupyter_lab_server["browser_root"]) with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True) page = browser.new_page() try: yield page finally: browser.close() @pytest.mark.browser def test_start_here_loads_as_the_entrypoint_in_jupyterlab(browser_page, jupyter_lab_server): url = ( f"{jupyter_lab_server['base_url']}/lab/tree/notebooks/START_HERE.ipynb" f"?token={jupyter_lab_server['token']}" ) browser_page.goto(url, wait_until="networkidle") _wait_for_notebook(browser_page, "START_HERE.ipynb", "Start Here") assert browser_page.locator(".jp-Notebook .jp-RenderedHTMLCommon", has_text="only supported starting point").first.is_visible() _wait_for_text_in_body(browser_page, "Course Blueprint") @pytest.mark.browser def test_foundations_lab_supports_quiz_answering_and_circuit_editing( browser_page, jupyter_lab_server, ): _open_notebook_from_directory( browser_page, jupyter_lab_server, "notebooks/foundations/module_01_principles_and_circuit_literacy", "lab.ipynb", "Principles and Circuit Literacy Lab", ) _run_all_cells(browser_page) browser_page.get_by_label("A. Only one deterministic outcome from the all-zero input").check() browser_page.get_by_role("button", name="Check answer").first.click() browser_page.locator("text=Correct.").first.wait_for(timeout=120000) editor = _find_code_editor(browser_page) editor.fill( 'circuit = QuantumCircuit(1, 1, name="workflow")\n' "circuit.x(0)\n" "circuit.measure(0, 0)\n" ) browser_page.get_by_role("button", name="Render circuit").first.click() browser_page.locator("pre", has_text="{'1': 256}").first.wait_for(timeout=120000) @pytest.mark.browser def test_capstone_studio_supports_checklist_feedback_and_rubric_workflows( browser_page, jupyter_lab_server, ): _open_notebook_from_directory( browser_page, jupyter_lab_server, "notebooks/professional/module_04_capstone_design_review", "studio.ipynb", "Capstone Circuit Design Review Studio", ) _run_all_cells(browser_page) browser_page.get_by_role("button", name="Render circuit").first.wait_for(timeout=120000) browser_page.locator("text=Capstone Circuit Design Review Evidence Checklist").first.wait_for( timeout=120000 ) browser_page.get_by_placeholder("State the current claim or judgement.").fill( "The middle-root GHZ candidate best satisfies the declared local brief." ) browser_page.get_by_placeholder("Name the exact evidence supporting that claim.").fill( "It preserves the ideal target, compiles with lower burden on the line, and retains stronger noisy support." ) browser_page.get_by_placeholder("Name the main remaining risk, assumption, or weakness.").fill( "The comparison still depends on a synthetic local noise model." ) browser_page.get_by_placeholder("State the next revision, experiment, or rewrite.").fill( "Compare the same candidates under a second line-constrained noise profile." ) browser_page.get_by_role("button", name="Review draft").click() browser_page.locator("text=Missing sections: None").first.wait_for(timeout=120000) _wait_for_text_in_body(browser_page, "Capstone Circuit Design Review Studio Self-Grading") _wait_for_text_in_body(browser_page, "Brief Clarity") _wait_for_text_in_body(browser_page, "Total: 0/28") _wait_for_text_in_body(browser_page, "Band: revision-needed")