QuantumLearning/tests/test_browser_ux.py

265 lines
9.2 KiB
Python

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")