diff --git a/.github/workflows/_container.yml b/.github/workflows/_container.yml index 0446d2e..b08a7f5 100644 --- a/.github/workflows/_container.yml +++ b/.github/workflows/_container.yml @@ -8,6 +8,17 @@ on: jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # The checks image, tagged and latest + - target: runtime + suffix: "" + # The same with Playwright and Chromium for the UI checks, tagged + # -browser and latest-browser + - target: browser + suffix: -browser steps: - name: Checkout @@ -34,6 +45,7 @@ jobs: DOCKER_BUILD_RECORD_UPLOAD: false with: context: . + target: ${{ matrix.target }} # Need load and tags so we can test it below load: true tags: tag_for_testing @@ -41,14 +53,25 @@ jobs: - name: Test cli works in cached runtime image run: docker run --rm tag_for_testing --version + - name: Test Chromium starts in cached browser image + if: matrix.target == 'browser' + run: > + docker run --rm --entrypoint python tag_for_testing -c + "from playwright.sync_api import sync_playwright as p; + b = p().start().chromium.launch(); print('chromium', b.version)" + - name: Create tags for publishing image id: meta uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} + # A release tag (0.2.0) also moves latest (latest-browser); a + # pre-release tag (0.2.0-beta.1, any tag with a '-') moves neither + flavor: | + latest=${{ !contains(github.ref_name, '-') }} + suffix=${{ matrix.suffix }},onlatest=true tags: | type=ref,event=tag - type=raw,value=latest - name: Push cached image to container registry if: inputs.publish && github.ref_type == 'tag' @@ -59,6 +82,7 @@ jobs: # Docker cache and publish it with: context: . + target: ${{ matrix.target }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index 963a4ea..f72e416 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,25 @@ ENV UV_PYTHON_INSTALL_DIR=/python RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-editable --no-dev --managed-python -# The runtime stage copies the built venv into a runtime container +# The browser build adds the ui extra (Playwright) to the same venv +FROM build AS build-browser +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-editable --no-dev --extra ui --managed-python + +# The -browser image: the runtime on Playwright's own image, which has Chromium +# and its system libraries at /ms-playwright. Its version must be the +# playwright pinned by the ui extra in pyproject.toml. +FROM mcr.microsoft.com/playwright/python:v1.63.0-resolute AS browser + +COPY --from=build-browser /python /python +COPY --from=build-browser /app/.venv /app/.venv +ENV PATH=/app/.venv/bin:$PATH + +ENTRYPOINT ["visr-tests"] +CMD ["--version"] + +# The runtime stage copies the built venv into a runtime container. +# It stays the last stage, so a build with no --target makes this image. FROM ubuntu:resolute AS runtime # Add apt-get system dependecies for runtime here if needed diff --git a/README.md b/README.md index a1cdc3e..3f5a835 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Check | What it checks `components/iocs` | each IOC in `IOC_NAMES` answers `caget :UPTIME` (devIocStats) `experiments/spectroscopy` | runs `demo_spectroscopy` as a step scan and as a fly scan through blueapi, then finds each run in tiled by `start.blueapi_task_id` and checks its exit status is `success`, it has 4 primary events, and `/binned?width=2&height=2` gives a 2x2 `RedTotal` `experiments/tomography` | the tomography experiment +`ui/spectroscopy` | in headless Chromium, as the service account: opens the ViSR UI's Spectroscopy page, presses Run with the form's default parameters, waits for "Plan succeeded", then checks through blueapi's task API that the task completed successfully. Needs the `-browser` image The PV checks only read: they never write a PV or move anything. A PV must answer within `PV_TIMEOUT` seconds; one that does not is tried once more before @@ -48,6 +49,54 @@ The scans move the sample stage; the fly scan also needs the PandA and PMAC. The plan, its parameters and the expected results are in `src/visr_tests/experiments/spectroscopy.py`. +### The UI check + +`ui/spectroscopy` drives the ViSR web UI (`b01-1-sci-ui`, the +[atlas](https://github.com/DiamondLightSource/atlas) `apps/visr` SPA) the way +a user does. Its selectors follow the deployed tag, `visr@0.5.4-auth-test-10`, +and will need updating when the deployment moves to a later UI. + +- It first asks blueapi for the worker state. If the worker is not `IDLE`, + another scan is running: the check fails and submits nothing. It never + queues behind another scan. +- It opens `$UI_URL/acquisition/spectroscopy` with the instrument session set + to `INSTRUMENT_SESSION`, checks the UI shows the service account logged in, + and presses Run with the form's defaults (`demo_spectroscopy`, 25 points, + 5 mm grid, 0.1 s exposure). The scan is real and moves the sample stage. +- It waits up to `SCAN_TIMEOUT` seconds, from the start of the check, for the + UI to report "Plan succeeded", then confirms with blueapi that the task + completed with a success outcome. If the check fails once the UI may have + started the scan (the timeout, a slow answer to the submission, an error + the UI shows), the page is stopped from starting any task, and each task it + did start is aborted if it is still the worker's active task. A task the + page did not start is never aborted. +- When the scan finishes the UI submits the `visr-reconstruction` workflow + for the instrument session, as it does for every scan. + +The browser holds no login session: it sends the service account's +`client_credentials` token as `Authorization: Bearer` on requests to the +UI's own origin (`UI_URL`) only, never to any other origin, such as the PV +camera stream or the identity provider, not even through a redirect. A +redirect within the UI's origin is followed with the token. The page's event +streams from the UI are opened by a stand-in for the browser's `EventSource` +that never follows a redirect. For the token to work, the UI's oauth2-proxy (in +`b01-1-services`, `services/b01-1-sci-ui`) must accept bearer tokens from the +identity provider, with the service account's audience: + +``` +skip_jwt_bearer_tokens = true +extra_jwt_issuers = ["https://identity.diamond.ac.uk/realms/dls=account"] +``` + +Without them the UI shows logged out and the check fails saying so. If the +check fails, a screenshot of the page is saved beside its JUnit report, as +`.png`. Neither the token nor request headers are logged or saved. + +The bearer is only added to requests for the UI's own origin. This guards +against ordinary redirects, not against hostile script in the UI page or a +redirect `Location` crafted to parse differently in Python and Chromium; +the check only ever loads our own UI, so those are accepted. + ### Environment Variable | Default | Used by @@ -55,11 +104,12 @@ Variable | Default | Used by `BLUEAPI_URL` | `https://b01-1-blueapi.diamond.ac.uk` | blueapi checks. The public URL, so requests go through oauth2-proxy as users' do `TILED_URL` | `http://b01-1-tiled:8000` | tiled checks. The in-cluster Service `TOKEN_URL` | `https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/token` | experiments -`CLIENT_ID` | none | experiments: the Keycloak service account's client id -`CLIENT_SECRET` | none | experiments: its client secret -`INSTRUMENT_SESSION` | none | experiments: the instrument session the plans run in, e.g. `cm44191-4`. The service account must be allowed to run plans in it -`SCAN_TIMEOUT` | `300` | experiments: seconds each scan may take, from submitting it to its run stopping in tiled +`CLIENT_ID` | none | experiments and UI checks: the Keycloak service account's client id +`CLIENT_SECRET` | none | experiments and UI checks: its client secret +`INSTRUMENT_SESSION` | none | experiments and UI checks: the instrument session the plans run in, e.g. `cm44191-4`. The service account must be allowed to run plans in it +`SCAN_TIMEOUT` | `300` | experiments: seconds each scan may take, from submitting it to its run stopping in tiled. `ui/spectroscopy`: seconds from the start of the check to the UI reporting the outcome `PV_TIMEOUT` | `5` | PV checks: seconds to wait for each PV read, as `caget -w 5` +`UI_URL` | `https://visr.diamond.ac.uk` | `ui/spectroscopy`: the ViSR UI, through its oauth2-proxy `IOC_NAMES` | none | `components/iocs`: the IOCs to check, separated by commas or spaces, e.g. `bl01c-di-dcam-01,bl01c-mo-ioc-01`. Each must load devIocStats (`devIocStats.iocAdminSoft` in its `ioc.yaml`) `EPICS_CA_NAME_SERVERS` | none | PV checks over CA: in the cluster the gateway, `b01-1-epics-gateways:9064` `EPICS_PVA_NAME_SERVERS` | none | PV checks over PVA: in the cluster the gateway, `b01-1-epics-gateways:9075` @@ -97,6 +147,43 @@ IOC_NAMES=bl01c-di-dcam-01,bl01c-di-dcam-02,bl01c-di-dcam-04,bl01c-di-dcam-05,bl components/iocs ``` +## The -browser image + +The UI checks need Playwright and Chromium, which the default image leaves +out. CI builds a second image from the same source, the Dockerfile's +`browser` target, on Playwright's own image +(`mcr.microsoft.com/playwright/python`), tagged `-browser` and +`latest-browser`: + +``` +docker run ghcr.io/diamondlightsource/visr-tests:-browser run ui/spectroscopy +``` + +It runs every check the default image does. Playwright comes from the `ui` +extra, pinned to the version of the base image: bump them together. To run +the UI check outside the image, install the extra and Chromium: + +``` +uv sync --extra ui +uv run playwright install --with-deps chromium +CLIENT_ID=... CLIENT_SECRET=... INSTRUMENT_SESSION=cm44191-4 \ + uv run visr-tests run ui/spectroscopy +``` + +In the cluster it runs as its own CronJob, `b01-1-nightly-ui`, at 03:00, on +the `-browser` image with the same `visr-tests-secret` and +`INSTRUMENT_SESSION` as the experiments job. + +The tests in `tests/test_ui.py` run the check in a real browser against a +build of the UI and fake backends (`tests/fake_ui.py`). They are skipped +unless the `ui` extra is installed and `VISR_UI_DIST` names a built +`apps/visr/dist` from atlas at the deployed tag, so CI skips them: run them +locally before a release: + +``` +VISR_UI_DIST=/apps/visr/dist uv run --extra ui pytest tests/test_ui.py +``` + ## Provisioning the client secret The experiment checks run as a Keycloak service account. Its client secret diff --git a/pyproject.toml b/pyproject.toml index 78a0d56..5300d8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,11 @@ license.file = "LICENSE" readme = "README.md" requires-python = ">=3.11" +[project.optional-dependencies] +# The UI checks (ui/*). Pinned to the Playwright of the -browser image's base, +# mcr.microsoft.com/playwright/python (Dockerfile): bump the two together +ui = ["playwright==1.63.0"] + [dependency-groups] dev = [ "pre-commit", diff --git a/src/visr_tests/browser.py b/src/visr_tests/browser.py new file mode 100644 index 0000000..8f1bd3a --- /dev/null +++ b/src/visr_tests/browser.py @@ -0,0 +1,580 @@ +"""Drive the ViSR web UI in headless Chromium, as the service account. + +The UI sits behind oauth2-proxy, which normally holds a user's session in a +cookie. Here the browser instead sends the client_credentials token as +``Authorization: Bearer`` on every request to the UI's own origin, and on no +other: requests to other origins (the PV camera stream, the identity provider) +go out untouched, and so does any redirect from the UI to another origin. That +needs the UI's oauth2-proxy to accept bearer tokens (``skip_jwt_bearer_tokens`` +and ``extra_jwt_issuers``, see the README). + +Playwright is imported only when a browser is opened, so the other checks run +without it; it comes with the ``ui`` extra and the ``-browser`` image. +""" + +from __future__ import annotations + +import importlib.util +import json +import logging +import re +import secrets +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin, urlsplit + +from .services import CheckError + +if TYPE_CHECKING: # pragma: no cover + from playwright.sync_api import ( + APIRequestContext, + APIResponse, + Locator, + Page, + Response, + Route, + ) + +log = logging.getLogger(__name__) + +UI_TIMEOUT = 30.0 # seconds for a page to load or a control to become ready +USERINFO = "/oauth2/userinfo" +LOGIN_PATHS = ("/oauth2/start", "/oauth2/sign_in") +PROXY_FLAGS = ( + "skip_jwt_bearer_tokens = true and " + "extra_jwt_issuers = https://identity.diamond.ac.uk/realms/dls=account" +) +# The key the UI keeps its selected instrument session under, JSON encoded +SESSION_KEY = "instrument-session-id" +DEFAULT_PORTS = {"http": 80, "https": 443} +REDIRECTS = (301, 302, 303, 307, 308) +MAX_REDIRECTS = 20 # as Chromium +SUBMIT = "/api/blueapi/tasks" # POST: the UI submits a task +START = "/api/blueapi/worker/task" # PUT {"task_id": ...}: the UI starts it +# Marks the requests of the page's event streams (see STREAMS) +STREAM_HEADER = "x-visr-tests-stream" + +# An event stream never ends, so the route cannot fetch it whole and fulfil it, +# as it does every other request to the UI; it can only let the browser send it +# with the bearer added. Chromium would then send the bearer on through any +# redirect. So the page's EventSource for the UI's own origin is replaced by +# one built on fetch() with redirect: "error", which never follows a redirect, +# and its requests carry a per-browser marker, the only requests the route +# lets out that way. Any other EventSource request goes out without the bearer. +STREAMS = r""" +(() => { + const Native = window.EventSource; + if (!Native) return; + const MARK = %(mark)s, NONCE = %(nonce)s; + class UiEventSource extends EventTarget { + constructor(url, init) { + const href = new URL(url, location.href).href; + if (new URL(href).origin !== location.origin) return new Native(url, init); + super(); + this.url = href; + this.withCredentials = Boolean(init && init.withCredentials); + this.readyState = 0; + this.onopen = this.onmessage = this.onerror = null; + this._retry = 3000; + this._lastId = ""; + this._abort = null; + this._connect(); + } + _fire(type, init) { + const event = init ? new MessageEvent(type, init) : new Event(type); + this.dispatchEvent(event); + const handler = ["open", "message", "error"].includes(type) + ? this["on" + type] : null; + if (typeof handler === "function") handler.call(this, event); + } + _retryLater() { + if (this.readyState === 2) return; + this.readyState = 0; + this._fire("error"); + setTimeout(() => { if (this.readyState !== 2) this._connect(); }, this._retry); + } + async _connect() { + const abort = new AbortController(); + this._abort = abort; + const headers = { Accept: "text/event-stream", [MARK]: NONCE }; + if (this._lastId) headers["Last-Event-ID"] = this._lastId; + let response; + try { + response = await fetch(this.url, { + headers, cache: "no-store", redirect: "error", signal: abort.signal, + credentials: this.withCredentials ? "include" : "same-origin", + }); + } catch (error) { + return this._retryLater(); + } + if (this.readyState === 2) return; + const type = (response.headers.get("content-type") || "").toLowerCase(); + if (response.status !== 200 || !type.startsWith("text/event-stream")) { + this.readyState = 2; + this._fire("error"); + return; + } + this.readyState = 1; + this._fire("open"); + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = "", data = "", name = "", id = this._lastId; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + // a "\r" ending a chunk waits, in case a "\n" follows it + const lines = (buffer + value).split(/\r\n|\n|\r(?!$)/); + buffer = lines.pop(); + for (const line of lines) { + if (line === "") { + this._lastId = id; + if (data !== "") { + this._fire(name || "message", { + data: data.slice(0, -1), lastEventId: id, origin: location.origin, + }); + } + data = name = ""; + continue; + } + if (line.startsWith(":")) continue; + const colon = line.indexOf(":"); + const field = colon < 0 ? line : line.slice(0, colon); + let text = colon < 0 ? "" : line.slice(colon + 1); + if (text.startsWith(" ")) text = text.slice(1); + if (field === "data") data += text + "\n"; + else if (field === "event") name = text; + else if (field === "id" && !text.includes("\0")) id = text; + else if (field === "retry" && /^\d+$/.test(text)) { + this._retry = Number(text); + } + } + } + } catch (error) { + // the stream broke, or close() aborted it + } + this._retryLater(); + } + close() { + this.readyState = 2; + if (this._abort) this._abort.abort(); + } + } + for (const [name, value] of [["CONNECTING", 0], ["OPEN", 1], ["CLOSED", 2]]) { + Object.defineProperty(UiEventSource, name, { value }); + Object.defineProperty(UiEventSource.prototype, name, { value }); + } + window.EventSource = UiEventSource; +})(); +""" + + +def playwright_missing() -> str: + """Why the browser cannot run, or "" if Playwright is installed.""" + if importlib.util.find_spec("playwright") is None: + return ( + "playwright not installed: run the UI checks in the " + "visr-tests:-browser image, or install the ui extra" + ) + return "" + + +def origin(url: str) -> str: + """``scheme://host:port`` of ``url``, with the default port made explicit.""" + parts = urlsplit(url) + scheme = parts.scheme.lower() + port = parts.port or DEFAULT_PORTS.get(scheme) + return f"{scheme}://{(parts.hostname or '').lower()}:{port}" + + +def with_bearer( + url: str, headers: Mapping[str, str], ui_origin: str, token: str +) -> dict[str, str] | None: + """``headers`` plus the bearer token if ``url`` is on the UI's origin. + + None for a request to any other origin: it must go out unchanged. + """ + if origin(url) != ui_origin: + return None + kept = {k: v for k, v in headers.items() if k.lower() != "authorization"} + return {**kept, "authorization": f"Bearer {token}"} + + +class PlanTimeoutError(Exception): + """The UI submitted a plan but did not report its outcome in time.""" + + def __init__(self, task_id: str): + super().__init__(task_id) + self.task_id = task_id + + +class TaskStarts: + """The tasks the page asked blueapi to start, and a stop to any more.""" + + def __init__(self) -> None: + self.task_ids: list[str] = [] + self.stopped = False + + def allow(self, method: str, path: str, body: Callable[[], Any]) -> bool: + """Whether a request from the page may go out; records any task start. + + A start is recorded before it is sent, so a task this page started is + always known, even if the page never sees blueapi's answer. + """ + if (method, path) != ("PUT", START): + return True + if self.stopped: + return False + sent = body() + task_id = sent.get("task_id") if isinstance(sent, dict) else None + if isinstance(task_id, str): + self.task_ids.append(task_id) + return True + + def stop(self) -> list[str]: + """Let the page start no more tasks: the ones it did start.""" + self.stopped = True + return list(dict.fromkeys(self.task_ids)) + + +def logged_out(why: str) -> CheckError: + return CheckError( + f"the UI shows logged out with the service account's bearer token ({why}): " + f"the UI's oauth2-proxy must accept bearer tokens, with {PROXY_FLAGS}" + ) + + +class Ui: + """The ViSR UI open in one browser page, authenticated by bearer token.""" + + def __init__( + self, + page: Page, + url: str, + timeout: float = UI_TIMEOUT, + starts: TaskStarts | None = None, + followed: list[str] | None = None, + ): + self.page = page + self.url = url + self.origin = origin(url) + self.timeout_ms = timeout * 1000 + self.starts = starts or TaskStarts() + # pages the UI redirected to, within its origin, followed by the route + self.followed = followed if followed is not None else [] + self.userinfo_status: int | None = None + self.failed: list[str] = [] # same-origin responses with an error status + page.on("response", self._saw) + + def _saw(self, response: Response) -> None: + if origin(response.url) != self.origin: + return + path = urlsplit(response.url).path + if path == USERINFO: + self.userinfo_status = response.status + if response.status >= 400: + self.failed.append(f"{response.request.method} {path} {response.status}") + + def _diagnosis(self) -> str: + """Same-origin requests that failed, to explain a page that is stuck.""" + if not self.failed: + return "" + return "; failed requests: " + ", ".join(dict.fromkeys(self.failed)) + + def _login_page(self) -> str: + """The login page the browser went to, or "".""" + for url in (*self.followed, self.page.url): + parts = urlsplit(url) + if origin(url) != self.origin or parts.path.startswith(LOGIN_PATHS): + return f"{parts.netloc}{parts.path}" + return "" + + def _check_logged_in(self) -> None: + if self.userinfo_status == 401: + raise logged_out(f"{USERINFO} answered HTTP 401") + login = self._login_page() + if login: + raise logged_out(f"it went to the login page {login}") + + def _wait(self, locator: Locator, what: str, timeout_ms: float) -> None: + from playwright.sync_api import Error + + try: + locator.first.wait_for(state="visible", timeout=timeout_ms) + except Error: + self._check_logged_in() + raise CheckError( + f"{what} within {timeout_ms / 1000:g}s{self._diagnosis()}" + ) from None + + def open(self, path: str, instrument_session: str) -> None: + """Open a page of the UI in ``instrument_session`` and check it is logged in.""" + self.page.add_init_script( + f"window.localStorage.setItem({json.dumps(SESSION_KEY)}, " + f"{json.dumps(json.dumps(instrument_session))});" + ) + response = self.page.goto( + f"{self.url}{path}", timeout=self.timeout_ms, wait_until="load" + ) + self._check_logged_in() + if response is not None and response.status in (401, 403): + raise logged_out(f"GET {path} answered HTTP {response.status}") + if response is not None and response.status >= 400: + raise CheckError(f"GET {path} on the UI returned HTTP {response.status}") + + login = self.page.get_by_role("button", name="Login", exact=True) + user = self.page.get_by_role("button", name="User Avatar") + self._wait( + user.or_(login), "the UI did not show who is logged in", self.timeout_ms + ) + if login.is_visible(): + raise logged_out( + f"it offers a Login button; {USERINFO} answered " + f"HTTP {self.userinfo_status}" + ) + self._check_logged_in() + log.info("UI %s is logged in", path) + + session = self.page.get_by_role("button", name=instrument_session, exact=True) + self._wait( + session, + f"the UI did not select instrument session {instrument_session}", + self.timeout_ms, + ) + + def run_plan(self, plan: str, deadline_ms: float) -> tuple[str, dict[str, Any]]: + """Press Run, wait for the UI to say the plan finished: (task id, request). + + ``deadline_ms`` is how long the UI may take to report the outcome. + """ + from playwright.sync_api import Error, expect + + run = self.page.get_by_role("button", name="Run", exact=True) + self._wait(run, "the UI did not show its Run button", self.timeout_ms) + try: + expect(run).to_be_enabled(timeout=self.timeout_ms) + except AssertionError: + self._check_logged_in() + raise CheckError( + f"the Run button stayed disabled for {self.timeout_ms / 1000:g}s: " + f"the UI does not see the blueapi worker IDLE{self._diagnosis()}" + ) from None + + def submission(response: Response) -> bool: + return ( + response.request.method == "POST" + and origin(response.url) == self.origin + and urlsplit(response.url).path == SUBMIT + ) + + try: + with self.page.expect_response( + submission, timeout=self.timeout_ms + ) as submitted: + run.click() + except Error: + self._check_logged_in() + raise CheckError( + f"pressing Run did not submit {plan} to blueapi{self._diagnosis()}" + ) from None + response = submitted.value + request = response.request.post_data_json or {} + if response.status != 201: + self._check_logged_in() + raise CheckError( + f"the UI's submission of {plan} got HTTP {response.status}" + f"{self._diagnosis()}" + ) + try: + task_id = response.json()["task_id"] + except (Error, ValueError, KeyError, TypeError): + raise CheckError( + "the UI's POST /api/blueapi/tasks has no task_id" + ) from None + if request.get("name") != plan: + raise CheckError(f"the UI submitted {request.get('name')!r}, not {plan!r}") + log.info( + "UI submitted %s as task %s with %s in %s", + plan, + task_id, + request.get("params"), + request.get("instrument_session"), + ) + + succeeded = self.page.get_by_text("Plan succeeded", exact=True) + failed = self.page.get_by_text( + re.compile(r"^(Failed to run plan|Plan submission failed)") + ) + try: + succeeded.or_(failed).first.wait_for(state="visible", timeout=deadline_ms) + except Error: + raise PlanTimeoutError(task_id) from None + if not succeeded.is_visible(): + raise CheckError( + f"the UI reported {failed.first.inner_text()!r} for task {task_id}" + ) + log.info("UI reports task %s succeeded", task_id) + return task_id, request + + def stop_tasks(self) -> list[str]: + """Stop the page starting any more tasks: the ones it started.""" + return self.starts.stop() + + def screenshot(self, path: Path) -> None: + """Best effort: the page as it is now, to explain a failure.""" + try: + path.parent.mkdir(parents=True, exist_ok=True) + self.page.screenshot(path=path, full_page=True) + log.info("saved a screenshot of the UI to %s", path) + except Exception as error: # noqa: BLE001 - never mask the real failure + log.warning("could not save a screenshot: %s", error) + + +def _redirected( + response: APIResponse, url: str, method: str, body: bytes | None +) -> tuple[str, str, bytes | None] | None: + """Where a browser goes next after ``response``: (url, method, body).""" + location = response.headers.get("location") + if response.status not in REDIRECTS or not location: + return None + if (response.status == 303 and method != "HEAD") or ( + response.status in (301, 302) and method == "POST" + ): + method, body = "GET", None + return urljoin(url, location), method, body + + +def _fetch_on_ui( + route: Route, + api: APIRequestContext, + ui_origin: str, + headers: dict[str, str], + followed: list[str], +) -> APIResponse: + """The UI's response to the routed request, sent with ``headers``. + + ``headers`` carry the bearer, so they go to the UI's origin only. + + Redirects within the UI's origin are followed here, each hop with the + bearer, and the browser gets the final response. A redirect to any other + origin is handed to the browser, which follows it without the bearer: the + route never sees that hop, and the browser itself never had the token. + The page keeps the URL it asked for, so a page's redirects are recorded in + ``followed``. + """ + request = route.request + url, method, body = request.url, request.method, request.post_data_buffer + response = route.fetch(headers=headers, max_redirects=0) + for _ in range(MAX_REDIRECTS): + hop = _redirected(response, url, method, body) + if hop is None or origin(hop[0]) != ui_origin: + break + if hop[1] != method: # the body is dropped: so are its headers + headers = { + k: v + for k, v in headers.items() + if k.lower() not in ("content-type", "content-length") + } + url, method, body = hop + if request.is_navigation_request(): + followed.append(url) + response = api.fetch( + url, method=method, headers=headers, data=body, max_redirects=0 + ) + return response + + +def _authoriser( + api: APIRequestContext, + ui_origin: str, + token: str, + nonce: str, + starts: TaskStarts, + followed: list[str], +) -> Callable[[Route], None]: + """The route for every request of the page: adds the bearer on the UI only.""" + from playwright.sync_api import Error + + def authorise(route: Route) -> None: + request = route.request + path = urlsplit(request.url).path + try: + headers = with_bearer(request.url, request.headers, ui_origin, token) + if headers is None: + route.continue_() + elif not starts.allow(request.method, path, lambda: _json(request)): + route.abort() + elif headers.pop(STREAM_HEADER, None) == nonce: + # The page's own event stream (STREAMS): fetch() with redirect + # "error", so the browser follows no redirect with the bearer + route.continue_(headers=headers) + elif request.resource_type == "eventsource": + # Any other event stream: the browser would follow a redirect + # with the bearer, so it goes without + route.continue_() + else: + route.fulfill( + response=_fetch_on_ui(route, api, ui_origin, headers, followed) + ) + except Error as error: + # Playwright's message logs the request headers, so the token: + # report only what failed + log.warning( + "request %s %s failed in the browser: %s", + request.method, + path, + type(error).__name__, + ) + try: + route.abort() + except Error: + pass # the page or browser is already gone + + return authorise + + +def _json(request: Any) -> Any: + """The request's JSON body, or None.""" + try: + return request.post_data_json + except Exception: # noqa: BLE001 - not JSON + return None + + +@contextmanager +def open_ui(url: str, token: str, timeout: float = UI_TIMEOUT) -> Iterator[Ui]: + """A fresh headless Chromium page on the UI, sending ``token`` to it only.""" + from playwright.sync_api import sync_playwright + + nonce = secrets.token_urlsafe(16) + starts = TaskStarts() + followed: list[str] = [] + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + try: + # Service workers could answer requests without passing the route. + # Pages fulfilled by the route count as public to Chromium, so + # their requests to private or loopback addresses (Diamond hosts) + # need the local network permission a user would be asked for. + context = browser.new_context( + service_workers="block", permissions=["local-network-access"] + ) + context.add_init_script( + STREAMS + % {"mark": json.dumps(STREAM_HEADER), "nonce": json.dumps(nonce)} + ) + context.route( + "**/*", + _authoriser( + context.request, origin(url), token, nonce, starts, followed + ), + ) + try: + yield Ui(context.new_page(), url, timeout, starts, followed) + finally: + # Requests still in flight (polls, the event stream) must not + # fail into Playwright's error output, which logs headers + context.unroute_all(behavior="ignoreErrors") + finally: + browser.close() diff --git a/src/visr_tests/conftest.py b/src/visr_tests/conftest.py index d647e6f..f263938 100644 --- a/src/visr_tests/conftest.py +++ b/src/visr_tests/conftest.py @@ -2,12 +2,15 @@ import json import os -from collections.abc import Callable, Iterator +from collections.abc import Callable, Generator, Iterator +from contextlib import ExitStack +from pathlib import Path from typing import NoReturn import httpx import pytest +from .browser import Ui, open_ui, playwright_missing from .services import ( Blueapi, CheckError, @@ -131,3 +134,44 @@ def blueapi( @pytest.fixture(scope="session") def tiled(config: Config, http: httpx.Client) -> Tiled: return Tiled(http, config.tiled_url) + + +REPORTS = pytest.StashKey[dict[str, pytest.TestReport]]() + + +@pytest.hookimpl(wrapper=True) +def pytest_runtest_makereport( + item: pytest.Item, call: pytest.CallInfo[None] +) -> Generator[None, pytest.TestReport, pytest.TestReport]: + """Keep each phase's report on the test, so fixtures can see a failure.""" + report = yield + item.stash.setdefault(REPORTS, {})[report.when] = report + return report + + +@pytest.fixture +def start_ui( + config: Config, request: pytest.FixtureRequest +) -> Iterator[Callable[[], Ui]]: + """Starts headless Chromium on the ViSR UI (UI_URL), sending it the token. + + The browser starts only when called, so a check can fail fast before it. + If the test fails, a screenshot of the page is saved beside its JUnit report. + """ + missing = playwright_missing() + if missing: + _fail(ConfigError(missing)) + token = request.getfixturevalue("token") + with ExitStack() as stack: + started: list[Ui] = [] + + def start() -> Ui: + if not started: + started.append(stack.enter_context(open_ui(config.ui_url, token))) + return started[0] + + yield start + call = request.node.stash.get(REPORTS, {}).get("call") + xml = getattr(request.config.option, "xmlpath", None) + if started and call is not None and call.failed and xml: + started[0].screenshot(Path(xml).with_suffix(".png")) diff --git a/src/visr_tests/services.py b/src/visr_tests/services.py index 414bd59..57cccd2 100644 --- a/src/visr_tests/services.py +++ b/src/visr_tests/services.py @@ -20,6 +20,7 @@ BLUEAPI_URL = "https://b01-1-blueapi.diamond.ac.uk" TILED_URL = "http://b01-1-tiled:8000" +UI_URL = "https://visr.diamond.ac.uk" TOKEN_URL = "https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/token" SCAN_TIMEOUT = 300.0 PV_TIMEOUT = 5.0 @@ -46,6 +47,7 @@ class Config: instrument_session: str = "" scan_timeout: float = SCAN_TIMEOUT pv_timeout: float = PV_TIMEOUT + ui_url: str = UI_URL @classmethod def from_env(cls, environ: Mapping[str, str] | None = None) -> Config: @@ -69,6 +71,7 @@ def seconds(name: str, default: float) -> float: return cls( blueapi_url=get("BLUEAPI_URL", BLUEAPI_URL).rstrip("/"), tiled_url=get("TILED_URL", TILED_URL).rstrip("/"), + ui_url=get("UI_URL", UI_URL).rstrip("/"), token_url=get("TOKEN_URL", TOKEN_URL), client_id=get("CLIENT_ID"), client_secret=get("CLIENT_SECRET"), @@ -173,6 +176,23 @@ def fetch_token(client: httpx.Client, config: Config) -> str: return token +def check_succeeded(task_id: str, plan: str, task: Mapping[str, Any]) -> None: + """Raise unless the complete ``task`` succeeded with no errors.""" + outcome = task.get("outcome") + errors = task.get("errors") or [] + if not isinstance(outcome, dict) or outcome.get("outcome") != "success": + if isinstance(outcome, dict) and outcome.get("outcome") == "error": + why = f"{outcome.get('type')}: {outcome.get('message')}" + else: + why = f"no success outcome ({outcome!r})" + raise CheckError(f"task {task_id} ({plan}) failed: {_shorten(why)}") + if errors: + raise CheckError( + f"task {task_id} ({plan}) reported {len(errors)} error(s): " + f"{_shorten(str(errors[0]))}" + ) + + class Blueapi: """blueapi's REST API. Requests under /api/v1 carry the bearer token. @@ -278,33 +298,17 @@ def run_plan( log.info("started task %s", task_id) while True: - task = self.get_json(f"/api/v1/tasks/{task_id}") - if not isinstance(task, dict): - raise CheckError( - f"GET /api/v1/tasks/{task_id} returned unexpected JSON" - ) + task = self.task(task_id) if task.get("is_complete") is True: break if self.clock() >= deadline: raise CheckError( f"task {task_id} ({plan}) did not complete in time" - f"{self._abort(task_id)}" + f"{self.abort(task_id)}" ) self.sleep(self.poll_interval) - outcome = task.get("outcome") - errors = task.get("errors") or [] - if not isinstance(outcome, dict) or outcome.get("outcome") != "success": - if isinstance(outcome, dict) and outcome.get("outcome") == "error": - why = f"{outcome.get('type')}: {outcome.get('message')}" - else: - why = f"no success outcome ({outcome!r})" - raise CheckError(f"task {task_id} ({plan}) failed: {_shorten(why)}") - if errors: - raise CheckError( - f"task {task_id} ({plan}) reported {len(errors)} error(s): " - f"{_shorten(str(errors[0]))}" - ) + check_succeeded(task_id, plan, task) log.info("task %s completed successfully", task_id) return task_id @@ -315,8 +319,24 @@ def _forget(self, task_id: str) -> None: except CheckError as error: log.warning("could not delete unstarted task %s: %s", task_id, error) - def _abort(self, task_id: str) -> str: - """Abort the worker's active task, only if it is ``task_id``.""" + def worker_state(self) -> str: + """The worker's state, e.g. ``IDLE`` or ``RUNNING``.""" + state = self.get_json("/api/v1/worker/state") + if not isinstance(state, str): + raise CheckError("GET /api/v1/worker/state returned unexpected JSON") + return state + + def task(self, task_id: str) -> dict[str, Any]: + task = self.get_json(f"/api/v1/tasks/{task_id}") + if not isinstance(task, dict): + raise CheckError(f"GET /api/v1/tasks/{task_id} returned unexpected JSON") + return task + + def abort(self, task_id: str) -> str: + """Abort the worker's active task, only if it is ``task_id``. + + Returns what happened, as a clause to append to an error message. + """ try: active = self.get_json("/api/v1/worker/task") if not isinstance(active, dict) or active.get("task_id") != task_id: diff --git a/src/visr_tests/ui/__init__.py b/src/visr_tests/ui/__init__.py new file mode 100644 index 0000000..5fb0411 --- /dev/null +++ b/src/visr_tests/ui/__init__.py @@ -0,0 +1 @@ +"""Checks that drive the ViSR web UI in a browser (the ``-browser`` image).""" diff --git a/src/visr_tests/ui/spectroscopy.py b/src/visr_tests/ui/spectroscopy.py new file mode 100644 index 0000000..9d87f67 --- /dev/null +++ b/src/visr_tests/ui/spectroscopy.py @@ -0,0 +1,92 @@ +"""Run a spectroscopy scan through the ViSR web UI, as a user would. + +Opens the Spectroscopy page (UI_URL) in headless Chromium, authenticated by the +service account's bearer token, presses Run with the form's default +parameters, and waits for the UI to report "Plan succeeded". blueapi's task API +then confirms the task completed successfully. + +The scan is real: it moves the sample stage. When it finishes, the UI submits +the visr-reconstruction workflow for the instrument session, as it does for any +scan. The check never queues behind another scan: if the blueapi worker is not +IDLE it fails without pressing Run. If it fails once the UI may have started +the scan, it aborts the scan the UI started, and only that. +""" + +import logging +import time +from collections.abc import Callable +from typing import Any + +from visr_tests.browser import PlanTimeoutError, Ui +from visr_tests.services import Blueapi, CheckError, Config, check_succeeded + +log = logging.getLogger(__name__) + +PLAN = "demo_spectroscopy" +PAGE = "/acquisition/spectroscopy" + + +def test_spectroscopy_ui( + config: Config, + instrument_session: str, + blueapi: Blueapi, + start_ui: Callable[[], Ui], +): + started = time.monotonic() + state = blueapi.worker_state() + if state != "IDLE": + raise CheckError( + f"the blueapi worker is {state}, not IDLE: another scan is running, " + "so this check did not submit one" + ) + + ui = start_ui() + ui.open(PAGE, instrument_session) + remaining = config.scan_timeout - (time.monotonic() - started) + try: + task_id, request = ui.run_plan(PLAN, max(remaining, 1.0) * 1000) + check_task(blueapi, instrument_session, task_id, request) + except PlanTimeoutError as timeout: + raise CheckError( + f"the UI did not report task {timeout.task_id} ({PLAN}) finished within " + f"SCAN_TIMEOUT ({config.scan_timeout:g}s){abort_own(ui, blueapi)}" + ) from None + except CheckError as error: + raise CheckError(f"{error}{abort_own(ui, blueapi)}") from None + except BaseException as error: + # not a CheckError: keep it as it is, its message unchanged + log.warning("after %s%s", type(error).__name__, abort_own(ui, blueapi)) + raise + log.info("UI scan passed in %.1fs", time.monotonic() - started) + + +def abort_own(ui: Ui, blueapi: Blueapi) -> str: + """Abort what the UI started, as a clause for an error message. + + The page may have started the task before the check failed (a slow answer + to the submission, an error the UI showed after starting it): stop it + starting any more, then abort each task it started that is still the + worker's active task. A task the page did not start is never aborted. + """ + started = ui.stop_tasks() + if not started: + return "; the UI started no task" + return "".join(blueapi.abort(task_id) for task_id in started) + + +def check_task( + blueapi: Blueapi, instrument_session: str, task_id: str, request: dict[str, Any] +) -> None: + """blueapi agrees the task the UI ran succeeded, in the right session.""" + session = request.get("instrument_session") + assert session == instrument_session, ( + f"the UI ran {PLAN} in instrument session {session!r}, " + f"not {instrument_session!r}" + ) + task = blueapi.task(task_id) + if task.get("is_complete") is not True: + raise CheckError( + f"the UI reports task {task_id} succeeded but blueapi says it is " + "not complete" + ) + check_succeeded(task_id, PLAN, task) diff --git a/tests/fake_services.py b/tests/fake_services.py index c3becd9..21d947e 100644 --- a/tests/fake_services.py +++ b/tests/fake_services.py @@ -59,6 +59,7 @@ def __init__(self) -> None: self.outcome: Any = {"outcome": "success"} self.errors: list[Any] = [] self.active_task: Any = {"task_id": TASK_ID} + self.worker_state: Any = "IDLE" self.abort_status = 202 # tiled self.tiled_healthz_status = 200 @@ -126,6 +127,8 @@ def _blueapi(self, request: httpx.Request) -> httpx.Response: ) if (method, path) == ("DELETE", f"/api/v1/tasks/{TASK_ID}"): return httpx.Response(200) + if (method, path) == ("GET", "/api/v1/worker/state"): + return httpx.Response(200, json=self.worker_state) if (method, path) == ("GET", "/api/v1/worker/task"): return httpx.Response(200, json=self.active_task) if (method, path) == ("PUT", "/api/v1/worker/state"): diff --git a/tests/fake_ui.py b/tests/fake_ui.py new file mode 100644 index 0000000..ae9337b --- /dev/null +++ b/tests/fake_ui.py @@ -0,0 +1,352 @@ +"""A fake ViSR deployment for the UI check: the built SPA plus fake backends. + +One HTTP server stands in for the UI's origin, as oauth2-proxy would present +it: it serves a built ViSR SPA (``dist``) and answers /oauth2/userinfo, +/auth/me, /api/blueapi, /api/supergraph and /api/data/events. Like oauth2-proxy +with ``skip_jwt_bearer_tokens``, it wants ``Authorization: Bearer `` on +/oauth2/userinfo, /auth/me and /api/*. It also answers the token endpoint and +blueapi's /api/v1 API, under /blueapi, for the check's own HTTP client. + +A second server is "another origin" (the identity provider, the PV camera +stream): /oauth2/start redirects to it, and the SPA's index.html is given an +image from it. It records any Authorization header it is sent. + +For the browser's own tests the UI also has /redirect (302, or ``code``, to +``to``), /blank (an empty page) and /stream (an event stream of one "hello"). +""" + +from __future__ import annotations + +import json +import mimetypes +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit + +TOKEN = "t0ken-for-the-ui-check" +CLIENT_ID = "visr-tests-ui" +CLIENT_SECRET = "s3cret-ui-client" + + +class Server(ThreadingHTTPServer): + daemon_threads = True + block_on_close = False + + +class FakeVisr: + """Both fake servers and the state of the fake blueapi worker.""" + + def __init__(self, dist: Path | None, scan_seconds: float = 2.0): + self.dist = dist + self.scan_seconds = scan_seconds + self.lock = threading.Lock() + # what the test can change + self.accept_bearer = True # False: as oauth2-proxy without the flags + self.worker = "IDLE" + self.complete_scans = True # False: a scan never finishes + self.fail_state_when_running = False # True: the UI's polls get 500 + self.foreign_task_on_submit = False # True: another task starts, 500 + # what happened + self.ui_requests: list[tuple[str, str, bool]] = [] # method, path, bearer + self.other_requests: list[tuple[str, str, str | None]] = [] + self.tasks: dict[str, dict[str, Any]] = {} + self.submitted: list[dict[str, Any]] = [] + self.workflows: list[dict[str, Any]] = [] + self.aborts: list[Any] = [] + self.events: list[dict[str, Any]] = [] + self._running: str | None = None + self._started = 0.0 + + self.ui = Server(("127.0.0.1", 0), self._handler(self._ui)) + self.other = Server(("127.0.0.1", 0), self._handler(self._other)) + self.ui_url = f"http://127.0.0.1:{self.ui.server_address[1]}" + self.other_url = f"http://localhost:{self.other.server_address[1]}" + self._threads = [ + threading.Thread(target=server.serve_forever, daemon=True) + for server in (self.ui, self.other) + ] + + def __enter__(self) -> FakeVisr: + for thread in self._threads: + thread.start() + return self + + def __exit__(self, *exc: object) -> None: + for server in (self.ui, self.other): + server.shutdown() + server.server_close() + + # --- the worker + + def _tick(self) -> None: + """Finish the running scan once it has run for scan_seconds.""" + with self.lock: + task_id = self._running + if task_id is None or not self.complete_scans: + return + if time.monotonic() - self._started < self.scan_seconds: + return + task = self.tasks[task_id] + task.update(is_complete=True, is_pending=False) + task["outcome"] = {"outcome": "success", "result": None, "type": None} + self._running = None + self.worker = "IDLE" + self.events.append(self._event("finished", task_id)) + + def _event(self, status: str, task_id: str) -> dict[str, Any]: + return { + "status": status, + "filepath": f"/data/{task_id}.nxs", + "snake": False, + "uuid": task_id, + } + + def _submit(self, body: dict[str, Any]) -> dict[str, Any]: + task_id = str(uuid.uuid4()) + with self.lock: + self.submitted.append(body) + self.tasks[task_id] = { + "task_id": task_id, + "task": {"name": body.get("name"), "params": body.get("params")}, + "request_id": None, + "is_complete": False, + "is_pending": True, + "errors": [], + "outcome": None, + } + return {"task_id": task_id} + + def _start(self, task_id: str) -> tuple[int, Any]: + with self.lock: + if task_id not in self.tasks: + return 404, {"detail": "no such task"} + if self.worker != "IDLE": + return 409, {"detail": "worker busy"} + self.worker = "RUNNING" + self._running = task_id + self._started = time.monotonic() + self.tasks[task_id]["is_pending"] = False + self.events.append(self._event("running", task_id)) + return 200, {"task_id": task_id} + + def _abort(self, body: Any) -> tuple[int, Any]: + with self.lock: + self.aborts.append(body) + if self._running: + task = self.tasks[self._running] + task.update(is_complete=True, is_pending=False) + task["outcome"] = {"outcome": "error", "type": "Abort", "message": ""} + self._running = None + self.worker = "IDLE" + return 202, "ABORTING" + + def _blueapi(self, method: str, path: str, body: Any) -> tuple[int, Any]: + """blueapi's API; ``path`` is relative to its root, without /api/v1.""" + self._tick() + if (method, path) == ("GET", "/worker/state"): + if self.fail_state_when_running and self.worker == "RUNNING": + return 500, {"detail": "worker state unavailable"} + return 200, self.worker + if (method, path) == ("PUT", "/worker/state"): + return self._abort(body) + if (method, path) == ("GET", "/worker/task"): + return 200, {"task_id": self._running} + if (method, path) == ("PUT", "/worker/task"): + return self._start(body["task_id"]) + if (method, path) == ("POST", "/tasks"): + if self.foreign_task_on_submit: + foreign = self._submit({"name": "someone_elses_plan"})["task_id"] + self.submitted.pop() + self._start(foreign) + return 500, {"detail": "submission failed"} + return 201, self._submit(body) + if method == "GET" and path.startswith("/tasks/"): + task = self.tasks.get(path.removeprefix("/tasks/")) + return (200, task) if task else (404, {"detail": "no such task"}) + if (method, path) == ("GET", "/plans"): + return 200, {"plans": []} + return 404, {"detail": "not found"} + + def _graphql(self, body: dict[str, Any]) -> tuple[int, Any]: + query = body.get("query", "") + if "submitWorkflowTemplate" in query: + with self.lock: + self.workflows.append(body.get("variables", {})) + return 200, {"data": {"submitWorkflowTemplate": {"name": "visr-recon-1"}}} + if "instrumentByKey" in query: + node = {"instrumentSessionReference": "CM99999-9"} + return 200, { + "data": { + "instrumentByKey": { + "instrumentSessions": {"edges": [{"node": node}]} + } + } + } + return 200, {"data": None, "errors": [{"message": "unknown query"}]} + + # --- HTTP + + def _handler(self, route: Any) -> type[BaseHTTPRequestHandler]: + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: # quiet + pass + + def do_GET(self) -> None: # noqa: N802 + route(self, "GET") + + def do_POST(self) -> None: # noqa: N802 + route(self, "POST") + + def do_PUT(self) -> None: # noqa: N802 + route(self, "PUT") + + def do_DELETE(self) -> None: # noqa: N802 + route(self, "DELETE") + + return Handler + + @staticmethod + def _send( + handler: BaseHTTPRequestHandler, + status: int, + body: bytes, + content_type: str, + headers: dict[str, str] | None = None, + ) -> None: + handler.send_response(status) + handler.send_header("Content-Type", content_type) + handler.send_header("Content-Length", str(len(body))) + for name, value in (headers or {}).items(): + handler.send_header(name, value) + handler.end_headers() + if handler.command != "HEAD": + handler.wfile.write(body) + + def _json(self, handler: BaseHTTPRequestHandler, status: int, body: Any) -> None: + self._send(handler, status, json.dumps(body).encode(), "application/json") + + def _other(self, handler: BaseHTTPRequestHandler, method: str) -> None: + path = urlsplit(handler.path).path + with self.lock: + self.other_requests.append( + (method, path, handler.headers.get("Authorization")) + ) + if path == "/pixel.gif": + gif = bytes.fromhex( + "47494638396101000100800000000000ffffff21f90401000000002c00000000" + "010001000002024401003b" + ) + self._send(handler, 200, gif, "image/gif") + else: + page = b"

Sign in to Diamond

" + self._send(handler, 200, page, "text/html") + + def _ui(self, handler: BaseHTTPRequestHandler, method: str) -> None: + parts = urlsplit(handler.path) + path = parts.path + authorization = handler.headers.get("Authorization") + bearer = authorization == f"Bearer {TOKEN}" + with self.lock: + self.ui_requests.append((method, path, bearer)) + length = int(handler.headers.get("Content-Length") or 0) + raw = handler.rfile.read(length) if length else b"" + body = json.loads(raw) if raw.startswith((b"{", b"[")) else None + + if path == "/redirect": + query = parse_qs(parts.query) + code = int(query.get("code", ["302"])[0]) + location = {"Location": query["to"][0]} + return self._send(handler, code, b"", "text/plain", location) + if path == "/blank": + return self._send(handler, 200, b"", "text/html") + if path == "/stream": + return self._sse(handler, [{"hello": "world"}]) + if path == "/token" and method == "POST": + return self._json(handler, 200, {"access_token": TOKEN}) + if path.startswith("/blueapi/api/v1/"): + # blueapi's own proxy, for the check's HTTP client + if not bearer: + return self._json(handler, 401, {"detail": "not authenticated"}) + status, answer = self._blueapi( + method, path.removeprefix("/blueapi/api/v1"), body + ) + return self._json(handler, status, answer) + if path == "/oauth2/start": + return self._send( + handler, 302, b"", "text/plain", {"Location": f"{self.other_url}/"} + ) + protected = path.startswith(("/api/", "/oauth2/", "/auth/")) + if protected and not (bearer and self.accept_bearer): + return self._json(handler, 401, {"error": "unauthorized"}) + if path == "/oauth2/userinfo": + return self._json( + handler, + 200, + { + "user": "service-account-visr-tests", + "email": "visr-tests@example.invalid", + "preferred_username": "service-account-visr-tests", + }, + ) + if path == "/oauth2/sign_in": + page = b"

Sign in

" + return self._send(handler, 200, page, "text/html") + if path == "/auth/me": + return self._send(handler, 200, b"", "text/plain") + if path.startswith("/api/blueapi/"): + status, answer = self._blueapi( + method, path.removeprefix("/api/blueapi"), body + ) + return self._json(handler, status, answer) + if path == "/api/supergraph": + status, answer = self._graphql(body or {}) + return self._json(handler, status, answer) + if path == "/api/data/events": + return self._sse(handler) + if path.startswith("/api/"): + return self._json(handler, 404, {"detail": "not found"}) + return self._static(handler, path) + + def _sse( + self, handler: BaseHTTPRequestHandler, first: list[Any] | None = None + ) -> None: + handler.send_response(200) + handler.send_header("Content-Type", "text/event-stream") + handler.send_header("Cache-Control", "no-cache") + handler.end_headers() + with self.lock: + sent = len(self.events) # only events from now on + try: + for event in first or []: + handler.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) + while True: + self._tick() + with self.lock: + new = self.events[sent:] + sent = len(self.events) + for event in new: + handler.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) + handler.wfile.write(b": keep-alive\n\n") + handler.wfile.flush() + time.sleep(0.2) + except OSError: + return + + def _static(self, handler: BaseHTTPRequestHandler, path: str) -> None: + assert self.dist is not None, "no built SPA to serve" + file = (self.dist / path.lstrip("/")).resolve() + if self.dist.resolve() in file.parents and file.is_file(): + kind = mimetypes.guess_type(file.name)[0] or "application/octet-stream" + return self._send(handler, 200, file.read_bytes(), kind) + # the SPA's routes, as nginx try_files does; plus an image from another + # origin, as the tomography page's PV camera stream is + page = (self.dist / "index.html").read_text() + image = f'' + page = page.replace("", f"{image}") + self._send(handler, 200, page.encode(), "text/html") diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..251ca70 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,410 @@ +"""The UI check: its helpers, and the check itself in a real browser. + +The browser tests run the check, as the CLI does, against a built ViSR SPA +served by :mod:`fake_ui`. They need the ui extra and Chromium, and a build of +the SPA at the tag the deployment uses, named by VISR_UI_DIST; without those +they are skipped, as they are in CI. To run them:: + + VISR_UI_DIST=/apps/visr/dist uv run --extra ui pytest tests/test_ui.py + +The tests of the browser's token handling need only the ui extra and Chromium. +""" + +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +import time +from collections.abc import Iterator +from pathlib import Path + +import httpx +import pytest + +from fake_services import TASK_ID, FakeServices, make +from fake_ui import CLIENT_ID, CLIENT_SECRET, TOKEN, FakeVisr +from visr_tests.browser import Ui, open_ui, origin, playwright_missing, with_bearer +from visr_tests.services import UI_URL, CheckError, Config, check_succeeded + +# --- helpers, no browser + + +def test_ui_url_default_and_env(): + assert Config.from_env({}).ui_url == UI_URL + assert Config.from_env({"UI_URL": "http://ui.test:8080/"}).ui_url == ( + "http://ui.test:8080" + ) + + +@pytest.mark.parametrize( + "url, expected", + [ + ("https://visr.diamond.ac.uk/api/x?y=1", "https://visr.diamond.ac.uk:443"), + ("HTTPS://VISR.diamond.ac.uk:443/", "https://visr.diamond.ac.uk:443"), + ("http://127.0.0.1:8080/a", "http://127.0.0.1:8080"), + ("http://localhost/a", "http://localhost:80"), + ], +) +def test_origin(url: str, expected: str): + assert origin(url) == expected + + +UI = origin("https://visr.diamond.ac.uk") + + +@pytest.mark.parametrize( + "url", + [ + "https://visr.diamond.ac.uk/", + "https://visr.diamond.ac.uk/api/blueapi/tasks", + "https://visr.diamond.ac.uk:443/oauth2/userinfo", + ], +) +def test_bearer_added_on_ui_origin(url: str): + headers = with_bearer(url, {"accept": "*/*", "Authorization": "old"}, UI, "tok") + assert headers == {"accept": "*/*", "authorization": "Bearer tok"} + + +@pytest.mark.parametrize( + "url", + [ + "https://visr-pvws.diamond.ac.uk/mjpg/BL01C-DI-DCAM-05:PVA:OUTPUT", + "https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/auth", + "http://visr.diamond.ac.uk/", # another scheme is another origin + "https://visr.diamond.ac.uk:8443/", + "https://visr.diamond.ac.uk.evil.test/", + ], +) +def test_bearer_not_sent_to_other_origins(url: str): + assert with_bearer(url, {"accept": "*/*"}, UI, "tok") is None + + +def test_worker_state_and_task(): + fake = FakeServices() + _, blueapi, _ = make(fake) + fake.polls_until_complete = 1 + assert blueapi.worker_state() == "IDLE" + fake.worker_state = "RUNNING" + assert blueapi.worker_state() == "RUNNING" + assert blueapi.task(TASK_ID)["is_complete"] is True + + +def test_worker_state_unexpected_json(): + def answer(request: httpx.Request) -> httpx.Response: + if request.url.path == "/token": + return httpx.Response(200, json={"access_token": "t"}) + return httpx.Response(200, json={"state": "IDLE"}) + + from visr_tests.services import Blueapi + + client = httpx.Client(transport=httpx.MockTransport(answer)) + blueapi = Blueapi(client, "https://blueapi.test", lambda: "t") + with pytest.raises(CheckError, match="unexpected JSON"): + blueapi.worker_state() + + +@pytest.mark.parametrize( + "task, message", + [ + ({"outcome": {"outcome": "success"}, "errors": []}, None), + ({"outcome": {"outcome": "error", "type": "E", "message": "m"}}, "E: m"), + ({"outcome": None}, "no success outcome"), + ({"outcome": {"outcome": "success"}, "errors": ["bad"]}, "1 error"), + ], +) +def test_check_succeeded(task: dict, message: str | None): + if message is None: + check_succeeded("t", "plan", task) + else: + with pytest.raises(CheckError, match=message): + check_succeeded("t", "plan", task) + + +def run_check(tmp_path: Path, **env: str) -> subprocess.CompletedProcess[str]: + keep = { + k: v + for k, v in os.environ.items() + if k + not in { + "BLUEAPI_URL", + "UI_URL", + "TOKEN_URL", + "CLIENT_ID", + "CLIENT_SECRET", + "INSTRUMENT_SESSION", + "SCAN_TIMEOUT", + } + } + return subprocess.run( + [sys.executable, "-m", "visr_tests", "run", "ui/spectroscopy"], + env={**keep, "REPORT_DIR": str(tmp_path), **env}, + capture_output=True, + text=True, + timeout=300, + ) + + +@pytest.mark.skipif( + importlib.util.find_spec("playwright") is not None, + reason="playwright is installed", +) +def test_ui_check_without_playwright_fails_clearly(tmp_path: Path): + result = run_check(tmp_path, INSTRUMENT_SESSION="cm1-1") + assert result.returncode == 1 + assert "ConfigError: playwright not installed" in result.stdout + assert "-browser image" in playwright_missing() + + +# --- the browser's token handling, against the fake servers + +needs_playwright = pytest.mark.skipif( + importlib.util.find_spec("playwright") is None, + reason="needs the ui extra (and Chromium)", +) + + +@pytest.fixture +def servers() -> Iterator[FakeVisr]: + with FakeVisr(None) as fake: + yield fake + + +@pytest.fixture +def browser(servers: FakeVisr) -> Iterator[Ui]: + with open_ui(servers.ui_url, TOKEN, timeout=10) as ui: + ui.page.goto(f"{servers.ui_url}/blank") + yield ui + + +def redirect(fake: FakeVisr, to: str, code: int = 302) -> str: + return f"{fake.ui_url}/redirect?code={code}&to={to.replace('&', '%26')}" + + +def settle(ui: Ui, fake: FakeVisr, path: str, seconds: float = 5.0) -> None: + """Let the page run until the other origin has had ``path``, or ``seconds``. + + The route runs only while Playwright is called, so wait in the page. + """ + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if any(p == path for _, p, _ in fake.other_requests): + return + ui.page.wait_for_timeout(100) + + +@needs_playwright +def test_event_stream_never_takes_bearer_through_redirect( + servers: FakeVisr, browser: Ui +): + other = f"{servers.other_url}/events" + chain = redirect(servers, redirect(servers, other)) + browser.page.evaluate( + "([a, b, c]) => { new EventSource(a); new EventSource(b);" + " fetch(c).catch(() => 0); }", + [redirect(servers, other), chain, redirect(servers, f"{other}-fetch")], + ) + settle(browser, servers, "/events-fetch") + browser.page.wait_for_timeout(1000) # time for the streams to be (wrongly) sent + assert ("GET", "/events-fetch", None) in servers.other_requests + assert all(auth is None for _, _, auth in servers.other_requests), ( + servers.other_requests + ) + redirects = [r for r in servers.ui_requests if r[1] == "/redirect"] + assert redirects and all(bearer for _, _, bearer in redirects), redirects + + +@needs_playwright +def test_event_stream_on_ui_gets_bearer_and_events(servers: FakeVisr, browser: Ui): + data = browser.page.evaluate( + """url => new Promise((resolve, reject) => { + const source = new EventSource(url); + source.onmessage = event => { source.close(); resolve(event.data); }; + setTimeout(() => reject(new Error("no event")), 5000); + })""", + "/stream", + ) + assert data == '{"hello": "world"}' + assert ("GET", "/stream", True) in servers.ui_requests + + +@needs_playwright +@pytest.mark.parametrize("code", [301, 302, 303, 307, 308]) +def test_redirect_within_ui_keeps_bearer(servers: FakeVisr, browser: Ui, code: int): + target = f"{servers.ui_url}/blank" + status = browser.page.evaluate( + "url => fetch(url, {method: 'POST', body: 'x'}).then(r => r.status)", + redirect(servers, redirect(servers, target, code), code), + ) + assert status == 200 + hops = [r for r in servers.ui_requests if r[1] in ("/redirect", "/blank")] + assert len(hops) == 4, hops # the page, two redirects, the target + assert all(bearer for _, _, bearer in hops), hops + method = "POST" if code in (307, 308) else "GET" + assert hops[-1][:2] == (method, "/blank") + + +@needs_playwright +def test_page_redirect_within_ui_keeps_bearer(servers: FakeVisr, browser: Ui): + browser.page.goto(redirect(servers, f"{servers.ui_url}/oauth2/sign_in")) + pages = [r for r in servers.ui_requests if r[1] != "/blank"] + assert pages == [("GET", "/redirect", True), ("GET", "/oauth2/sign_in", True)] + with pytest.raises(CheckError, match="went to the login page .*/oauth2/sign_in"): + browser._check_logged_in() + + +@needs_playwright +def test_no_task_starts_once_stopped(servers: FakeVisr, browser: Ui): + start = """id => fetch("/api/blueapi/worker/task", {method: "PUT", + headers: {"content-type": "application/json"}, + body: JSON.stringify({task_id: id})}).then(r => r.status, () => "failed")""" + assert browser.page.evaluate(start, "mine") == 404 # sent: no such task + assert browser.stop_tasks() == ["mine"] + assert browser.page.evaluate(start, "later") == "failed" + starts = [r for r in servers.ui_requests if r[1] == "/api/blueapi/worker/task"] + assert starts == [("PUT", "/api/blueapi/worker/task", True)] + + +# --- the check in a real browser, against the SPA and fake backends + +DIST = os.environ.get("VISR_UI_DIST", "") +SESSION = "cm12345-1" + +needs_browser = pytest.mark.skipif( + not DIST or importlib.util.find_spec("playwright") is None, + reason="needs the ui extra and VISR_UI_DIST (a built ViSR SPA)", +) + + +@pytest.fixture +def visr() -> Iterator[FakeVisr]: + dist = Path(DIST) + assert (dist / "index.html").is_file(), f"no index.html in {dist}" + with FakeVisr(dist) as fake: + yield fake + + +def check(tmp_path: Path, fake: FakeVisr, **env: str) -> subprocess.CompletedProcess: + result = run_check( + tmp_path, + UI_URL=fake.ui_url, + BLUEAPI_URL=f"{fake.ui_url}/blueapi", + TOKEN_URL=f"{fake.ui_url}/token", + CLIENT_ID=CLIENT_ID, + CLIENT_SECRET=CLIENT_SECRET, + INSTRUMENT_SESSION=SESSION, + **env, + ) + print(result.stdout[-4000:], result.stderr[-2000:]) + output = result.stdout + result.stderr + reports(tmp_path) + assert TOKEN not in output and CLIENT_SECRET not in output + return result + + +def reports(tmp_path: Path) -> str: + return "".join(path.read_text() for path in tmp_path.rglob("*.xml")) + + +def assert_bearer_only_on_ui_origin(fake: FakeVisr) -> None: + ui = [r for r in fake.ui_requests if not r[1].startswith(("/token", "/blueapi"))] + assert ui, "the browser made no requests to the UI" + assert all(bearer for _, _, bearer in ui), [r for r in ui if not r[2]] + assert fake.other_requests, "the browser made no requests to the other origin" + assert all(auth is None for _, _, auth in fake.other_requests), fake.other_requests + + +@needs_browser +def test_ui_scan_passes(tmp_path: Path, visr: FakeVisr): + result = check(tmp_path, visr) + assert result.returncode == 0 + assert len(visr.submitted) == 1 + submitted = visr.submitted[0] + assert submitted["name"] == "demo_spectroscopy" + assert submitted["instrument_session"] == SESSION + # the form's defaults at visr@0.5.4-auth-test-10 + assert submitted["params"] == { + "total_number_of_scan_points": 25, + "grid_size": 5.0, + "grid_origin_x": 0.0, + "grid_origin_y": 0.0, + "exposure_time": 0.1, + } + (task,) = visr.tasks.values() + assert task["is_complete"] and task["outcome"]["outcome"] == "success" + assert ("GET", "/blueapi/api/v1/worker/state", True) in visr.ui_requests + assert ("GET", f"/blueapi/api/v1/tasks/{task['task_id']}", True) in ( + visr.ui_requests + ) + assert not visr.aborts + # the scan's events reached the UI, which then submitted its workflow + assert ("GET", "/api/data/events", True) in visr.ui_requests + assert len(visr.workflows) == 1 + assert_bearer_only_on_ui_origin(visr) + assert not list(tmp_path.rglob("*.png")), "screenshot taken of a passing check" + + +@needs_browser +def test_ui_worker_busy_fails_without_submitting(tmp_path: Path, visr: FakeVisr): + visr.worker = "RUNNING" + result = check(tmp_path, visr) + assert result.returncode == 1 + assert "another scan is running" in result.stdout + assert "worker is RUNNING, not IDLE" in reports(tmp_path) + assert not visr.submitted + assert not any( + path == "/" or path.startswith("/acquisition") + for _, path, _ in visr.ui_requests + ) + + +@needs_browser +def test_ui_logged_out_fails_clearly(tmp_path: Path, visr: FakeVisr): + visr.accept_bearer = False + result = check(tmp_path, visr) + assert result.returncode == 1 + report = reports(tmp_path) + assert "the UI shows logged out with the service account's bearer token" in report + assert "skip_jwt_bearer_tokens = true" in report + assert not visr.submitted + assert list(tmp_path.rglob("spectroscopy.png")), "no screenshot of the failure" + assert_bearer_only_on_ui_origin(visr) + + +@needs_browser +def test_ui_scan_timeout_aborts(tmp_path: Path, visr: FakeVisr): + visr.complete_scans = False + result = check(tmp_path, visr, SCAN_TIMEOUT="8") + assert result.returncode == 1 + report = reports(tmp_path) + assert "did not report task" in report + assert "within SCAN_TIMEOUT (8s); aborted it" in report + assert len(visr.aborts) == 1 + assert list(tmp_path.rglob("spectroscopy.png")), "no screenshot of the failure" + + +@needs_browser +def test_ui_error_after_start_aborts_the_task(tmp_path: Path, visr: FakeVisr): + visr.fail_state_when_running = True + result = check(tmp_path, visr) + assert result.returncode == 1 + report = reports(tmp_path) + assert "the UI reported 'Failed to run plan" in report + assert "; aborted it" in report + assert len(visr.aborts) == 1 + (task,) = visr.tasks.values() + assert task["outcome"]["type"] == "Abort" + + +@needs_browser +def test_ui_failure_never_aborts_a_task_it_did_not_start( + tmp_path: Path, visr: FakeVisr +): + visr.foreign_task_on_submit = True + result = check(tmp_path, visr) + assert result.returncode == 1 + report = reports(tmp_path) + assert "submission of demo_spectroscopy got HTTP 500" in report + assert "the UI started no task" in report + assert not visr.aborts + assert visr.worker == "RUNNING" diff --git a/uv.lock b/uv.lock index a83a122..a04bdd3 100644 --- a/uv.lock +++ b/uv.lock @@ -246,6 +246,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/3b/b74ae9db2f78f1354278731372ae89870626be7b86cbb46b8d707acd1a98/filelock-4.0.6-py3-none-any.whl", hash = "sha256:9b139fb93b2ac5807f7feaf63aa4546fbd74074a2d3f554c040f40a4694d7b9f", size = 109077, upload-time = "2026-09-28T22:16:14.731Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d7/41511ee2696f14be4200b524d9553dc4295e2bdeb20aa8962c3cb25e71c6/greenlet-3.5.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a6a4b98a9132e0f45c9fc245a63894cfd8c45fb7a0d6bffc5eab3ec327cf7324", size = 294075, upload-time = "2026-09-14T14:25:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7b/b509624970909294cd064ff7346148ca9941c21bec9026d7873dd254e9fa/greenlet-3.5.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45bfd2b51e38aaa5f9849f114d9c7c1d75f69187c849b3549cd64c465283abfa", size = 613429, upload-time = "2026-09-14T15:12:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5c/d2eb503067f9ba20875ef8c87681f29a64f53bbbbe4059a5d7c53179d442/greenlet-3.5.6-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6dede9133e1da41d561bc3fb14e92b47e2ce39ae60edefaad145658ea7c5e2", size = 625405, upload-time = "2026-09-14T15:20:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/9b071d11c8bb9f5f38cccacc38fcc234d91997a4c395cc2bf43ecae89642/greenlet-3.5.6-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4fb8e59f68845d56c23c031dcd79c329f345e4a9d2ffac91c3d1ab366bdc457b", size = 633270, upload-time = "2026-09-14T15:25:04.864Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/63d4477ce31dff2fd802a9a20240f6606aac85977e0fb18443aae33de3f6/greenlet-3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c20ea32a73d17b9b60e3371240e17b0068120c98a5ec01a224a7dd8c89733ba", size = 624428, upload-time = "2026-09-14T14:35:56.895Z" }, + { url = "https://files.pythonhosted.org/packages/88/17/ac11883ecc9da19c681c8b763ee39e6f7dca2aa81874eb11a075d3cbeb00/greenlet-3.5.6-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d701eab36200c36224833d07dbdb709adb7fd4253429548ddb5e547b8ed40586", size = 428068, upload-time = "2026-09-14T15:28:35.872Z" }, + { url = "https://files.pythonhosted.org/packages/ad/aa/9cde4e00688eaa2a03b91d12e4681439a87e6aad860399e0847af6a014ca/greenlet-3.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a0b2791239c99992a86c1b635b787fe2a877d9eaaa26f8891ce943832b585ae", size = 1588385, upload-time = "2026-09-14T15:10:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/24632b5ec186b64e21e07a8f53ce5e15a7e9cb33eddee99a5fe16379afa5/greenlet-3.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:188bf333769b7145e2b0b4a7f09615ec550ed44d3a2a8395fb7b36f0e9901e13", size = 1653119, upload-time = "2026-09-14T14:35:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/019d2ef898f4b9ac845167f1c6f73229e9a4e2439362a5e2ce50205a19b0/greenlet-3.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:a6b4ff33f7e011bbaa148238d131c4fd4f8afbab3c104ddfbdb2b12b74ff7016", size = 323317, upload-time = "2026-09-14T14:22:38.836Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/439df999455e3bdf02b1c68f3848d4020385ef0a01f89f706b07bf148a65/greenlet-3.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:59deccd347735a7774223b05a93773fddbb298aba3cea21be4337fb4752dbe32", size = 307739, upload-time = "2026-09-14T14:23:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -537,6 +614,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/31/1521c38d175108ac395941adbcb9f11e085340bed81ad166e8ad8c21c17b/platformdirs-4.12.1-py3-none-any.whl", hash = "sha256:7f251621befae78b2cb74616f9fc99d3fa3db0afba5035987731f8dcd430337d", size = 33011, upload-time = "2026-09-28T05:05:15.06Z" }, ] +[[package]] +name = "playwright" +version = "1.63.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/dd/fbb3d34228ad753bc14464d5f2252585b73367539775a88d0a3949cf5a45/playwright-1.63.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:84c540759e8e7f01e690e197e04060f48899d37cea322299f255843273d3385d", size = 44278407, upload-time = "2026-09-15T16:49:06.045Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9a/948b930b1a8c4ee869e5a139a2b7747caa06aab56a3f09a2f0abdcbda221/playwright-1.63.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd1aa00631d44d55e56e0975bf3f3f285fac4a9fd2813183f0c12a488f1a1b24", size = 42944679, upload-time = "2026-09-15T16:49:10.039Z" }, + { url = "https://files.pythonhosted.org/packages/94/11/dc5c13fa1602371603acd461be47529c1b3513815d3a0dc98f642c291a10/playwright-1.63.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c89fc4736502a1f0fac2c8ca5d10c0cbc1c669f1f4774a2d8507a43140e4d53f", size = 44278410, upload-time = "2026-09-15T16:49:13.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/9c/103a5037789062bdab27c7dca53f3ca6b075b572ab2cd96eec825b3aec4e/playwright-1.63.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad21bc07516b187965a7521c5cf0df0bd657b17482eaad74335272d35a2b07de", size = 48217159, upload-time = "2026-09-15T16:49:17.404Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/3d85505284c5a210f2da6c07b8f757524e79d1fba9cfdafe1eafb766ae59/playwright-1.63.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:354e15b29503565fc598b89f16fbe070459343bef9d7498a93e304864000c6a7", size = 47902708, upload-time = "2026-09-15T16:49:21.055Z" }, + { url = "https://files.pythonhosted.org/packages/69/8d/f74ff6b52751859f4b69caf66aa7d4a3c8d6c1f0c7dc9920da103612ee39/playwright-1.63.0-py3-none-win32.whl", hash = "sha256:660c00c62639e31b16700ba5456b351ddba55bb766b7ce8261223aa34928e482", size = 38606075, upload-time = "2026-09-15T16:49:29.546Z" }, + { url = "https://files.pythonhosted.org/packages/76/eb/d6b8d92658038e260dbc7dd69fb3fdbe245aac283953de8b50b02bfe5f61/playwright-1.63.0-py3-none-win_amd64.whl", hash = "sha256:2f9a707a6c6c91157ed77bff2b8caeb04b3c8d46e70d585fc134298cbe4b5cc6", size = 38606082, upload-time = "2026-09-15T16:49:32.838Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/2432b3e3c7c62103b72d4c4cc8b16a56383ada372bbb0d1278591e988f71/playwright-1.63.0-py3-none-win_arm64.whl", hash = "sha256:1e4a3a838ce22fb68ad17193fcd142a19610d9d70fb9966d2239f5dddc0cc05b", size = 34523554, upload-time = "2026-09-15T16:49:36.455Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -600,6 +696,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/fd/475a3a1c60b133cb0365efdbbd8aa80f0d36dc36c5148884ec354837ca33/pvxslibs-1.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c3b89233dd18d756c16163eb55afb4dea394ff900bff9350bf423f7fa5566e21", size = 831280, upload-time = "2026-09-28T23:44:36.756Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -998,6 +1106,11 @@ dependencies = [ { name = "pytest-asyncio" }, ] +[package.optional-dependencies] +ui = [ + { name = "playwright" }, +] + [package.dev-dependencies] dev = [ { name = "pre-commit" }, @@ -1015,9 +1128,11 @@ requires-dist = [ { name = "aioca", specifier = ">=2.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "p4p", specifier = ">=4.3.0" }, + { name = "playwright", marker = "extra == 'ui'", specifier = "==1.63.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.4.0" }, ] +provides-extras = ["ui"] [package.metadata.requires-dev] dev = [