diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69f6d66..2097955 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,45 @@ jobs: working-directory: packages/headless-npm run: npm pack --dry-run + python-sdk: + name: Python SDK (${{ matrix.os }}, ${{ matrix.python }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python: ["3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: packages/headless-python/pyproject.toml + - name: Install Python SDK development tools + working-directory: packages/headless-python + run: python -m pip install -e '.[dev]' + - name: Verify generated protocol contract + working-directory: packages/headless-python + run: python scripts/generate.py --check + - name: Format and lint + working-directory: packages/headless-python + run: | + ruff check . + ruff format --check . + - name: Static typing + working-directory: packages/headless-python + run: mypy + - name: Unit and lifecycle tests + working-directory: packages/headless-python + run: pytest + - name: Build and verify exact package contents + working-directory: packages/headless-python + run: | + python -m build + python scripts/verify_package.py + protocol: name: Protocol suite runs-on: ubuntu-latest @@ -149,6 +188,11 @@ jobs: with: node-version: 22 cache: pnpm + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: packages/headless-python/pyproject.toml - run: pnpm install --frozen-lockfile --filter @lockintime/headless - name: Build app run: ./apps/headless/build.sh @@ -161,6 +205,13 @@ jobs: HEADLESS_TEST_CLI: ${{ github.workspace }}/apps/headless/Headless.app/Contents/Resources/bin/headless HEADLESS_TEST_HOST: ${{ github.workspace }}/apps/headless/Headless.app/Contents/MacOS/Headless run: node packages/headless-npm/test/macos-swift-integration.mjs + - name: Install Python SDK + run: python -m pip install ./packages/headless-python + - name: Swift CLI to Python SDK integration + env: + HEADLESS_TEST_CLI: ${{ github.workspace }}/apps/headless/Headless.app/Contents/Resources/bin/headless + HEADLESS_TEST_HOST: ${{ github.workspace }}/apps/headless/Headless.app/Contents/MacOS/Headless + run: python packages/headless-python/tests/swift_integration.py macos-e2e: name: macOS E2E (WKWebView) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml new file mode 100644 index 0000000..def3f5b --- /dev/null +++ b/.github/workflows/python-release.yml @@ -0,0 +1,102 @@ +name: Publish Python SDK + +on: + push: + tags: + - "python-v*" + pull_request: + paths: + - ".github/workflows/python-release.yml" + - "packages/headless-python/**" + - "sdk/protocol-fixtures.json" + - "sdk/protocol-schema.json" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: python-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Verify and build Python SDK + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # v7.0.1 at this verified commit. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + # v6.3.0 at this verified commit. + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: packages/headless-python/pyproject.toml + - name: Verify tag matches package version + if: startsWith(github.ref, 'refs/tags/python-v') + working-directory: packages/headless-python + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + package_version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + test "$RELEASE_TAG" = "python-v$package_version" + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main || { + echo "Python SDK releases must point to a commit already merged into main" >&2 + exit 64 + } + - name: Install development tools + working-directory: packages/headless-python + run: python -m pip install -e '.[dev]' + - name: Verify source and tests + working-directory: packages/headless-python + run: | + python scripts/generate.py --check + ruff check . + ruff format --check . + mypy + pytest + - name: Build exact distributions + working-directory: packages/headless-python + run: | + python -m build + python scripts/verify_package.py + - name: Install and import built distributions + working-directory: packages/headless-python + run: | + python -m venv "$RUNNER_TEMP/python-wheel" + "$RUNNER_TEMP/python-wheel/bin/python" -m pip install --no-deps dist/*.whl + "$RUNNER_TEMP/python-wheel/bin/python" -c 'import headless_sdk; print(headless_sdk.__version__)' + python -m venv "$RUNNER_TEMP/python-sdist" + "$RUNNER_TEMP/python-sdist/bin/python" -m pip install --no-deps dist/*.tar.gz + "$RUNNER_TEMP/python-sdist/bin/python" -c 'import headless_sdk; print(headless_sdk.__version__)' + # v7.0.1 at this verified commit. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: python-distributions + path: packages/headless-python/dist/ + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to PyPI + needs: build + if: startsWith(github.ref, 'refs/tags/python-v') + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/lockintime-headless/ + permissions: + id-token: write + steps: + # v8 at this verified commit; every action in the OIDC-enabled job is immutable. + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: python-distributions + path: dist + # release/v1 at this verified commit; keep OIDC authority immutable. + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 diff --git a/apps/headless/Dockerfile.linux b/apps/headless/Dockerfile.linux index 520be0b..9224528 100644 --- a/apps/headless/Dockerfile.linux +++ b/apps/headless/Dockerfile.linux @@ -56,7 +56,7 @@ CMD ["/usr/local/bin/headless", "help"] FROM runtime-base AS test USER root RUN apt-get update \ - && apt-get install -y --no-install-recommends busybox expect gnome-keyring \ + && apt-get install -y --no-install-recommends busybox expect gnome-keyring python3 \ && rm -rf /var/lib/apt/lists/* RUN install -d -m 0700 -o headless -g headless /run/user/10001 COPY Tests/Fixtures /opt/headless/fixtures diff --git a/apps/headless/Tests/linux-docker.sh b/apps/headless/Tests/linux-docker.sh index 58e126a..979327a 100755 --- a/apps/headless/Tests/linux-docker.sh +++ b/apps/headless/Tests/linux-docker.sh @@ -37,6 +37,12 @@ trap restore_evidence_owner EXIT INT TERM docker run --rm --name headless-p1-e2e --shm-size=1g --cap-add=SYS_ADMIN \ -e HEADLESS_EVIDENCE_DIR=/evidence -v "$EVIDENCE_DIR:/evidence" \ headless-p1-test /opt/headless/linux-e2e.sh +docker run --rm --name headless-python-sdk-integration --shm-size=1g --cap-add=SYS_ADMIN \ + -e PYTHONPATH=/opt/python-sdk/src \ + -e HEADLESS_TEST_CLI=/usr/local/bin/headless \ + -e HEADLESS_TEST_HOST=/usr/local/bin/headless-host \ + -v "$PWD/../../packages/headless-python:/opt/python-sdk:ro" \ + headless-p1-test python3 /opt/python-sdk/tests/swift_integration.py restore_evidence_owner trap - EXIT INT TERM ( diff --git a/packages/headless-python/.gitignore b/packages/headless-python/.gitignore new file mode 100644 index 0000000..94aeee4 --- /dev/null +++ b/packages/headless-python/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +build/ +dist/ +*.egg-info/ diff --git a/packages/headless-python/LICENSE b/packages/headless-python/LICENSE new file mode 100644 index 0000000..68385f9 --- /dev/null +++ b/packages/headless-python/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 LockInTime +Copyright (c) 2026 Antiwork, Inc. (original chromeless foundation) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/headless-python/README.md b/packages/headless-python/README.md new file mode 100644 index 0000000..87a9e12 --- /dev/null +++ b/packages/headless-python/README.md @@ -0,0 +1,150 @@ +# Headless Python SDK + +`lockintime-headless` is the zero-runtime-dependency Python client for the local +[Headless](https://github.com/LockInTime/headless) browser host. It supports CPython +3.11 through 3.14 on macOS and Linux. + +The SDK connects directly to Headless's private per-user Unix socket. It never opens +a TCP port, evaluates arbitrary JavaScript, or receives a credential password. The +typed API and runtime validators are generated from the repository's canonical SDK +schema. SDK versions use semantic versioning independently of the wire protocol. + +## Install + +```sh +python -m pip install lockintime-headless +``` + +The `headless` CLI must already be installed and available on `PATH` when using +`launch()`. `connect()` only attaches to an existing host and never shuts it down. + +## Synchronous client + +```python +from headless_sdk import Untrusted, connect + +with connect() as client: + client.session_create(name="research") + session = client.session("research") + page = session.visit(url="https://example.com") + assert isinstance(page, Untrusted) + print(page.value["title"]) +``` + +The equivalent CLI flow is: + +```sh +headless start --background +headless session create research +headless --session research visit https://example.com +``` + +## Asynchronous client + +```python +import asyncio + +from headless_sdk import aconnect + + +async def main() -> None: + async with await aconnect() as client: + session = await client.open_session("research") + page = await session.inspect(text=True) + print(page.value["text"]) + + +asyncio.run(main()) +``` + +Pass an `asyncio.Event` as `cancel=` or cancel the calling task. A cancellation or +timeout before any bytes are written is retry-safe. Once request bytes have been +written, timeout, cancellation, transport, framing, or response-validation failures +raise `OperationOutcomeUnknown`. Do not retry that operation until you inspect host +state. + +## Supervised host ownership + +```python +from headless_sdk import launch + +with launch(allow=["example.com"]) as host: + print(host.client.host_status["pid"]) +``` + +`launch()` runs `headless start --supervised`, keeps the owner pipe open, and grants +ownership only after the startup response PID matches the connected host PID. +Closing the wrapper terminates and reaps only that owned launcher. It cannot adopt +or stop a concurrently started shared host. Omit `presentation` to preserve the +platform default. On macOS, pass `presentation="background"` or +`presentation="foreground"` for an explicit override. Custom executable paths must +be absolute. + +## Authentication and untrusted data + +Page-derived results are wrapped in `Untrusted[T]`; validate them before using them +in privileged operations. `AUTH_REQUIRED` becomes `AuthenticationRequiredError` and +its details are also untrusted. Login accepts only a challenge plus credential alias, +or interactive mode: + +```python +from headless_sdk import AuthenticationRequiredError + +try: + session.click(role="button", name="Continue") +except AuthenticationRequiredError as error: + aliases = error.details.value["accounts"] + session.auth_login( + challenge=error.details.value["challenge"], + account=aliases[0]["alias"], + ) +``` + +There is intentionally no password parameter. Password enrollment remains a trusted +CLI or native UI operation. Sensitive cookie and storage values still require both +the command flag and the host's diagnostics environment gate. + +## Compatibility and security limits + +- The SDK requires the exact bundled wire protocol version. A mismatch fails before + result decoding. +- Capabilities are negotiated on connect. Missing commands raise + `UnsupportedCapabilityError`; the SDK does not emulate them. +- Socket paths must be direct children of `/tmp/headless-`. The directory and + socket must be owned by the current user, non-symlinks, and private (`0700` and + `0600`, respectively). +- The transport accepts exactly one newline-terminated JSON response up to 1 MiB. +- The same-user boundary prevents other OS users from connecting. It does not defend + against a malicious process already running as your OS account. +- `connect()` never owns the host. Stop shared hosts only through an explicit user + action. + +Report security problems according to the repository +[security policy](https://github.com/LockInTime/headless/security/policy). Do not put +secrets, cookies, private artifacts, or credential material in reports. + +## Release controls + +Python packages publish only from a `python-v` tag whose commit is +already merged into `main`. The GitHub `pypi` environment allows only `python-v*` tags +and requires independent reviewer approval with administrator bypass disabled. PyPI +trusted publishing is bound to that environment; no long-lived package token is stored +in the repository. + +## Development checks + +From `packages/headless-python`: + +```sh +python -m pip install -e '.[dev]' +python scripts/generate.py --check +ruff check . +ruff format --check . +mypy +pytest +python -m build +python scripts/verify_package.py +``` + +Publishing uses PyPI trusted publishing from reviewed `python-v*` tags. The package +has its own semantic version and is not released by Headless product `v*` tags. diff --git a/packages/headless-python/pyproject.toml b/packages/headless-python/pyproject.toml new file mode 100644 index 0000000..0693ad4 --- /dev/null +++ b/packages/headless-python/pyproject.toml @@ -0,0 +1,86 @@ +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "lockintime-headless" +version = "0.1.0" +description = "Typed local Python SDK for the Headless agent browser" +readme = "README.md" +requires-python = ">=3.11,<3.15" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "LockInTime" }] +keywords = ["browser-automation", "headless-browser", "sdk"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Operating System :: MacOS :: MacOS X", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [] + +[project.urls] +Homepage = "https://github.com/LockInTime/headless" +Issues = "https://github.com/LockInTime/headless/issues" +Repository = "https://github.com/LockInTime/headless.git" +Security = "https://github.com/LockInTime/headless/security/policy" + +[project.optional-dependencies] +dev = [ + "build>=1.2,<2", + "mypy>=1.15,<2", + "pytest>=8.3,<10", + "ruff>=0.11,<1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/headless_sdk"] + +[tool.hatch.build.targets.sdist] +include = [ + "/LICENSE", + "/README.md", + "/pyproject.toml", + "/src/headless_sdk", +] + +[tool.pytest.ini_options] +addopts = "-ra --strict-config --strict-markers" +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.11" +strict = true +files = ["src", "tests/type_contract.py"] +warn_unreachable = true + +[tool.ruff] +target-version = "py311" +line-length = 100 +extend-exclude = ["dist", "src/headless_sdk/generated.py"] + +[tool.ruff.lint] +select = ["A", "ASYNC", "B", "C4", "E", "F", "I", "N", "PERF", "PIE", "PL", "RUF", "SIM", "UP", "W"] +ignore = ["PLC0415"] + +[tool.ruff.lint.per-file-ignores] +"scripts/generate.py" = ["PERF401"] +"src/headless_sdk/__init__.py" = ["A004"] +"src/headless_sdk/_protocol.py" = ["PLR0911", "PLR0912"] +"src/headless_sdk/_transport.py" = ["A004", "ASYNC109", "PLR0912", "PLR0913", "PLR0915", "PLR0917"] +"src/headless_sdk/client.py" = ["ASYNC109"] +"src/headless_sdk/errors.py" = ["A001", "N818", "PLR0913"] +"src/headless_sdk/lifecycle.py" = ["ASYNC109", "PLR0912", "PLR0913", "PLR0915"] +"tests/**" = ["PLR0911", "PLR2004", "S101"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/packages/headless-python/scripts/generate.py b/packages/headless-python/scripts/generate.py new file mode 100644 index 0000000..af36d14 --- /dev/null +++ b/packages/headless-python/scripts/generate.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import keyword +import pprint +import re +from pathlib import Path +from typing import Any + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = PACKAGE_ROOT.parents[1] +SCHEMA_PATH = REPOSITORY_ROOT / "sdk" / "protocol-schema.json" +FIXTURES_PATH = REPOSITORY_ROOT / "sdk" / "protocol-fixtures.json" +OUTPUT_PATH = PACKAGE_ROOT / "src" / "headless_sdk" / "generated.py" + + +def class_name(value: str) -> str: + return "".join( + part[:1].upper() + part[1:] for part in re.split(r"[^A-Za-z0-9]+", value) if part + ) + + +def snake_name(value: str) -> str: + value = value.replace(".", "_") + value = re.sub(r"(? str: + return f"Literal[{', '.join(repr(value) for value in values)}]" + + +def parameter_type(parameter: dict[str, Any]) -> str: + values = parameter.get("values") + if isinstance(values, list) and not parameter.get("caseInsensitiveValues", False): + return literal_union(values) + return { + "boolean": "bool", + "integer": "int", + "number": "int | float", + "string": "str", + "string-array": "Sequence[str]", + }[parameter["type"]] + + +def result_type(field_type: str) -> str: + return { + "array": "list[JsonValue]", + "boolean": "bool", + "json": "JsonValue", + "number": "int | float", + "object": "dict[str, JsonValue]", + "string": "str", + "string-or-null": "str | None", + }[field_type] + + +def command_result_type(command: dict[str, Any]) -> str: + name = command["result"]["schema"]["name"] + if command["result"]["mayContainUntrustedContent"]: + return f"Untrusted[{name}]" + return name + + +def register_result_schema( + result_schemas: dict[str, dict[str, Any]], result: object, source: str +) -> None: + if not isinstance(result, dict) or not isinstance(result.get("fields"), list): + raise ValueError(f"invalid result schema: {source}") + name = result.get("name") + if not isinstance(name, str) or not name: + raise ValueError(f"invalid result schema name: {source}") + previous = result_schemas.get(name) + if previous is not None and previous != result: + raise ValueError(f"conflicting result schema: {name}") + result_schemas[name] = result + + +def emit_parameters(lines: list[str], command: dict[str, Any]) -> None: + name = f"{class_name(command['name'])}Parameters" + lines.append(f"class {name}(TypedDict):") + if not command["parameters"]: + lines.append(" pass") + for parameter in command["parameters"]: + annotation = parameter_type(parameter) + if not parameter["required"]: + annotation = f"NotRequired[{annotation}]" + lines.append(f" {parameter['name']}: {annotation}") + lines.append("") + + +def emit_result(lines: list[str], schema: dict[str, Any]) -> None: + lines.append(f"class {schema['name']}(TypedDict, total=False):") + for field in schema["fields"]: + wrapper = "Required" if field["required"] else "NotRequired" + lines.append(f" {field['name']}: {wrapper}[{result_type(field['type'])}]") + if not schema["fields"]: + lines.append(" pass") + lines.append("") + + +def emit_method(lines: list[str], command: dict[str, Any], asynchronous: bool) -> None: + prefix = "async def" if asynchronous else "def" + method = snake_name(command["name"]) + result = command_result_type(command) + lines.append(f" {prefix} {method}(") + lines.append(" self,") + lines.append(" *,") + for parameter in command["parameters"]: + annotation = parameter_type(parameter) + default = "" if parameter["required"] else " = None" + if not parameter["required"]: + annotation = f"{annotation} | None" + lines.append(f" {snake_name(parameter['name'])}: {annotation}{default},") + lines.append(" timeout: float | None = None,") + cancel_type = "AsyncCancellation" if asynchronous else "SyncCancellation" + lines.append(f" cancel: {cancel_type} | None = None,") + lines.append(f" ) -> {result}:") + if command["parameters"]: + lines.append(" parameters: dict[str, JsonValue] = {") + lines.extend( + f" {parameter['name']!r}: cast(JsonValue, {snake_name(parameter['name'])})," + for parameter in command["parameters"] + if parameter["required"] + ) + lines.append(" }") + for parameter in command["parameters"]: + if not parameter["required"]: + local = snake_name(parameter["name"]) + lines.append(f" if {local} is not None:") + lines.append( + f" parameters[{parameter['name']!r}] = cast(JsonValue, {local})" + ) + else: + lines.append(" parameters: dict[str, JsonValue] = {}") + invocation = "_invoke_async" if asynchronous else "_invoke_sync" + awaited = "await " if asynchronous else "" + invocation_line = ( + f" return cast({result}, {awaited}self.{invocation}" + f"({command['name']!r}, parameters, timeout, cancel))" + ) + lines.append(invocation_line) + lines.append("") + + +def emit_mixin( + lines: list[str], + name: str, + commands: list[dict[str, Any]], + asynchronous: bool, +) -> None: + lines.append(f"class {name}:") + invocation = "_invoke_async" if asynchronous else "_invoke_sync" + if asynchronous: + lines.extend( + [ + f" async def {invocation}(", + " self, command: CommandName, parameters: dict[str, JsonValue],", + " timeout: float | None, cancel: AsyncCancellation | None,", + " ) -> object:", + " raise NotImplementedError", + "", + ] + ) + else: + lines.extend( + [ + f" def {invocation}(", + " self, command: CommandName, parameters: dict[str, JsonValue],", + " timeout: float | None, cancel: SyncCancellation | None,", + " ) -> object:", + " raise NotImplementedError", + "", + ] + ) + for command in commands: + emit_method(lines, command, asynchronous) + + +def generate() -> str: + schema_bytes = SCHEMA_PATH.read_bytes() + fixture_bytes = FIXTURES_PATH.read_bytes() + schema = json.loads(schema_bytes) + fixtures = json.loads(fixture_bytes) + if schema.get("format") != "headless-sdk-contract" or schema.get("schemaVersion") != 1: + raise ValueError("unsupported Headless SDK schema") + if fixtures.get("schemaVersion") != 1 or fixtures.get("protocolVersion") != schema.get( + "protocolVersion" + ): + raise ValueError("protocol fixtures do not match the SDK schema") + commands = schema.get("commands") + if not isinstance(commands, list) or not commands: + raise ValueError("schema has no commands") + lifecycle = schema.get("localLifecycle", {}).get("launch", {}) + presentations = next( + ( + option.get("values") + for option in lifecycle.get("options", []) + if option.get("name") == "presentation" + ), + None, + ) + if not isinstance(presentations, list) or "background" not in presentations: + raise ValueError("schema has no launch presentation contract") + + result_schemas: dict[str, dict[str, Any]] = {} + for command in commands: + if command.get("scope") not in {"host", "session"}: + raise ValueError(f"invalid command scope: {command.get('name')}") + result = command.get("result", {}).get("schema") + register_result_schema(result_schemas, result, str(command.get("name"))) + for code, detail in schema.get("errorDetails", {}).items(): + result = detail.get("schema") + register_result_schema(result_schemas, result, f"error detail {code}") + + metadata = { + command["name"]: { + "capabilityNegotiated": command["capabilityNegotiated"], + "constraints": command["constraints"], + "parameters": command["parameters"], + "result": command["result"], + "scope": command["scope"], + "timeout": command["timeout"], + } + for command in commands + } + max_timeout = max( + max( + command["timeout"]["defaultMilliseconds"], + command["timeout"].get("maximumMilliseconds", 0), + *command["timeout"]["parameterPresentOverrides"].values(), + ) + for command in commands + ) + error_codes = schema["response"]["failure"]["error"]["codes"] + lifecycle_codes = lifecycle["errors"] + lines = [ + "# Generated by scripts/generate.py. Do not edit.", + "from __future__ import annotations", + "", + "from collections.abc import Sequence", + "from typing import Any, Literal, NotRequired, Required, TypeAlias, TypedDict, cast", + "", + "from ._types import AsyncCancellation, SyncCancellation, Untrusted", + "", + "JsonPrimitive: TypeAlias = bool | float | int | str | None", + 'JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]', + "", + f"PROTOCOL_VERSION = {schema['protocolVersion']!r}", + f"PROTOCOL_SCHEMA_VERSION = {schema['schemaVersion']}", + f"MAXIMUM_MESSAGE_BYTES = {schema['maximumMessageBytes']}", + f"MAXIMUM_COMMAND_TIMEOUT_SECONDS = {max_timeout / 1000!r}", + f"PROTOCOL_SCHEMA_SHA256 = {hashlib.sha256(schema_bytes).hexdigest()!r}", + f"PROTOCOL_FIXTURES_SHA256 = {hashlib.sha256(fixture_bytes).hexdigest()!r}", + f"RESPONSE_ADDITIONAL_PROPERTIES = {schema['response']['additionalProperties']!r}", + f"COMMAND_ERROR_CODES = {tuple(error_codes)!r}", + f"CommandErrorCode = {literal_union(error_codes)}", + f"LIFECYCLE_ERROR_CODES = {tuple(lifecycle_codes)!r}", + f"LifecycleErrorCode = {literal_union(lifecycle_codes)}", + f"LAUNCH_PRESENTATIONS = {tuple(presentations)!r}", + f"LaunchPresentation = {literal_union(presentations)}", + f"CommandName = {literal_union([command['name'] for command in commands])}", + "", + ] + for command in commands: + emit_parameters(lines, command) + for result in result_schemas.values(): + emit_result(lines, result) + lines.extend( + [ + "COMMAND_METADATA: dict[CommandName, dict[str, Any]] = " + f"{pprint.pformat(metadata, sort_dicts=True, width=100)}", + "ERROR_DETAILS_METADATA: dict[str, dict[str, Any]] = " + f"{pprint.pformat(schema['errorDetails'], sort_dicts=True, width=100)}", + "LOCAL_LIFECYCLE: dict[str, Any] = " + f"{pprint.pformat(schema['localLifecycle'], sort_dicts=True, width=100)}", + "", + "def command_timeout_seconds(", + " command: CommandName, parameters: dict[str, JsonValue]", + ") -> float:", + ' policy = COMMAND_METADATA[command]["timeout"]', + ' for parameter, milliseconds in policy["parameterPresentOverrides"].items():', + " if parameter in parameters:", + " return cast(float, milliseconds / 1000)", + ' if "parameterName" in policy:', + ' value = parameters.get(policy["parameterName"])', + " if isinstance(value, (int, float)) and not isinstance(value, bool):", + " milliseconds = max(", + ' policy["minimumMilliseconds"],', + ' min(policy["maximumMilliseconds"],', + ' value + policy["parameterGraceMilliseconds"]),', + " )", + " return cast(float, milliseconds / 1000)", + ' return cast(float, policy["defaultMilliseconds"] / 1000)', + "", + ] + ) + host_commands = [command for command in commands if command["scope"] == "host"] + session_commands = [command for command in commands if command["scope"] == "session"] + emit_mixin(lines, "SyncHostCommands", host_commands, False) + emit_mixin(lines, "SyncSessionCommands", session_commands, False) + emit_mixin(lines, "AsyncHostCommands", host_commands, True) + emit_mixin(lines, "AsyncSessionCommands", session_commands, True) + return "\n".join(lines).rstrip() + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + arguments = parser.parse_args() + output = generate() + if arguments.check: + if not OUTPUT_PATH.exists() or OUTPUT_PATH.read_text(encoding="utf-8") != output: + raise SystemExit("generated Python SDK is stale; run python scripts/generate.py") + else: + OUTPUT_PATH.write_text(output, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/packages/headless-python/scripts/verify_package.py b/packages/headless-python/scripts/verify_package.py new file mode 100644 index 0000000..4af36e0 --- /dev/null +++ b/packages/headless-python/scripts/verify_package.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import email +import sys +import tarfile +import tomllib +import zipfile +from pathlib import Path + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +VERSION = tomllib.loads((PACKAGE_ROOT / "pyproject.toml").read_text())["project"]["version"] +DIST_INFO = f"lockintime_headless-{VERSION}.dist-info" +PACKAGE_FILES = { + "headless_sdk/__init__.py", + "headless_sdk/_protocol.py", + "headless_sdk/_transport.py", + "headless_sdk/_types.py", + "headless_sdk/client.py", + "headless_sdk/errors.py", + "headless_sdk/generated.py", + "headless_sdk/lifecycle.py", + "headless_sdk/py.typed", +} +WHEEL_FILES = PACKAGE_FILES | { + f"{DIST_INFO}/METADATA", + f"{DIST_INFO}/RECORD", + f"{DIST_INFO}/WHEEL", + f"{DIST_INFO}/licenses/LICENSE", +} +SDIST_ROOT = f"lockintime_headless-{VERSION}" +SDIST_FILES = { + f"{SDIST_ROOT}/.gitignore", + f"{SDIST_ROOT}/LICENSE", + f"{SDIST_ROOT}/PKG-INFO", + f"{SDIST_ROOT}/README.md", + f"{SDIST_ROOT}/pyproject.toml", + *(f"{SDIST_ROOT}/src/{name}" for name in PACKAGE_FILES), +} + + +def require_single(pattern: str) -> Path: + matches = list((PACKAGE_ROOT / "dist").glob(pattern)) + if len(matches) != 1: + raise SystemExit(f"expected one {pattern} artifact, found {len(matches)}") + return matches[0] + + +def assert_exact(label: str, actual: set[str], expected: set[str]) -> None: + if actual == expected: + return + missing = sorted(expected - actual) + unexpected = sorted(actual - expected) + raise SystemExit(f"{label} contents differ; missing={missing}, unexpected={unexpected}") + + +def verify_metadata(raw: bytes) -> None: + metadata = email.message_from_bytes(raw) + if metadata["Name"] != "lockintime-headless" or metadata["Version"] != VERSION: + raise SystemExit("built package name or version does not match release metadata") + if metadata["Requires-Python"] != "<3.15,>=3.11": + raise SystemExit("built package lost its Python compatibility declaration") + requirements = metadata.get_all("Requires-Dist", []) + runtime = [requirement for requirement in requirements if "extra ==" not in requirement] + if runtime: + raise SystemExit(f"runtime dependencies are forbidden: {runtime}") + classifiers = set(metadata.get_all("Classifier", [])) + for version in ("3.11", "3.12", "3.13", "3.14"): + if f"Programming Language :: Python :: {version}" not in classifiers: + raise SystemExit(f"missing CPython {version} compatibility classifier") + + +def main() -> None: + wheel = require_single("*.whl") + sdist = require_single("*.tar.gz") + expected_wheel_name = f"lockintime_headless-{VERSION}-py3-none-any.whl" + if wheel.name != expected_wheel_name: + raise SystemExit(f"unexpected wheel filename: {wheel.name}") + with zipfile.ZipFile(wheel) as archive: + names = {name.rstrip("/") for name in archive.namelist() if not name.endswith("/")} + assert_exact("wheel", names, WHEEL_FILES) + verify_metadata(archive.read(f"{DIST_INFO}/METADATA")) + with tarfile.open(sdist, "r:gz") as archive: + names = {member.name for member in archive.getmembers() if member.isfile()} + assert_exact("sdist", names, SDIST_FILES) + verify_metadata(archive.extractfile(f"{SDIST_ROOT}/PKG-INFO").read()) # type: ignore[union-attr] + print(f"verified exact wheel ({len(WHEEL_FILES)} files) and sdist ({len(SDIST_FILES)} files)") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/headless-python/src/headless_sdk/__init__.py b/packages/headless-python/src/headless_sdk/__init__.py new file mode 100644 index 0000000..47ba32b --- /dev/null +++ b/packages/headless-python/src/headless_sdk/__init__.py @@ -0,0 +1,84 @@ +"""Typed Python client for the local Headless browser host.""" + +from importlib.metadata import PackageNotFoundError, version + +from ._types import AsyncCancellation, SyncCancellation, Untrusted +from .client import AsyncClient, AsyncSession, Client, Session, aconnect, connect +from .errors import ( + AuthenticationRequiredError, + CancelledBeforeSend, + ClientClosedError, + CommandError, + ConnectionError, + HeadlessError, + HostLaunchError, + MalformedResponseError, + OperationOutcomeUnknown, + ProtocolMismatchError, + ResponseIdMismatchError, + ResponseTooLargeError, + TimeoutBeforeSend, + TransportError, + UnsupportedCapabilityError, + ValidationError, +) +from .generated import ( + MAXIMUM_MESSAGE_BYTES, + PROTOCOL_FIXTURES_SHA256, + PROTOCOL_SCHEMA_SHA256, + PROTOCOL_SCHEMA_VERSION, + PROTOCOL_VERSION, + AuthenticationRequired, + CommandName, + HostStatus, + LaunchPresentation, +) +from .lifecycle import AsyncHeadlessHost, HeadlessHost, alaunch, launch + +try: + __version__ = version("lockintime-headless") +except PackageNotFoundError: + # Source checkouts used by integration tests do not have installed metadata. + __version__ = "0+source" + +__all__ = [ + "MAXIMUM_MESSAGE_BYTES", + "PROTOCOL_FIXTURES_SHA256", + "PROTOCOL_SCHEMA_SHA256", + "PROTOCOL_SCHEMA_VERSION", + "PROTOCOL_VERSION", + "AsyncCancellation", + "AsyncClient", + "AsyncHeadlessHost", + "AsyncSession", + "AuthenticationRequired", + "AuthenticationRequiredError", + "CancelledBeforeSend", + "Client", + "ClientClosedError", + "CommandError", + "CommandName", + "ConnectionError", + "HeadlessError", + "HeadlessHost", + "HostLaunchError", + "HostStatus", + "LaunchPresentation", + "MalformedResponseError", + "OperationOutcomeUnknown", + "ProtocolMismatchError", + "ResponseIdMismatchError", + "ResponseTooLargeError", + "Session", + "SyncCancellation", + "TimeoutBeforeSend", + "TransportError", + "UnsupportedCapabilityError", + "Untrusted", + "ValidationError", + "__version__", + "aconnect", + "alaunch", + "connect", + "launch", +] diff --git a/packages/headless-python/src/headless_sdk/_protocol.py b/packages/headless-python/src/headless_sdk/_protocol.py new file mode 100644 index 0000000..cf0304a --- /dev/null +++ b/packages/headless-python/src/headless_sdk/_protocol.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import json +import re +import uuid +from collections.abc import Mapping, Sequence +from typing import Any, cast + +from ._types import Untrusted, is_finite_number +from .errors import ( + AuthenticationRequiredError, + CommandError, + MalformedResponseError, + ProtocolMismatchError, + ResponseIdMismatchError, + UnsupportedCapabilityError, + ValidationError, +) +from .generated import ( + COMMAND_METADATA, + ERROR_DETAILS_METADATA, + MAXIMUM_MESSAGE_BYTES, + PROTOCOL_VERSION, + RESPONSE_ADDITIONAL_PROPERTIES, + AuthenticationRequired, + CommandName, + JsonValue, +) + +_SESSION_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$") +_ENVELOPE_FIELDS = frozenset({"id", "version", "ok", "result", "error"}) +_MAXIMUM_SESSION_BYTES = 64 +_MAXIMUM_REQUEST_ID_BYTES = 128 + + +def _utf8_length(value: str) -> int: + return len(value.encode("utf-8")) + + +def _is_json_value(value: object) -> bool: + pending = [value] + inspected = 0 + while pending: + if inspected >= MAXIMUM_MESSAGE_BYTES: + return False + inspected += 1 + candidate = pending.pop() + if candidate is None or isinstance(candidate, (str, bool)): + continue + if is_finite_number(candidate): + continue + if isinstance(candidate, list): + pending.extend(candidate) + continue + if isinstance(candidate, dict) and all(isinstance(key, str) for key in candidate): + pending.extend(candidate.values()) + continue + return False + return True + + +def _record(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise MalformedResponseError(f"{label} must be an object") + return value + + +def _reject_nonstandard_json_constant(value: str) -> None: + raise ValueError(f"non-standard JSON constant {value}") + + +def validate_session(session: str) -> None: + if ( + not isinstance(session, str) + or not session + or _utf8_length(session) > _MAXIMUM_SESSION_BYTES + or _SESSION_PATTERN.fullmatch(session) is None + ): + raise ValidationError( + "session must contain 1 to 64 bytes using only letters, digits, dot, " + "underscore, or hyphen" + ) + + +def _validate_parameter(command: str, definition: Mapping[str, Any], value: object) -> None: + name = cast(str, definition["name"]) + label = f"{command}.{name}" + value_type = definition["type"] + if value_type == "string": + if not isinstance(value, str): + raise ValidationError(f"{label} must be a string") + if definition.get("required") is True and not value: + raise ValidationError(f"{label} must not be empty") + minimum = definition.get("minimumBytes") + maximum = definition.get("maximumBytes") + if isinstance(minimum, int) and _utf8_length(value) < minimum: + raise ValidationError(f"{label} must contain at least {minimum} UTF-8 bytes") + if isinstance(maximum, int) and _utf8_length(value) > maximum: + raise ValidationError(f"{label} exceeds {maximum} UTF-8 bytes") + values = definition.get("values") + if isinstance(values, list): + candidate = value.lower() if definition.get("caseInsensitiveValues") is True else value + if candidate not in values: + raise ValidationError(f"{label} must be one of {', '.join(values)}") + return + if value_type == "boolean": + if not isinstance(value, bool): + raise ValidationError(f"{label} must be a boolean") + return + if value_type in {"integer", "number"}: + if not is_finite_number(value) or (value_type == "integer" and not isinstance(value, int)): + raise ValidationError(f"{label} must be a finite {value_type}") + minimum = definition.get("minimum") + maximum = definition.get("maximum") + if isinstance(minimum, (int, float)) and value < minimum: + raise ValidationError(f"{label} must be at least {minimum}") + if isinstance(maximum, (int, float)) and value > maximum: + raise ValidationError(f"{label} must be at most {maximum}") + return + if value_type == "string-array": + if ( + not isinstance(value, Sequence) + or isinstance(value, (str, bytes)) + or any(not isinstance(item, str) or not item for item in value) + ): + raise ValidationError(f"{label} must be an array of non-empty strings") + maximum_items = definition.get("maximumItems") + if isinstance(maximum_items, int) and len(value) > maximum_items: + raise ValidationError(f"{label} exceeds {maximum_items} items") + item_maximum = definition.get("itemMaximumBytes") + if isinstance(item_maximum, int) and any( + _utf8_length(item) > item_maximum for item in value + ): + raise ValidationError(f"{label} contains an item exceeding {item_maximum} UTF-8 bytes") + return + raise ValidationError(f"unsupported generated parameter type for {label}") + + +def validate_parameters(command: CommandName, parameters: Mapping[str, object]) -> None: + metadata = COMMAND_METADATA.get(command) + if metadata is None: + raise ValidationError(f"unknown Headless command: {command}") + if not isinstance(parameters, dict): + raise ValidationError(f"{command} parameters must be a plain dictionary") + definitions = cast(list[dict[str, Any]], metadata["parameters"]) + known = {cast(str, definition["name"]): definition for definition in definitions} + unknown = next((key for key in parameters if key not in known), None) + if unknown is not None: + raise ValidationError(f"{command} received unknown parameter {unknown}") + for name, definition in known.items(): + if name not in parameters: + if definition["required"] is True: + raise ValidationError(f"{command} requires {name}") + else: + _validate_parameter(command, definition, parameters[name]) + if command == "session.create": + validate_session(cast(str, parameters["name"])) + + +def create_request( + command: CommandName, + parameters: dict[str, JsonValue], + session: str | None = None, + request_id: str | None = None, +) -> dict[str, JsonValue]: + validate_parameters(command, parameters) + identifier = str(uuid.uuid4()) if request_id is None else request_id + if ( + not isinstance(identifier, str) + or not identifier + or _utf8_length(identifier) > _MAXIMUM_REQUEST_ID_BYTES + ): + raise ValidationError("request id is invalid") + if session is not None: + validate_session(session) + if COMMAND_METADATA[command]["scope"] != "session": + raise ValidationError(f"{command} is host-scoped and cannot target a session") + request: dict[str, JsonValue] = { + "id": identifier, + "version": PROTOCOL_VERSION, + "command": command, + "parameters": parameters, + } + if session is not None: + request["session"] = session + return request + + +def encode_request(request: Mapping[str, JsonValue]) -> bytes: + try: + encoded = ( + json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n" + ) + except (TypeError, ValueError) as error: + raise ValidationError("request could not be encoded as JSON") from error + if len(encoded) > MAXIMUM_MESSAGE_BYTES: + raise ValidationError(f"request exceeds the {MAXIMUM_MESSAGE_BYTES}-byte frame limit") + if encoded.count(b"\n") != 1 or not encoded.endswith(b"\n"): + raise ValidationError("request must contain exactly one terminal newline frame") + return encoded + + +def _valid_field(field_type: str, value: object) -> bool: + if field_type == "array": + return isinstance(value, list) and _is_json_value(value) + if field_type == "boolean": + return isinstance(value, bool) + if field_type == "json": + return _is_json_value(value) + if field_type == "number": + return is_finite_number(value) + if field_type == "object": + return isinstance(value, dict) and _is_json_value(value) + if field_type == "string": + return isinstance(value, str) + if field_type == "string-or-null": + return value is None or isinstance(value, str) + return False + + +def _validate_object_schema(label: str, schema: Mapping[str, Any], value: object) -> dict[str, Any]: + record = _record(value, label) + fields = cast(list[dict[str, Any]], schema["fields"]) + known = {cast(str, field["name"]) for field in fields} + for field in fields: + name = cast(str, field["name"]) + if name not in record: + if field["required"] is True: + raise MalformedResponseError(f"{label} is missing {name}") + elif not _valid_field(cast(str, field["type"]), record[name]): + raise MalformedResponseError(f"{label} has invalid {name}") + if schema["additionalProperties"] is False: + unknown = next((name for name in record if name not in known), None) + if unknown is not None: + raise MalformedResponseError(f"{label} has unknown field {unknown}") + if not _is_json_value(record): + raise MalformedResponseError(f"{label} is not valid JSON") + return record + + +def decode_response(frame: bytes, expected_id: str, command: CommandName) -> object: + try: + value = json.loads( + frame.decode("utf-8"), + parse_constant=_reject_nonstandard_json_constant, + ) + except (UnicodeDecodeError, ValueError, RecursionError) as error: + raise MalformedResponseError("Headless returned malformed JSON") from error + response = _record(value, "Headless response") + if not RESPONSE_ADDITIONAL_PROPERTIES: + unknown = next((name for name in response if name not in _ENVELOPE_FIELDS), None) + if unknown is not None: + raise MalformedResponseError(f"Headless response has unknown field {unknown}") + version = response.get("version") + if not isinstance(version, str): + raise MalformedResponseError("Headless response is missing its protocol version") + if version != PROTOCOL_VERSION: + raise ProtocolMismatchError(PROTOCOL_VERSION, version) + identifier = response.get("id") + if not isinstance(identifier, str): + raise MalformedResponseError("Headless response is missing its id") + if identifier != expected_id: + raise ResponseIdMismatchError(expected_id, identifier) + if not isinstance(response.get("ok"), bool): + raise MalformedResponseError("Headless response is missing ok") + if response["ok"] is False: + if response.get("result") is not None: + raise MalformedResponseError("failed Headless response contains a result") + error_record = _record(response.get("error"), "failed Headless response error") + code = error_record.get("code") + message = error_record.get("message") + if not isinstance(code, str) or not isinstance(message, str): + raise MalformedResponseError("failed Headless response has an invalid error") + suggestion = error_record.get("suggestion") + if suggestion is not None and not isinstance(suggestion, str): + raise MalformedResponseError("response error suggestion must be a string") + details = error_record.get("details") + if details is not None and not _is_json_value(details): + raise MalformedResponseError("response error details are not valid JSON") + if code == "UNSUPPORTED_CAPABILITY": + raise UnsupportedCapabilityError(command, message, suggestion, details) + if code == "AUTH_REQUIRED": + validated = _validate_object_schema( + "AUTH_REQUIRED details", + cast(dict[str, Any], ERROR_DETAILS_METADATA["AUTH_REQUIRED"]["schema"]), + details, + ) + raise AuthenticationRequiredError( + message, + suggestion, + Untrusted(cast(AuthenticationRequired, validated)), + ) + raise CommandError(code, message, suggestion, details) + if response.get("error") is not None: + raise MalformedResponseError("successful Headless response contains an error") + metadata = cast(dict[str, Any], COMMAND_METADATA[command]["result"]) + result = _validate_object_schema( + f"{command} result", cast(dict[str, Any], metadata["schema"]), response.get("result") + ) + if metadata["mayContainUntrustedContent"] is True: + return Untrusted(result) + return result diff --git a/packages/headless-python/src/headless_sdk/_transport.py b/packages/headless-python/src/headless_sdk/_transport.py new file mode 100644 index 0000000..7f364b3 --- /dev/null +++ b/packages/headless-python/src/headless_sdk/_transport.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import asyncio +import contextlib +import errno +import os +import selectors +import socket +import stat +import threading +import time +from collections.abc import Awaitable, Mapping +from typing import Any, TypeVar + +from ._types import AsyncCancellation, SyncCancellation, is_finite_number +from .errors import ( + CancelledBeforeSend, + ClientClosedError, + ConnectionError, + MalformedResponseError, + OperationOutcomeUnknown, + ResponseTooLargeError, + TimeoutBeforeSend, + ValidationError, +) +from .generated import MAXIMUM_COMMAND_TIMEOUT_SECONDS, MAXIMUM_MESSAGE_BYTES + +T = TypeVar("T") +_CONNECTING = {errno.EINPROGRESS, errno.EALREADY, errno.EWOULDBLOCK} + + +def runtime_directory() -> str: + if not hasattr(os, "getuid"): + raise ConnectionError("Headless local transport requires a Unix-like operating system") + return f"/tmp/headless-{os.getuid()}" + + +def validate_socket_location(socket_path: str, label: str = "socket_path") -> None: + if not isinstance(socket_path, str) or not os.path.isabs(socket_path): + raise ValidationError(f"{label} must be absolute") + normalized = os.path.abspath(socket_path) + if normalized != socket_path or os.path.dirname(normalized) != runtime_directory(): + raise ValidationError( + f"{label} must be a direct child of the Headless runtime directory " + f"{runtime_directory()}" + ) + + +def default_socket_path(environment: Mapping[str, str] | None = None) -> str: + selected = os.environ if environment is None else environment + override = selected.get("HEADLESS_SOCKET") + if override is not None: + validate_socket_location(override, "HEADLESS_SOCKET") + return override + return os.path.join(runtime_directory(), "host.sock") + + +def validate_private_socket(socket_path: str) -> None: + validate_socket_location(socket_path) + user_id = os.getuid() + try: + parent = os.lstat(os.path.dirname(socket_path)) + except OSError as error: + raise ConnectionError("Headless runtime directory is unavailable") from error + if ( + not stat.S_ISDIR(parent.st_mode) + or stat.S_ISLNK(parent.st_mode) + or parent.st_uid != user_id + or stat.S_IMODE(parent.st_mode) & 0o077 + ): + raise ConnectionError("Headless runtime directory is not private to the current user") + try: + endpoint = os.lstat(socket_path) + except OSError as error: + raise ConnectionError("Headless host is not running") from error + if ( + not stat.S_ISSOCK(endpoint.st_mode) + or stat.S_ISLNK(endpoint.st_mode) + or endpoint.st_uid != user_id + or stat.S_IMODE(endpoint.st_mode) & 0o077 + ): + raise ConnectionError("Headless socket is not private to the current user") + + +def _validate_request(frame: bytes, timeout: float) -> None: + if not is_finite_number(timeout) or timeout <= 0 or timeout > MAXIMUM_COMMAND_TIMEOUT_SECONDS: + raise ValidationError( + "timeout must be greater than zero and at most " + f"{MAXIMUM_COMMAND_TIMEOUT_SECONDS} seconds" + ) + if len(frame) > MAXIMUM_MESSAGE_BYTES: + raise ValidationError(f"request exceeds the {MAXIMUM_MESSAGE_BYTES}-byte frame limit") + if not frame.endswith(b"\n") or frame.count(b"\n") != 1: + raise ValidationError("request must contain exactly one terminal newline frame") + + +def _certainty_error( + request_id: str, + sent: bool, + reason: str, + cause: BaseException | None = None, +) -> BaseException: + if sent: + return OperationOutcomeUnknown(request_id, reason, cause) + if reason == "cancelled": + return CancelledBeforeSend() + if reason == "timed-out": + return TimeoutBeforeSend() + if isinstance(cause, BaseException): + return ConnectionError("could not connect to the Headless host") + return ConnectionError("Headless connection failed before the request was sent") + + +class SyncUnixSocketTransport: + def __init__(self, socket_path: str | None = None) -> None: + self.socket_path = default_socket_path() if socket_path is None else socket_path + validate_socket_location(self.socket_path) + self._closed = False + self._active: set[socket.socket] = set() + self._lock = threading.Lock() + + def _ensure_open(self) -> None: + if self._closed: + raise ClientClosedError() + + def _wait_ready( + self, + endpoint: socket.socket, + event: int, + deadline: float, + cancel: SyncCancellation | None, + request_id: str, + sent: bool, + ) -> None: + with selectors.DefaultSelector() as selector: + selector.register(endpoint, event) + while True: + if cancel is not None and cancel.is_set(): + raise _certainty_error(request_id, sent, "cancelled") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise _certainty_error(request_id, sent, "timed-out") + wait = min(remaining, 0.05) if cancel is not None else remaining + if selector.select(wait): + return + + def send( + self, + frame: bytes, + request_id: str, + timeout: float, + cancel: SyncCancellation | None = None, + ) -> bytes: + self._ensure_open() + _validate_request(frame, timeout) + if cancel is not None and cancel.is_set(): + raise CancelledBeforeSend() + validate_private_socket(self.socket_path) + self._ensure_open() + deadline = time.monotonic() + timeout + endpoint = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + endpoint.setblocking(False) + with self._lock: + if self._closed: + endpoint.close() + raise ClientClosedError() + self._active.add(endpoint) + sent = False + try: + result = endpoint.connect_ex(self.socket_path) + if result not in {0, errno.EISCONN}: + if result not in _CONNECTING: + raise OSError(result, os.strerror(result)) + self._wait_ready( + endpoint, selectors.EVENT_WRITE, deadline, cancel, request_id, sent + ) + result = endpoint.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) + if result: + raise OSError(result, os.strerror(result)) + offset = 0 + while offset < len(frame): + if cancel is not None and cancel.is_set(): + raise _certainty_error(request_id, sent, "cancelled") + if time.monotonic() >= deadline: + raise _certainty_error(request_id, sent, "timed-out") + try: + written = endpoint.send(frame[offset:]) + except BlockingIOError: + self._wait_ready( + endpoint, selectors.EVENT_WRITE, deadline, cancel, request_id, sent + ) + continue + if written <= 0: + raise OSError("socket write made no progress") + sent = True + offset += written + + response = bytearray() + while True: + self._wait_ready(endpoint, selectors.EVENT_READ, deadline, cancel, request_id, sent) + chunk = endpoint.recv(64 * 1024) + if not chunk: + break + response.extend(chunk) + newline = response.find(b"\n") + if len(response) > MAXIMUM_MESSAGE_BYTES or ( + len(response) == MAXIMUM_MESSAGE_BYTES and newline < 0 + ): + raise OperationOutcomeUnknown( + request_id, + "read-failed", + ResponseTooLargeError(MAXIMUM_MESSAGE_BYTES), + ) + if newline >= 0 and newline != len(response) - 1: + raise OperationOutcomeUnknown( + request_id, + "read-failed", + MalformedResponseError("Headless returned more than one response frame"), + ) + newline = response.find(b"\n") + if newline < 0: + message = ( + "Headless response was empty" + if not response + else "Headless response did not end with a newline" + ) + raise OperationOutcomeUnknown( + request_id, "read-failed", MalformedResponseError(message) + ) + return bytes(response[:newline]) + except (CancelledBeforeSend, TimeoutBeforeSend, OperationOutcomeUnknown): + raise + except OSError as error: + raise _certainty_error(request_id, sent, "read-failed", error) from error + finally: + with self._lock: + self._active.discard(endpoint) + endpoint.close() + + def close(self) -> None: + with self._lock: + if self._closed: + return + self._closed = True + active = tuple(self._active) + for endpoint in active: + endpoint.close() + + def _after_fork_child(self) -> None: + active = tuple(self._active) + self._active.clear() + self._closed = True + self._lock = threading.Lock() + for endpoint in active: + endpoint.close() + + +class AsyncUnixSocketTransport: + def __init__(self, socket_path: str | None = None) -> None: + self.socket_path = default_socket_path() if socket_path is None else socket_path + validate_socket_location(self.socket_path) + self._closed = False + self._active: set[asyncio.StreamWriter] = set() + + async def _await_step( + self, + awaitable: Awaitable[T], + deadline: float, + cancel: AsyncCancellation | None, + request_id: str, + sent: bool, + ) -> T: + operation = asyncio.ensure_future(awaitable) + cancellation = asyncio.create_task(cancel.wait()) if cancel is not None else None + tasks: set[asyncio.Future[Any]] = {operation} + if cancellation is not None: + tasks.add(cancellation) + try: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise _certainty_error(request_id, sent, "timed-out") + done, _ = await asyncio.wait( + tasks, timeout=remaining, return_when=asyncio.FIRST_COMPLETED + ) + if cancellation is not None and cancellation in done: + operation.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await operation + raise _certainty_error(request_id, sent, "cancelled") + if operation in done: + return operation.result() + operation.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await operation + raise _certainty_error(request_id, sent, "timed-out") + except asyncio.CancelledError as error: + operation.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await operation + raise _certainty_error(request_id, sent, "cancelled", error) from error + finally: + if cancellation is not None: + cancellation.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cancellation + + async def send( + self, + frame: bytes, + request_id: str, + timeout: float, + cancel: AsyncCancellation | None = None, + ) -> bytes: + if self._closed: + raise ClientClosedError() + _validate_request(frame, timeout) + if cancel is not None and cancel.is_set(): + raise CancelledBeforeSend() + validate_private_socket(self.socket_path) + if self._closed: + raise ClientClosedError() + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + sent = False + writer: asyncio.StreamWriter | None = None + try: + reader, connected_writer = await self._await_step( + asyncio.open_unix_connection(self.socket_path), + deadline, + cancel, + request_id, + sent, + ) + writer = connected_writer + if self._closed: + raise ClientClosedError() + if cancel is not None and cancel.is_set(): + raise CancelledBeforeSend() + self._active.add(writer) + writer.write(frame) + sent = True + await self._await_step(writer.drain(), deadline, cancel, request_id, sent) + response = bytearray() + while True: + chunk = await self._await_step( + reader.read(64 * 1024), deadline, cancel, request_id, sent + ) + if not chunk: + break + response.extend(chunk) + newline = response.find(b"\n") + if len(response) > MAXIMUM_MESSAGE_BYTES or ( + len(response) == MAXIMUM_MESSAGE_BYTES and newline < 0 + ): + raise OperationOutcomeUnknown( + request_id, + "read-failed", + ResponseTooLargeError(MAXIMUM_MESSAGE_BYTES), + ) + if newline >= 0 and newline != len(response) - 1: + raise OperationOutcomeUnknown( + request_id, + "read-failed", + MalformedResponseError("Headless returned more than one response frame"), + ) + newline = response.find(b"\n") + if newline < 0: + message = ( + "Headless response was empty" + if not response + else "Headless response did not end with a newline" + ) + raise OperationOutcomeUnknown( + request_id, "read-failed", MalformedResponseError(message) + ) + return bytes(response[:newline]) + except (CancelledBeforeSend, TimeoutBeforeSend, OperationOutcomeUnknown): + raise + except OSError as error: + raise _certainty_error(request_id, sent, "read-failed", error) from error + finally: + if writer is not None: + self._active.discard(writer) + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + + async def close(self) -> None: + if self._closed: + return + self._closed = True + active = tuple(self._active) + for writer in active: + writer.close() + for writer in active: + with contextlib.suppress(Exception): + await writer.wait_closed() + self._active.clear() + + def _after_fork_child(self) -> None: + self._closed = True + active = tuple(self._active) + self._active.clear() + for writer in active: + writer.close() diff --git a/packages/headless-python/src/headless_sdk/_types.py b/packages/headless-python/src/headless_sdk/_types.py new file mode 100644 index 0000000..d47e426 --- /dev/null +++ b/packages/headless-python/src/headless_sdk/_types.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Generic, Protocol, TypeGuard, TypeVar + +T = TypeVar("T") + + +def is_finite_number(value: object) -> TypeGuard[int | float]: + if not isinstance(value, (int, float)) or isinstance(value, bool): + return False + try: + return math.isfinite(value) + except OverflowError: + return False + + +@dataclass(frozen=True, slots=True) +class Untrusted(Generic[T]): + """A value containing page-derived data that must remain untrusted.""" + + value: T + untrusted_content: bool = True + + +class SyncCancellation(Protocol): + def is_set(self) -> bool: ... + + +class AsyncCancellation(Protocol): + def is_set(self) -> bool: ... + + async def wait(self) -> bool: ... diff --git a/packages/headless-python/src/headless_sdk/client.py b/packages/headless-python/src/headless_sdk/client.py new file mode 100644 index 0000000..b999455 --- /dev/null +++ b/packages/headless-python/src/headless_sdk/client.py @@ -0,0 +1,527 @@ +from __future__ import annotations + +import asyncio +import contextlib +import threading +import time +from concurrent.futures import Future +from concurrent.futures import TimeoutError as FutureTimeoutError +from typing import Any, cast + +from ._protocol import create_request, decode_response, encode_request, validate_session +from ._transport import AsyncUnixSocketTransport, SyncUnixSocketTransport, default_socket_path +from ._types import AsyncCancellation, SyncCancellation, is_finite_number +from .errors import ( + CancelledBeforeSend, + ClientClosedError, + CommandError, + MalformedResponseError, + OperationOutcomeUnknown, + TimeoutBeforeSend, + UnsupportedCapabilityError, + ValidationError, +) +from .generated import ( + COMMAND_METADATA, + MAXIMUM_COMMAND_TIMEOUT_SECONDS, + PROTOCOL_VERSION, + AsyncHostCommands, + AsyncSessionCommands, + CommandName, + HostStatus, + JsonValue, + SessionClose, + SyncHostCommands, + SyncSessionCommands, + command_timeout_seconds, +) + + +def _request_timeout( + command: CommandName, parameters: dict[str, JsonValue], requested: float | None +) -> float: + if requested is None: + return command_timeout_seconds(command, parameters) + if ( + not is_finite_number(requested) + or requested <= 0 + or requested > MAXIMUM_COMMAND_TIMEOUT_SECONDS + ): + raise ValidationError( + "timeout must be greater than zero and at most " + f"{MAXIMUM_COMMAND_TIMEOUT_SECONDS} seconds" + ) + return requested + + +def _supported_commands(status: HostStatus) -> frozenset[CommandName]: + capabilities = status["capabilities"] + commands = capabilities.get("commands") + if not isinstance(commands, list) or any(not isinstance(command, str) for command in commands): + raise MalformedResponseError("host capabilities do not declare supported commands") + known = frozenset(COMMAND_METADATA) + return frozenset(command for command in commands if command in known) + + +def _validate_status(status: HostStatus) -> frozenset[CommandName]: + if status["protocolVersion"] != PROTOCOL_VERSION: + raise MalformedResponseError( + "ping result protocolVersion does not match the response envelope" + ) + return _supported_commands(status) + + +class Client(SyncHostCommands): + def __init__(self, socket_path: str | None = None) -> None: + self.socket_path = default_socket_path() if socket_path is None else socket_path + self._transport = SyncUnixSocketTransport(self.socket_path) + self._host_status: HostStatus | None = None + self._supported: frozenset[CommandName] | None = None + self._closed = False + + @property + def host_status(self) -> HostStatus: + if self._host_status is None: + raise ValidationError("connect() must complete before reading host status") + return self._host_status + + @property + def capabilities(self) -> dict[str, JsonValue]: + return self.host_status["capabilities"] + + def connect( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Client: + status = self.ping(timeout=timeout, cancel=cancel) + supported = _validate_status(status) + self._host_status = status + self._supported = supported + return self + + def request( + self, + command: CommandName, + parameters: dict[str, JsonValue], + *, + session: str | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> object: + if self._closed: + raise ClientClosedError() + if session is not None: + validate_session(session) + if command != "ping" and self._supported is None: + raise ValidationError("connect() must complete before browser commands are sent") + if command != "ping" and self._supported is not None and command not in self._supported: + raise UnsupportedCapabilityError(command) + request = create_request(command, parameters, session) + request_id = cast(str, request["id"]) + frame = self._transport.send( + encode_request(request), + request_id, + _request_timeout(command, parameters, timeout), + cancel, + ) + try: + result = decode_response(frame, request_id, command) + if command == "ping": + _validate_status(cast(HostStatus, result)) + return result + except CommandError: + raise + except MalformedResponseError as error: + raise OperationOutcomeUnknown(request_id, "read-failed", error) from error + + def session(self, name: str) -> Session: + validate_session(name) + return Session(self, name) + + def open_session( + self, + name: str, + *, + isolated: bool | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Session: + self.session_create(name=name, isolated=isolated, timeout=timeout, cancel=cancel) + return self.session(name) + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._transport.close() + + def _after_fork_child(self) -> None: + self._closed = True + self._transport._after_fork_child() + + def __enter__(self) -> Client: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def _invoke_sync( + self, + command: CommandName, + parameters: dict[str, JsonValue], + timeout: float | None, + cancel: SyncCancellation | None, + ) -> object: + return self.request(command, parameters, timeout=timeout, cancel=cancel) + + +class Session(SyncSessionCommands): + def __init__(self, client: Client, name: str) -> None: + validate_session(name) + self._client = client + self.name = name + self._closed = False + self._close_lock = threading.Lock() + self._close_future: Future[SessionClose] | None = None + + def session_close( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> SessionClose: + with self._close_lock: + leader = self._close_future is None + if leader: + self._close_future = Future() + close_future = self._close_future + assert close_future is not None + if not leader: + if close_future.done(): + return close_future.result() + deadline = time.monotonic() + _request_timeout("session.close", {}, timeout) + while True: + if cancel is not None and cancel.is_set(): + raise CancelledBeforeSend() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutBeforeSend() + wait = min(remaining, 0.05) if cancel is not None else remaining + try: + return close_future.result(timeout=wait) + except FutureTimeoutError: + continue + try: + result = cast( + SessionClose, + self._client.request( + "session.close", {}, session=self.name, timeout=timeout, cancel=cancel + ), + ) + self._closed = True + close_future.set_result(result) + return result + except BaseException as error: + if isinstance(error, OperationOutcomeUnknown): + self._closed = True + close_future.set_exception(error) + if not isinstance(error, OperationOutcomeUnknown): + with self._close_lock: + if self._close_future is close_future: + self._close_future = None + raise + + def close( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> None: + if self._closed: + return + self.session_close(timeout=timeout, cancel=cancel) + + def __enter__(self) -> Session: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def _invoke_sync( + self, + command: CommandName, + parameters: dict[str, JsonValue], + timeout: float | None, + cancel: SyncCancellation | None, + ) -> object: + with self._close_lock: + closing = self._close_future is not None + if self._closed or closing: + raise ClientClosedError() + return self._client.request( + command, parameters, session=self.name, timeout=timeout, cancel=cancel + ) + + +class AsyncClient(AsyncHostCommands): + def __init__(self, socket_path: str | None = None) -> None: + self.socket_path = default_socket_path() if socket_path is None else socket_path + self._transport = AsyncUnixSocketTransport(self.socket_path) + self._host_status: HostStatus | None = None + self._supported: frozenset[CommandName] | None = None + self._closed = False + + @property + def host_status(self) -> HostStatus: + if self._host_status is None: + raise ValidationError("aconnect() must complete before reading host status") + return self._host_status + + @property + def capabilities(self) -> dict[str, JsonValue]: + return self.host_status["capabilities"] + + async def connect( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> AsyncClient: + status = await self.ping(timeout=timeout, cancel=cancel) + supported = _validate_status(status) + self._host_status = status + self._supported = supported + return self + + async def request( + self, + command: CommandName, + parameters: dict[str, JsonValue], + *, + session: str | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> object: + if self._closed: + raise ClientClosedError() + if session is not None: + validate_session(session) + if command != "ping" and self._supported is None: + raise ValidationError("aconnect() must complete before browser commands are sent") + if command != "ping" and self._supported is not None and command not in self._supported: + raise UnsupportedCapabilityError(command) + request = create_request(command, parameters, session) + request_id = cast(str, request["id"]) + frame = await self._transport.send( + encode_request(request), + request_id, + _request_timeout(command, parameters, timeout), + cancel, + ) + try: + result = decode_response(frame, request_id, command) + if command == "ping": + _validate_status(cast(HostStatus, result)) + return result + except CommandError: + raise + except MalformedResponseError as error: + raise OperationOutcomeUnknown(request_id, "read-failed", error) from error + + def session(self, name: str) -> AsyncSession: + validate_session(name) + return AsyncSession(self, name) + + async def open_session( + self, + name: str, + *, + isolated: bool | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> AsyncSession: + await self.session_create(name=name, isolated=isolated, timeout=timeout, cancel=cancel) + return self.session(name) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + await self._transport.close() + + def _after_fork_child(self) -> None: + self._closed = True + self._transport._after_fork_child() + + async def __aenter__(self) -> AsyncClient: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + async def _invoke_async( + self, + command: CommandName, + parameters: dict[str, JsonValue], + timeout: float | None, + cancel: AsyncCancellation | None, + ) -> object: + return await self.request(command, parameters, timeout=timeout, cancel=cancel) + + +class AsyncSession(AsyncSessionCommands): + def __init__(self, client: AsyncClient, name: str) -> None: + validate_session(name) + self._client = client + self.name = name + self._closed = False + self._closing = False + self._close_lock = asyncio.Lock() + self._close_result: SessionClose | None = None + self._close_error: OperationOutcomeUnknown | None = None + + async def session_close( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> SessionClose: + cached = self._cached_close_result() + if cached is not None: + return cached + remaining = await self._acquire_close_lock(timeout, cancel) + try: + cached = self._cached_close_result() + if cached is not None: + return cached + self._closing = True + try: + result = cast( + SessionClose, + await self._client.request( + "session.close", {}, session=self.name, timeout=remaining, cancel=cancel + ), + ) + except OperationOutcomeUnknown as error: + self._closed = True + self._close_error = error + raise + finally: + self._closing = False + self._closed = True + self._close_result = result + return result + finally: + self._close_lock.release() + + def _cached_close_result(self) -> SessionClose | None: + if self._close_error is not None: + raise self._close_error + return self._close_result + + async def _acquire_close_lock( + self, + timeout: float | None, + cancel: AsyncCancellation | None, + ) -> float: + if cancel is not None and cancel.is_set(): + raise CancelledBeforeSend() + loop = asyncio.get_running_loop() + deadline = loop.time() + _request_timeout("session.close", {}, timeout) + acquisition = asyncio.create_task(self._close_lock.acquire()) + cancellation = asyncio.create_task(cancel.wait()) if cancel is not None else None + pending: set[asyncio.Future[Any]] = {acquisition} + if cancellation is not None: + pending.add(cancellation) + acquired = False + keep_lock = False + try: + done, _ = await asyncio.wait( + pending, + timeout=max(0, deadline - loop.time()), + return_when=asyncio.FIRST_COMPLETED, + ) + if cancellation is not None and cancellation in done: + raise CancelledBeforeSend() + if acquisition not in done: + raise TimeoutBeforeSend() + acquired = acquisition.result() + remaining = deadline - loop.time() + if remaining <= 0: + raise TimeoutBeforeSend() + keep_lock = True + return remaining + except asyncio.CancelledError as error: + raise CancelledBeforeSend() from error + finally: + if not acquisition.done(): + acquisition.cancel() + with contextlib.suppress(asyncio.CancelledError): + await acquisition + elif not acquired and not acquisition.cancelled(): + acquired = acquisition.result() + if acquired and not keep_lock: + self._close_lock.release() + if cancellation is not None: + cancellation.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cancellation + + async def close( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> None: + if self._closed: + return + await self.session_close(timeout=timeout, cancel=cancel) + + async def __aenter__(self) -> AsyncSession: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + async def _invoke_async( + self, + command: CommandName, + parameters: dict[str, JsonValue], + timeout: float | None, + cancel: AsyncCancellation | None, + ) -> object: + if self._closed or self._closing: + raise ClientClosedError() + return await self._client.request( + command, parameters, session=self.name, timeout=timeout, cancel=cancel + ) + + +def connect( + socket_path: str | None = None, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, +) -> Client: + client = Client(socket_path) + try: + return client.connect(timeout=timeout, cancel=cancel) + except BaseException: + client.close() + raise + + +async def aconnect( + socket_path: str | None = None, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, +) -> AsyncClient: + client = AsyncClient(socket_path) + try: + return await client.connect(timeout=timeout, cancel=cancel) + except BaseException: + await client.close() + raise diff --git a/packages/headless-python/src/headless_sdk/errors.py b/packages/headless-python/src/headless_sdk/errors.py new file mode 100644 index 0000000..2113967 --- /dev/null +++ b/packages/headless-python/src/headless_sdk/errors.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ._types import Untrusted + +if TYPE_CHECKING: + from .generated import AuthenticationRequired, LifecycleErrorCode + + +class HeadlessError(Exception): + """Base class for SDK failures.""" + + +class ValidationError(HeadlessError): + pass + + +class ClientClosedError(HeadlessError): + def __init__(self) -> None: + super().__init__("the Headless client is closed") + + +class TransportError(HeadlessError): + def __init__(self, message: str, *, retry_safe: bool) -> None: + super().__init__(message) + self.retry_safe = retry_safe + + +class ConnectionError(TransportError): + def __init__(self, message: str) -> None: + super().__init__(message, retry_safe=True) + + +class TimeoutBeforeSend(TransportError): + def __init__(self) -> None: + super().__init__("the request timed out before any bytes were sent", retry_safe=True) + + +class CancelledBeforeSend(TransportError): + def __init__(self) -> None: + super().__init__("the request was cancelled before any bytes were sent", retry_safe=True) + + +class OperationOutcomeUnknown(TransportError): + def __init__(self, request_id: str, reason: str, cause: BaseException | None = None) -> None: + super().__init__( + f"request {request_id} was sent but its outcome is unknown ({reason}); " + "inspect host state before continuing", + retry_safe=False, + ) + self.request_id = request_id + self.reason = reason + self.__cause__ = cause + + +class MalformedResponseError(TransportError): + def __init__(self, message: str) -> None: + super().__init__(message, retry_safe=False) + + +class ResponseTooLargeError(MalformedResponseError): + def __init__(self, maximum_bytes: int) -> None: + super().__init__(f"Headless response exceeded the {maximum_bytes}-byte frame limit") + + +class ProtocolMismatchError(MalformedResponseError): + def __init__(self, expected_version: str, actual_version: str) -> None: + super().__init__( + f"Headless protocol mismatch: expected {expected_version}, received {actual_version}" + ) + self.expected_version = expected_version + self.actual_version = actual_version + + +class ResponseIdMismatchError(MalformedResponseError): + def __init__(self, expected_id: str, actual_id: str) -> None: + super().__init__( + f"Headless response id mismatch: expected {expected_id}, received {actual_id}" + ) + self.expected_id = expected_id + self.actual_id = actual_id + + +class CommandError(HeadlessError): + def __init__( + self, + code: str, + message: str, + suggestion: str | None = None, + details: Any = None, + ) -> None: + super().__init__(message) + self.code = code + self.suggestion = suggestion + self.details = details + + +class AuthenticationRequiredError(CommandError): + details: Untrusted[AuthenticationRequired] + + def __init__( + self, + message: str, + suggestion: str | None, + details: Untrusted[AuthenticationRequired], + ) -> None: + super().__init__("AUTH_REQUIRED", message, suggestion, details) + + +class UnsupportedCapabilityError(CommandError): + def __init__( + self, + command: str, + message: str | None = None, + suggestion: str | None = None, + details: Any = None, + ) -> None: + super().__init__( + "UNSUPPORTED_CAPABILITY", + message or f"the connected Headless host does not support {command}", + suggestion, + details, + ) + self.command = command + + +class HostLaunchError(HeadlessError): + def __init__( + self, + message: str, + *, + code: LifecycleErrorCode = "HOST_START_FAILED", + suggestion: str | None = None, + details: Any = None, + exit_code: int | None = None, + signal: int | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.suggestion = suggestion + self.details = details + self.exit_code = exit_code + self.signal = signal diff --git a/packages/headless-python/src/headless_sdk/generated.py b/packages/headless-python/src/headless_sdk/generated.py new file mode 100644 index 0000000..c18387e --- /dev/null +++ b/packages/headless-python/src/headless_sdk/generated.py @@ -0,0 +1,2979 @@ +# Generated by scripts/generate.py. Do not edit. +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, Literal, NotRequired, Required, TypeAlias, TypedDict, cast + +from ._types import AsyncCancellation, SyncCancellation, Untrusted + +JsonPrimitive: TypeAlias = bool | float | int | str | None +JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"] + +PROTOCOL_VERSION = '0.5' +PROTOCOL_SCHEMA_VERSION = 1 +MAXIMUM_MESSAGE_BYTES = 1048576 +MAXIMUM_COMMAND_TIMEOUT_SECONDS = 125.0 +PROTOCOL_SCHEMA_SHA256 = '882634187c7ef02ec4ed51fff0e747114eadeff308c10bb9b3274b3630fad11d' +PROTOCOL_FIXTURES_SHA256 = '0b51ffaa2d3e3aaf0c32adcfeb02c180dcbe44face0d49e1c332b69f403ae062' +RESPONSE_ADDITIONAL_PROPERTIES = True +COMMAND_ERROR_CODES = ('ARTIFACT_ERROR', 'AUTH_ACCOUNT_NOT_FOUND', 'AUTH_CHALLENGE_CONSUMED', 'AUTH_CHALLENGE_EXPIRED', 'AUTH_CHALLENGE_NOT_FOUND', 'AUTH_FORM_CHANGED', 'AUTH_ORIGIN_CHANGED', 'AUTH_REQUIRED', 'CREDENTIAL_ALIAS_EXISTS', 'ELEMENT_NOT_FOUND', 'FLOW_FAILED', 'HOST_STOPPING', 'HOST_UNAVAILABLE', 'INTERNAL_ERROR', 'INVALID_CAPTURE_FORMAT', 'INVALID_COMMAND', 'INVALID_FLOW', 'INVALID_INPUT', 'INVALID_REQUEST', 'INVALID_SESSION', 'MISSING_PARAMETER', 'OPERATION_FAILED', 'PEER_DENIED', 'RECORDER_UNAVAILABLE', 'RECORDING_ACTIVE', 'RECORDING_FAILED', 'RECORDING_NOT_ACTIVE', 'REGION_NOT_FOUND', 'RESPONSE_TOO_LARGE', 'SENSITIVE_DIAGNOSTICS_DISABLED', 'SESSION_EXISTS', 'SESSION_NOT_FOUND', 'TIMEOUT', 'UNSAFE_NAVIGATION', 'UNSAFE_RESOURCE_TYPE', 'UNSUPPORTED_CAPABILITY', 'USER_PRESENCE_DENIED', 'USER_PRESENCE_UNAVAILABLE', 'VAULT_LOCKED', 'VAULT_OPERATION_FAILED', 'VAULT_RESPONSE_INVALID', 'VAULT_UNAVAILABLE') +CommandErrorCode = Literal['ARTIFACT_ERROR', 'AUTH_ACCOUNT_NOT_FOUND', 'AUTH_CHALLENGE_CONSUMED', 'AUTH_CHALLENGE_EXPIRED', 'AUTH_CHALLENGE_NOT_FOUND', 'AUTH_FORM_CHANGED', 'AUTH_ORIGIN_CHANGED', 'AUTH_REQUIRED', 'CREDENTIAL_ALIAS_EXISTS', 'ELEMENT_NOT_FOUND', 'FLOW_FAILED', 'HOST_STOPPING', 'HOST_UNAVAILABLE', 'INTERNAL_ERROR', 'INVALID_CAPTURE_FORMAT', 'INVALID_COMMAND', 'INVALID_FLOW', 'INVALID_INPUT', 'INVALID_REQUEST', 'INVALID_SESSION', 'MISSING_PARAMETER', 'OPERATION_FAILED', 'PEER_DENIED', 'RECORDER_UNAVAILABLE', 'RECORDING_ACTIVE', 'RECORDING_FAILED', 'RECORDING_NOT_ACTIVE', 'REGION_NOT_FOUND', 'RESPONSE_TOO_LARGE', 'SENSITIVE_DIAGNOSTICS_DISABLED', 'SESSION_EXISTS', 'SESSION_NOT_FOUND', 'TIMEOUT', 'UNSAFE_NAVIGATION', 'UNSAFE_RESOURCE_TYPE', 'UNSUPPORTED_CAPABILITY', 'USER_PRESENCE_DENIED', 'USER_PRESENCE_UNAVAILABLE', 'VAULT_LOCKED', 'VAULT_OPERATION_FAILED', 'VAULT_RESPONSE_INVALID', 'VAULT_UNAVAILABLE'] +LIFECYCLE_ERROR_CODES = ('HOST_START_FAILED', 'NAVIGATION_ALLOWLIST_CONFLICT', 'UNSUPPORTED_BROWSER_RUNTIME', 'UNSUPPORTED_CAPABILITY') +LifecycleErrorCode = Literal['HOST_START_FAILED', 'NAVIGATION_ALLOWLIST_CONFLICT', 'UNSUPPORTED_BROWSER_RUNTIME', 'UNSUPPORTED_CAPABILITY'] +LAUNCH_PRESENTATIONS = ('background', 'foreground') +LaunchPresentation = Literal['background', 'foreground'] +CommandName = Literal['ping', 'shutdown', 'profile.clear', 'session.create', 'session.list', 'session.close', 'visit', 'inspect', 'click', 'fill', 'upload', 'press', 'scroll', 'back', 'reload', 'wait', 'tour', 'capture.info', 'screenshot', 'artifact.list', 'record.start', 'record.status', 'record.stop', 'qa.report', 'qa.clear', 'console.list', 'network.list', 'network.get', 'styles.get', 'cookies.list', 'storage.list', 'visual.compare', 'performance.get', 'animation.list', 'report.create', 'flow.start', 'flow.stop', 'flow.run', 'network.emulate', 'network.mock.set', 'network.mock.clear', 'auth.login'] + +class PingParameters(TypedDict): + pass + +class ShutdownParameters(TypedDict): + pass + +class ProfileClearParameters(TypedDict): + pass + +class SessionCreateParameters(TypedDict): + name: str + isolated: NotRequired[bool] + +class SessionListParameters(TypedDict): + pass + +class SessionCloseParameters(TypedDict): + pass + +class VisitParameters(TypedDict): + url: str + +class InspectParameters(TypedDict): + interactive: NotRequired[bool] + text: NotRequired[bool] + context: NotRequired[Literal['summary', 'outline', 'text', 'actions', 'full']] + task: NotRequired[str] + within: NotRequired[str] + limit: NotRequired[int] + budget: NotRequired[int] + depth: NotRequired[int] + +class ClickParameters(TypedDict): + target: NotRequired[str] + role: NotRequired[str] + name: NotRequired[str] + +class FillParameters(TypedDict): + target: NotRequired[str] + role: NotRequired[str] + name: NotRequired[str] + value: str + +class UploadParameters(TypedDict): + target: NotRequired[str] + role: NotRequired[str] + name: NotRequired[str] + artifact: str + +class PressParameters(TypedDict): + key: str + +class ScrollParameters(TypedDict): + direction: NotRequired[Literal['up', 'down', 'top', 'bottom']] + amount: NotRequired[int | float] + +class BackParameters(TypedDict): + pass + +class ReloadParameters(TypedDict): + pass + +class WaitParameters(TypedDict): + settled: NotRequired[bool] + url: NotRequired[str] + text: NotRequired[str] + timeoutMs: NotRequired[int | float] + +class TourParameters(TypedDict): + fullPage: NotRequired[bool] + pace: NotRequired[int | float] + +class CaptureInfoParameters(TypedDict): + pass + +class ScreenshotParameters(TypedDict): + target: NotRequired[str] + role: NotRequired[str] + name: NotRequired[str] + fullPage: NotRequired[bool] + output: NotRequired[str] + series: NotRequired[Literal['viewport', 'section']] + outputPrefix: NotRequired[str] + format: NotRequired[str] + clipboard: NotRequired[bool] + +class ArtifactListParameters(TypedDict): + pass + +class RecordStartParameters(TypedDict): + output: NotRequired[str] + fps: NotRequired[int | float] + format: NotRequired[str] + quality: NotRequired[str] + +class RecordStatusParameters(TypedDict): + pass + +class RecordStopParameters(TypedDict): + output: NotRequired[str] + +class QaReportParameters(TypedDict): + pass + +class QaClearParameters(TypedDict): + pass + +class ConsoleListParameters(TypedDict): + level: NotRequired[Literal['all', 'log', 'info', 'debug', 'warn', 'error', 'assert']] + limit: NotRequired[int | float] + +class NetworkListParameters(TypedDict): + failed: NotRequired[bool] + status: NotRequired[int | float] + limit: NotRequired[int | float] + +class NetworkGetParameters(TypedDict): + requestId: str + +class StylesGetParameters(TypedDict): + target: NotRequired[str] + role: NotRequired[str] + name: NotRequired[str] + properties: NotRequired[Sequence[str]] + +class CookiesListParameters(TypedDict): + includeValues: NotRequired[bool] + +class StorageListParameters(TypedDict): + scope: NotRequired[Literal['local', 'session', 'all']] + includeValues: NotRequired[bool] + +class VisualCompareParameters(TypedDict): + before: str + after: str + output: NotRequired[str] + +class PerformanceGetParameters(TypedDict): + pass + +class AnimationListParameters(TypedDict): + pass + +class ReportCreateParameters(TypedDict): + output: NotRequired[str] + +class FlowStartParameters(TypedDict): + pass + +class FlowStopParameters(TypedDict): + output: NotRequired[str] + +class FlowRunParameters(TypedDict): + input: str + +class NetworkEmulateParameters(TypedDict): + offline: NotRequired[bool] + latencyMs: NotRequired[int | float] + downloadKbps: NotRequired[int | float] + uploadKbps: NotRequired[int | float] + +class NetworkMockSetParameters(TypedDict): + url: str + status: NotRequired[int | float] + body: str + contentType: NotRequired[str] + +class NetworkMockClearParameters(TypedDict): + pass + +class AuthLoginParameters(TypedDict): + challenge: NotRequired[str] + account: NotRequired[str] + interactive: NotRequired[bool] + +class HostStatus(TypedDict, total=False): + ready: Required[bool] + pid: Required[int | float] + engine: Required[str] + platform: Required[str] + productVersion: Required[str] + protocolVersion: Required[str] + capabilities: Required[dict[str, JsonValue]] + recordingAvailable: Required[bool] + artifactDirectory: Required[str] + navigationAllowlist: Required[list[JsonValue]] + +class Shutdown(TypedDict, total=False): + stopping: Required[bool] + +class ProfileClear(TypedDict, total=False): + cleared: Required[bool] + session: Required[str] + +class SessionCreate(TypedDict, total=False): + session: Required[str] + isolated: Required[bool] + +class SessionList(TypedDict, total=False): + sessions: Required[list[JsonValue]] + details: Required[list[JsonValue]] + +class SessionClose(TypedDict, total=False): + closed: Required[str] + +class PageState(TypedDict, total=False): + url: Required[str] + title: Required[str] + readyState: Required[str] + text: Required[str] + runningAnimations: Required[int | float] + mutationQuietMs: Required[int | float] + scrollY: Required[int | float] + contentHeight: Required[int | float] + +class Inspection(TypedDict, total=False): + url: Required[str] + title: Required[str] + contextMode: Required[str] + viewport: Required[dict[str, JsonValue]] + untrustedContent: Required[bool] + elements: NotRequired[list[JsonValue]] + regions: NotRequired[list[JsonValue]] + snippets: NotRequired[list[JsonValue]] + text: NotRequired[str] + +class Click(TypedDict, total=False): + clicked: Required[str] + role: Required[str] + name: Required[str] + +class Fill(TypedDict, total=False): + filled: Required[str] + valueLength: Required[int | float] + +class Upload(TypedDict, total=False): + uploaded: Required[str] + role: Required[str] + name: Required[str] + artifact: Required[str] + +class Press(TypedDict, total=False): + pressed: Required[str] + +class Scroll(TypedDict, total=False): + direction: Required[str] + amount: Required[int | float] + +class Tour(TypedDict, total=False): + start: Required[int | float] + end: Required[int | float] + durationMs: Required[int | float] + +class CaptureInfo(TypedDict, total=False): + engine: Required[str] + page: Required[dict[str, JsonValue]] + trace: Required[list[JsonValue]] + recording: Required[dict[str, JsonValue]] + +class Screenshot(TypedDict, total=False): + name: NotRequired[str] + path: NotRequired[str] + kind: NotRequired[str] + bytes: NotRequired[int | float] + createdAt: NotRequired[int | float] + artifacts: NotRequired[list[JsonValue]] + truncated: NotRequired[bool] + +class ArtifactList(TypedDict, total=False): + directory: Required[str] + artifacts: Required[list[JsonValue]] + total: Required[int | float] + omitted: Required[int | float] + truncated: Required[bool] + +class Recording(TypedDict, total=False): + active: Required[bool] + format: NotRequired[str] + quality: NotRequired[str] + name: NotRequired[str] + +class QAReport(TypedDict, total=False): + untrustedContent: Required[bool] + summary: Required[dict[str, JsonValue]] + issues: Required[list[JsonValue]] + events: Required[list[JsonValue]] + omitted: Required[dict[str, JsonValue]] + truncated: Required[bool] + +class QAClear(TypedDict, total=False): + cleared: Required[int | float] + +class ConsoleList(TypedDict, total=False): + untrustedContent: Required[bool] + messages: Required[list[JsonValue]] + returned: Required[int | float] + available: Required[int | float] + +class NetworkList(TypedDict, total=False): + untrustedContent: Required[bool] + requests: Required[list[JsonValue]] + returned: Required[int | float] + available: Required[int | float] + +class NetworkDetail(TypedDict, total=False): + found: Required[bool] + requestId: NotRequired[str] + untrustedContent: Required[bool] + request: NotRequired[dict[str, JsonValue]] + +class Styles(TypedDict, total=False): + ref: Required[str] + role: Required[str] + name: Required[str] + box: Required[dict[str, JsonValue]] + styles: Required[dict[str, JsonValue]] + +class CookieList(TypedDict, total=False): + cookies: Required[list[JsonValue]] + returned: Required[int | float] + available: Required[int | float] + truncated: Required[bool] + +class StorageList(TypedDict, total=False): + origin: Required[str] + stores: Required[list[JsonValue]] + +class VisualComparison(TypedDict, total=False): + name: Required[str] + changedPixels: NotRequired[int | float] + differenceRatio: NotRequired[int | float] + +class Performance(TypedDict, total=False): + url: Required[str] + timing: Required[JsonValue] + webVitals: Required[dict[str, JsonValue]] + resources: Required[dict[str, JsonValue]] + +class AnimationList(TypedDict, total=False): + count: Required[int | float] + animations: Required[list[JsonValue]] + truncated: Required[bool] + +class Artifact(TypedDict, total=False): + name: Required[str] + path: Required[str] + kind: Required[str] + bytes: Required[int | float] + createdAt: Required[int | float] + +class FlowStart(TypedDict, total=False): + recording: Required[bool] + note: Required[str] + +class FlowRun(TypedDict, total=False): + completed: Required[int | float] + input: Required[str] + +class NetworkEmulation(TypedDict, total=False): + offline: Required[bool] + latencyMs: Required[int | float] + downloadKbps: Required[int | float] + uploadKbps: Required[int | float] + engine: Required[str] + +class NetworkMock(TypedDict, total=False): + url: Required[str] + status: Required[int | float] + activeMocks: Required[int | float] + +class NetworkMockClear(TypedDict, total=False): + cleared: Required[int | float] + +class AuthenticationLogin(TypedDict, total=False): + origin: Required[str] + account: Required[str | None] + saved: Required[bool] + continuation: Required[str] + passwordExposed: Required[bool] + originalActionReplayed: Required[bool] + +class AuthenticationRequired(TypedDict, total=False): + challenge: Required[str] + origin: Required[str] + detection: Required[str] + accounts: Required[list[JsonValue]] + expiresInSeconds: Required[int | float] + userPresenceRequired: Required[bool] + credentialUseAvailable: Required[bool] + vaultAvailable: Required[bool] + vaultStatus: Required[str] + untrustedContent: Required[bool] + originalActionReplayed: Required[bool] + +COMMAND_METADATA: dict[CommandName, dict[str, Any]] = {'animation.list': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'count', + 'required': True, + 'type': 'number'}, + {'name': 'animations', + 'required': True, + 'type': 'array'}, + {'name': 'truncated', + 'required': True, + 'type': 'boolean'}], + 'name': 'AnimationList', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'artifact.list': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'directory', + 'required': True, + 'type': 'string'}, + {'name': 'artifacts', + 'required': True, + 'type': 'array'}, + {'name': 'total', + 'required': True, + 'type': 'number'}, + {'name': 'omitted', + 'required': True, + 'type': 'number'}, + {'name': 'truncated', + 'required': True, + 'type': 'boolean'}], + 'name': 'ArtifactList', + 'type': 'object'}}, + 'scope': 'host', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'auth.login': {'capabilityNegotiated': True, + 'constraints': ['choose interactive login or account alias', + 'saved login requires a valid single-use challenge'], + 'parameters': [{'maximumBytes': 64, + 'name': 'challenge', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 64, + 'name': 'account', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'name': 'interactive', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'origin', + 'required': True, + 'type': 'string'}, + {'name': 'account', + 'required': True, + 'type': 'string-or-null'}, + {'name': 'saved', + 'required': True, + 'type': 'boolean'}, + {'name': 'continuation', + 'required': True, + 'type': 'string'}, + {'name': 'passwordExposed', + 'required': True, + 'type': 'boolean'}, + {'name': 'originalActionReplayed', + 'required': True, + 'type': 'boolean'}], + 'name': 'AuthenticationLogin', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'back': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'url', 'required': True, 'type': 'string'}, + {'name': 'title', 'required': True, 'type': 'string'}, + {'name': 'readyState', + 'required': True, + 'type': 'string'}, + {'name': 'text', 'required': True, 'type': 'string'}, + {'name': 'runningAnimations', + 'required': True, + 'type': 'number'}, + {'name': 'mutationQuietMs', + 'required': True, + 'type': 'number'}, + {'name': 'scrollY', 'required': True, 'type': 'number'}, + {'name': 'contentHeight', + 'required': True, + 'type': 'number'}], + 'name': 'PageState', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'capture.info': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'engine', + 'required': True, + 'type': 'string'}, + {'name': 'page', + 'required': True, + 'type': 'object'}, + {'name': 'trace', + 'required': True, + 'type': 'array'}, + {'name': 'recording', + 'required': True, + 'type': 'object'}], + 'name': 'CaptureInfo', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'click': {'capabilityNegotiated': False, + 'constraints': ['exactly one target reference or semantic role/name target'], + 'parameters': [{'maximumBytes': 16, + 'name': 'target', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 128, + 'name': 'role', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 1000, + 'name': 'name', + 'required': False, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'clicked', 'required': True, 'type': 'string'}, + {'name': 'role', 'required': True, 'type': 'string'}, + {'name': 'name', 'required': True, 'type': 'string'}], + 'name': 'Click', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'console.list': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 16, + 'name': 'level', + 'required': False, + 'sensitive': False, + 'type': 'string', + 'values': ['all', + 'log', + 'info', + 'debug', + 'warn', + 'error', + 'assert']}, + {'maximum': 200, + 'minimum': 1, + 'name': 'limit', + 'required': False, + 'sensitive': False, + 'type': 'number'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'untrustedContent', + 'required': True, + 'type': 'boolean'}, + {'name': 'messages', + 'required': True, + 'type': 'array'}, + {'name': 'returned', + 'required': True, + 'type': 'number'}, + {'name': 'available', + 'required': True, + 'type': 'number'}], + 'name': 'ConsoleList', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'cookies.list': {'capabilityNegotiated': False, + 'constraints': ['values require the sensitive diagnostics environment gate'], + 'parameters': [{'name': 'includeValues', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'cookies', + 'required': True, + 'type': 'array'}, + {'name': 'returned', + 'required': True, + 'type': 'number'}, + {'name': 'available', + 'required': True, + 'type': 'number'}, + {'name': 'truncated', + 'required': True, + 'type': 'boolean'}], + 'name': 'CookieList', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'fill': {'capabilityNegotiated': False, + 'constraints': ['exactly one target reference or semantic role/name target'], + 'parameters': [{'maximumBytes': 16, + 'name': 'target', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 128, + 'name': 'role', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 1000, + 'name': 'name', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 900000, + 'name': 'value', + 'required': True, + 'sensitive': True, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'filled', 'required': True, 'type': 'string'}, + {'name': 'valueLength', + 'required': True, + 'type': 'number'}], + 'name': 'Fill', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'flow.run': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 128, + 'name': 'input', + 'required': True, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'completed', + 'required': True, + 'type': 'number'}, + {'name': 'input', + 'required': True, + 'type': 'string'}], + 'name': 'FlowRun', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 125000, 'parameterPresentOverrides': {}}}, + 'flow.start': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'recording', + 'required': True, + 'type': 'boolean'}, + {'name': 'note', + 'required': True, + 'type': 'string'}], + 'name': 'FlowStart', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'flow.stop': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 128, + 'name': 'output', + 'required': False, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'name', + 'required': True, + 'type': 'string'}, + {'name': 'path', + 'required': True, + 'type': 'string'}, + {'name': 'kind', + 'required': True, + 'type': 'string'}, + {'name': 'bytes', + 'required': True, + 'type': 'number'}, + {'name': 'createdAt', + 'required': True, + 'type': 'number'}], + 'name': 'Artifact', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'inspect': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'name': 'interactive', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}, + {'name': 'text', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}, + {'maximumBytes': 16, + 'name': 'context', + 'required': False, + 'sensitive': False, + 'type': 'string', + 'values': ['summary', 'outline', 'text', 'actions', 'full']}, + {'maximumBytes': 512, + 'name': 'task', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 16, + 'name': 'within', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximum': 250, + 'minimum': 1, + 'name': 'limit', + 'required': False, + 'sensitive': False, + 'type': 'integer'}, + {'maximum': 16000, + 'minimum': 256, + 'name': 'budget', + 'required': False, + 'sensitive': False, + 'type': 'integer'}, + {'maximum': 8, + 'minimum': 0, + 'name': 'depth', + 'required': False, + 'sensitive': False, + 'type': 'integer'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'url', 'required': True, 'type': 'string'}, + {'name': 'title', 'required': True, 'type': 'string'}, + {'name': 'contextMode', + 'required': True, + 'type': 'string'}, + {'name': 'viewport', + 'required': True, + 'type': 'object'}, + {'name': 'untrustedContent', + 'required': True, + 'type': 'boolean'}, + {'name': 'elements', + 'required': False, + 'type': 'array'}, + {'name': 'regions', + 'required': False, + 'type': 'array'}, + {'name': 'snippets', + 'required': False, + 'type': 'array'}, + {'name': 'text', + 'required': False, + 'type': 'string'}], + 'name': 'Inspection', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'network.emulate': {'capabilityNegotiated': True, + 'constraints': [], + 'parameters': [{'name': 'offline', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}, + {'maximum': 120000, + 'minimum': 0, + 'name': 'latencyMs', + 'required': False, + 'sensitive': False, + 'type': 'number'}, + {'maximum': 1000000, + 'minimum': -1, + 'name': 'downloadKbps', + 'required': False, + 'sensitive': False, + 'type': 'number'}, + {'maximum': 1000000, + 'minimum': -1, + 'name': 'uploadKbps', + 'required': False, + 'sensitive': False, + 'type': 'number'}], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'offline', + 'required': True, + 'type': 'boolean'}, + {'name': 'latencyMs', + 'required': True, + 'type': 'number'}, + {'name': 'downloadKbps', + 'required': True, + 'type': 'number'}, + {'name': 'uploadKbps', + 'required': True, + 'type': 'number'}, + {'name': 'engine', + 'required': True, + 'type': 'string'}], + 'name': 'NetworkEmulation', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'network.get': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 128, + 'name': 'requestId', + 'required': True, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'found', + 'required': True, + 'type': 'boolean'}, + {'name': 'requestId', + 'required': False, + 'type': 'string'}, + {'name': 'untrustedContent', + 'required': True, + 'type': 'boolean'}, + {'name': 'request', + 'required': False, + 'type': 'object'}], + 'name': 'NetworkDetail', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'network.list': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'name': 'failed', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}, + {'maximum': 599, + 'minimum': 100, + 'name': 'status', + 'required': False, + 'sensitive': False, + 'type': 'number'}, + {'maximum': 200, + 'minimum': 1, + 'name': 'limit', + 'required': False, + 'sensitive': False, + 'type': 'number'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'untrustedContent', + 'required': True, + 'type': 'boolean'}, + {'name': 'requests', + 'required': True, + 'type': 'array'}, + {'name': 'returned', + 'required': True, + 'type': 'number'}, + {'name': 'available', + 'required': True, + 'type': 'number'}], + 'name': 'NetworkList', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'network.mock.clear': {'capabilityNegotiated': True, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'cleared', + 'required': True, + 'type': 'number'}], + 'name': 'NetworkMockClear', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'network.mock.set': {'capabilityNegotiated': True, + 'constraints': [], + 'parameters': [{'maximumBytes': 8192, + 'name': 'url', + 'required': True, + 'sensitive': False, + 'type': 'string'}, + {'maximum': 599, + 'minimum': 100, + 'name': 'status', + 'required': False, + 'sensitive': False, + 'type': 'number'}, + {'maximumBytes': 65536, + 'name': 'body', + 'required': True, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 256, + 'name': 'contentType', + 'required': False, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'url', + 'required': True, + 'type': 'string'}, + {'name': 'status', + 'required': True, + 'type': 'number'}, + {'name': 'activeMocks', + 'required': True, + 'type': 'number'}], + 'name': 'NetworkMock', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'performance.get': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'url', + 'required': True, + 'type': 'string'}, + {'name': 'timing', + 'required': True, + 'type': 'json'}, + {'name': 'webVitals', + 'required': True, + 'type': 'object'}, + {'name': 'resources', + 'required': True, + 'type': 'object'}], + 'name': 'Performance', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'ping': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'ready', 'required': True, 'type': 'boolean'}, + {'name': 'pid', 'required': True, 'type': 'number'}, + {'name': 'engine', 'required': True, 'type': 'string'}, + {'name': 'platform', 'required': True, 'type': 'string'}, + {'name': 'productVersion', + 'required': True, + 'type': 'string'}, + {'name': 'protocolVersion', + 'required': True, + 'type': 'string'}, + {'name': 'capabilities', + 'required': True, + 'type': 'object'}, + {'name': 'recordingAvailable', + 'required': True, + 'type': 'boolean'}, + {'name': 'artifactDirectory', + 'required': True, + 'type': 'string'}, + {'name': 'navigationAllowlist', + 'required': True, + 'type': 'array'}], + 'name': 'HostStatus', + 'type': 'object'}}, + 'scope': 'host', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'press': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 32, + 'name': 'key', + 'required': True, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'pressed', + 'required': True, + 'type': 'string'}], + 'name': 'Press', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'profile.clear': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'cleared', + 'required': True, + 'type': 'boolean'}, + {'name': 'session', + 'required': True, + 'type': 'string'}], + 'name': 'ProfileClear', + 'type': 'object'}}, + 'scope': 'host', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'qa.clear': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'cleared', + 'required': True, + 'type': 'number'}], + 'name': 'QAClear', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'qa.report': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'untrustedContent', + 'required': True, + 'type': 'boolean'}, + {'name': 'summary', + 'required': True, + 'type': 'object'}, + {'name': 'issues', + 'required': True, + 'type': 'array'}, + {'name': 'events', + 'required': True, + 'type': 'array'}, + {'name': 'omitted', + 'required': True, + 'type': 'object'}, + {'name': 'truncated', + 'required': True, + 'type': 'boolean'}], + 'name': 'QAReport', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'record.start': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 128, + 'name': 'output', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximum': 30, + 'minimum': 1, + 'name': 'fps', + 'required': False, + 'sensitive': False, + 'type': 'number'}, + {'caseInsensitiveValues': True, + 'maximumBytes': 16, + 'name': 'format', + 'required': False, + 'sensitive': False, + 'type': 'string', + 'values': ['mp4', 'mov', 'webm', 'gif']}, + {'caseInsensitiveValues': True, + 'maximumBytes': 16, + 'name': 'quality', + 'required': False, + 'sensitive': False, + 'type': 'string', + 'values': ['fast', 'balanced', 'high']}], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'active', + 'required': True, + 'type': 'boolean'}, + {'name': 'format', + 'required': False, + 'type': 'string'}, + {'name': 'quality', + 'required': False, + 'type': 'string'}, + {'name': 'name', + 'required': False, + 'type': 'string'}], + 'name': 'Recording', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'record.status': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'active', + 'required': True, + 'type': 'boolean'}, + {'name': 'format', + 'required': False, + 'type': 'string'}, + {'name': 'quality', + 'required': False, + 'type': 'string'}, + {'name': 'name', + 'required': False, + 'type': 'string'}], + 'name': 'Recording', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'record.stop': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 128, + 'name': 'output', + 'required': False, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'active', + 'required': True, + 'type': 'boolean'}, + {'name': 'format', + 'required': False, + 'type': 'string'}, + {'name': 'quality', + 'required': False, + 'type': 'string'}, + {'name': 'name', + 'required': False, + 'type': 'string'}], + 'name': 'Recording', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 30000, 'parameterPresentOverrides': {}}}, + 'reload': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'url', 'required': True, 'type': 'string'}, + {'name': 'title', 'required': True, 'type': 'string'}, + {'name': 'readyState', + 'required': True, + 'type': 'string'}, + {'name': 'text', 'required': True, 'type': 'string'}, + {'name': 'runningAnimations', + 'required': True, + 'type': 'number'}, + {'name': 'mutationQuietMs', + 'required': True, + 'type': 'number'}, + {'name': 'scrollY', + 'required': True, + 'type': 'number'}, + {'name': 'contentHeight', + 'required': True, + 'type': 'number'}], + 'name': 'PageState', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'report.create': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 128, + 'name': 'output', + 'required': False, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'name', + 'required': True, + 'type': 'string'}, + {'name': 'path', + 'required': True, + 'type': 'string'}, + {'name': 'kind', + 'required': True, + 'type': 'string'}, + {'name': 'bytes', + 'required': True, + 'type': 'number'}, + {'name': 'createdAt', + 'required': True, + 'type': 'number'}], + 'name': 'Artifact', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'screenshot': {'capabilityNegotiated': True, + 'constraints': ['target, full-page, and series modes are mutually constrained', + 'PDF requires full-page mode and no clipboard'], + 'parameters': [{'maximumBytes': 16, + 'name': 'target', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 128, + 'name': 'role', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 1000, + 'name': 'name', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'name': 'fullPage', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}, + {'maximumBytes': 128, + 'name': 'output', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 32, + 'name': 'series', + 'required': False, + 'sensitive': False, + 'type': 'string', + 'values': ['viewport', 'section']}, + {'maximumBytes': 80, + 'name': 'outputPrefix', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'caseInsensitiveValues': True, + 'maximumBytes': 16, + 'name': 'format', + 'required': False, + 'sensitive': False, + 'type': 'string', + 'values': ['png', 'jpg', 'jpeg', 'pdf']}, + {'name': 'clipboard', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'name', + 'required': False, + 'type': 'string'}, + {'name': 'path', + 'required': False, + 'type': 'string'}, + {'name': 'kind', + 'required': False, + 'type': 'string'}, + {'name': 'bytes', + 'required': False, + 'type': 'number'}, + {'name': 'createdAt', + 'required': False, + 'type': 'number'}, + {'name': 'artifacts', + 'required': False, + 'type': 'array'}, + {'name': 'truncated', + 'required': False, + 'type': 'boolean'}], + 'name': 'Screenshot', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 30000, + 'parameterPresentOverrides': {'series': 125000}}}, + 'scroll': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 8192, + 'name': 'direction', + 'required': False, + 'sensitive': False, + 'type': 'string', + 'values': ['up', 'down', 'top', 'bottom']}, + {'maximum': 100000, + 'minimum': 0.1, + 'name': 'amount', + 'required': False, + 'sensitive': False, + 'type': 'number'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'direction', + 'required': True, + 'type': 'string'}, + {'name': 'amount', + 'required': True, + 'type': 'number'}], + 'name': 'Scroll', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'session.close': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'closed', + 'required': True, + 'type': 'string'}], + 'name': 'SessionClose', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'session.create': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 64, + 'name': 'name', + 'required': True, + 'sensitive': False, + 'type': 'string'}, + {'name': 'isolated', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'session', + 'required': True, + 'type': 'string'}, + {'name': 'isolated', + 'required': True, + 'type': 'boolean'}], + 'name': 'SessionCreate', + 'type': 'object'}}, + 'scope': 'host', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'session.list': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'sessions', + 'required': True, + 'type': 'array'}, + {'name': 'details', + 'required': True, + 'type': 'array'}], + 'name': 'SessionList', + 'type': 'object'}}, + 'scope': 'host', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'shutdown': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'stopping', + 'required': True, + 'type': 'boolean'}], + 'name': 'Shutdown', + 'type': 'object'}}, + 'scope': 'host', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'storage.list': {'capabilityNegotiated': False, + 'constraints': ['values require the sensitive diagnostics environment gate'], + 'parameters': [{'maximumBytes': 16, + 'name': 'scope', + 'required': False, + 'sensitive': False, + 'type': 'string', + 'values': ['local', 'session', 'all']}, + {'name': 'includeValues', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'origin', + 'required': True, + 'type': 'string'}, + {'name': 'stores', + 'required': True, + 'type': 'array'}], + 'name': 'StorageList', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'styles.get': {'capabilityNegotiated': False, + 'constraints': ['exactly one target reference or semantic role/name target'], + 'parameters': [{'maximumBytes': 16, + 'name': 'target', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 128, + 'name': 'role', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 1000, + 'name': 'name', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'itemMaximumBytes': 128, + 'maximumItems': 64, + 'name': 'properties', + 'required': False, + 'sensitive': False, + 'type': 'string-array'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'ref', + 'required': True, + 'type': 'string'}, + {'name': 'role', + 'required': True, + 'type': 'string'}, + {'name': 'name', + 'required': True, + 'type': 'string'}, + {'name': 'box', + 'required': True, + 'type': 'object'}, + {'name': 'styles', + 'required': True, + 'type': 'object'}], + 'name': 'Styles', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'tour': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'name': 'fullPage', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}, + {'maximum': 5000, + 'minimum': 100, + 'name': 'pace', + 'required': False, + 'sensitive': False, + 'type': 'number'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'start', 'required': True, 'type': 'number'}, + {'name': 'end', 'required': True, 'type': 'number'}, + {'name': 'durationMs', + 'required': True, + 'type': 'number'}], + 'name': 'Tour', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 125000, 'parameterPresentOverrides': {}}}, + 'upload': {'capabilityNegotiated': True, + 'constraints': ['artifact must be an existing private-store basename', + 'exactly one target reference or semantic role/name target'], + 'parameters': [{'maximumBytes': 16, + 'name': 'target', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 128, + 'name': 'role', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 1000, + 'name': 'name', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 128, + 'name': 'artifact', + 'required': True, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'uploaded', + 'required': True, + 'type': 'string'}, + {'name': 'role', 'required': True, 'type': 'string'}, + {'name': 'name', 'required': True, 'type': 'string'}, + {'name': 'artifact', + 'required': True, + 'type': 'string'}], + 'name': 'Upload', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'visit': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 8192, + 'name': 'url', + 'required': True, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'url', 'required': True, 'type': 'string'}, + {'name': 'title', 'required': True, 'type': 'string'}, + {'name': 'readyState', + 'required': True, + 'type': 'string'}, + {'name': 'text', 'required': True, 'type': 'string'}, + {'name': 'runningAnimations', + 'required': True, + 'type': 'number'}, + {'name': 'mutationQuietMs', + 'required': True, + 'type': 'number'}, + {'name': 'scrollY', 'required': True, 'type': 'number'}, + {'name': 'contentHeight', + 'required': True, + 'type': 'number'}], + 'name': 'PageState', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'visual.compare': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'maximumBytes': 128, + 'name': 'before', + 'required': True, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 128, + 'name': 'after', + 'required': True, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 128, + 'name': 'output', + 'required': False, + 'sensitive': False, + 'type': 'string'}], + 'result': {'mayContainUntrustedContent': False, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'name', + 'required': True, + 'type': 'string'}, + {'name': 'changedPixels', + 'required': False, + 'type': 'number'}, + {'name': 'differenceRatio', + 'required': False, + 'type': 'number'}], + 'name': 'VisualComparison', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, + 'wait': {'capabilityNegotiated': False, + 'constraints': [], + 'parameters': [{'name': 'settled', + 'required': False, + 'sensitive': False, + 'type': 'boolean'}, + {'maximumBytes': 8192, + 'name': 'url', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximumBytes': 30000, + 'name': 'text', + 'required': False, + 'sensitive': False, + 'type': 'string'}, + {'maximum': 120000, + 'minimum': 100, + 'name': 'timeoutMs', + 'required': False, + 'sensitive': False, + 'type': 'number'}], + 'result': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'url', 'required': True, 'type': 'string'}, + {'name': 'title', 'required': True, 'type': 'string'}, + {'name': 'readyState', + 'required': True, + 'type': 'string'}, + {'name': 'text', 'required': True, 'type': 'string'}, + {'name': 'runningAnimations', + 'required': True, + 'type': 'number'}, + {'name': 'mutationQuietMs', + 'required': True, + 'type': 'number'}, + {'name': 'scrollY', 'required': True, 'type': 'number'}, + {'name': 'contentHeight', + 'required': True, + 'type': 'number'}], + 'name': 'PageState', + 'type': 'object'}}, + 'scope': 'session', + 'timeout': {'defaultMilliseconds': 15000, + 'maximumMilliseconds': 125000, + 'minimumMilliseconds': 10000, + 'parameterGraceMilliseconds': 5000, + 'parameterName': 'timeoutMs', + 'parameterPresentOverrides': {}}}} +ERROR_DETAILS_METADATA: dict[str, dict[str, Any]] = {'AUTH_REQUIRED': {'mayContainUntrustedContent': True, + 'schema': {'additionalProperties': True, + 'fields': [{'name': 'challenge', 'required': True, 'type': 'string'}, + {'name': 'origin', 'required': True, 'type': 'string'}, + {'name': 'detection', 'required': True, 'type': 'string'}, + {'name': 'accounts', 'required': True, 'type': 'array'}, + {'name': 'expiresInSeconds', + 'required': True, + 'type': 'number'}, + {'name': 'userPresenceRequired', + 'required': True, + 'type': 'boolean'}, + {'name': 'credentialUseAvailable', + 'required': True, + 'type': 'boolean'}, + {'name': 'vaultAvailable', + 'required': True, + 'type': 'boolean'}, + {'name': 'vaultStatus', + 'required': True, + 'type': 'string'}, + {'name': 'untrustedContent', + 'required': True, + 'type': 'boolean'}, + {'name': 'originalActionReplayed', + 'required': True, + 'type': 'boolean'}], + 'name': 'AuthenticationRequired', + 'type': 'object'}}} +LOCAL_LIFECYCLE: dict[str, Any] = {'connect': {'errors': ['HOST_UNAVAILABLE'], + 'ownership': 'shared', + 'transport': 'local-unix-socket'}, + 'launch': {'argv': ['start', '--supervised'], + 'command': 'start', + 'errors': ['HOST_START_FAILED', + 'NAVIGATION_ALLOWLIST_CONFLICT', + 'UNSUPPORTED_BROWSER_RUNTIME', + 'UNSUPPORTED_CAPABILITY'], + 'options': [{'name': 'presentation', + 'required': False, + 'type': 'string', + 'values': ['background', 'foreground']}, + {'itemMaximumBytes': 300, + 'maximumItems': 32, + 'name': 'allow', + 'required': False, + 'type': 'string-array'}, + {'const': True, 'name': 'supervised', 'required': True, 'type': 'boolean'}], + 'ownership': 'owned-only-after-response-pid-matches-launched-child', + 'result': {'additionalProperties': True, + 'fields': [{'name': 'ready', 'required': True, 'type': 'boolean'}, + {'name': 'pid', 'required': True, 'type': 'number'}, + {'name': 'engine', 'required': True, 'type': 'string'}, + {'name': 'platform', 'required': True, 'type': 'string'}, + {'name': 'productVersion', 'required': True, 'type': 'string'}, + {'name': 'protocolVersion', 'required': True, 'type': 'string'}, + {'name': 'capabilities', 'required': True, 'type': 'object'}, + {'name': 'recordingAvailable', + 'required': True, + 'type': 'boolean'}, + {'name': 'artifactDirectory', 'required': True, 'type': 'string'}, + {'name': 'navigationAllowlist', + 'required': True, + 'type': 'array'}], + 'name': 'HostStatus', + 'type': 'object'}}} + +def command_timeout_seconds( + command: CommandName, parameters: dict[str, JsonValue] +) -> float: + policy = COMMAND_METADATA[command]["timeout"] + for parameter, milliseconds in policy["parameterPresentOverrides"].items(): + if parameter in parameters: + return cast(float, milliseconds / 1000) + if "parameterName" in policy: + value = parameters.get(policy["parameterName"]) + if isinstance(value, (int, float)) and not isinstance(value, bool): + milliseconds = max( + policy["minimumMilliseconds"], + min(policy["maximumMilliseconds"], + value + policy["parameterGraceMilliseconds"]), + ) + return cast(float, milliseconds / 1000) + return cast(float, policy["defaultMilliseconds"] / 1000) + +class SyncHostCommands: + def _invoke_sync( + self, command: CommandName, parameters: dict[str, JsonValue], + timeout: float | None, cancel: SyncCancellation | None, + ) -> object: + raise NotImplementedError + + def ping( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> HostStatus: + parameters: dict[str, JsonValue] = {} + return cast(HostStatus, self._invoke_sync('ping', parameters, timeout, cancel)) + + def shutdown( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Shutdown: + parameters: dict[str, JsonValue] = {} + return cast(Shutdown, self._invoke_sync('shutdown', parameters, timeout, cancel)) + + def profile_clear( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> ProfileClear: + parameters: dict[str, JsonValue] = {} + return cast(ProfileClear, self._invoke_sync('profile.clear', parameters, timeout, cancel)) + + def session_create( + self, + *, + name: str, + isolated: bool | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> SessionCreate: + parameters: dict[str, JsonValue] = { + 'name': cast(JsonValue, name), + } + if isolated is not None: + parameters['isolated'] = cast(JsonValue, isolated) + return cast(SessionCreate, self._invoke_sync('session.create', parameters, timeout, cancel)) + + def session_list( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> SessionList: + parameters: dict[str, JsonValue] = {} + return cast(SessionList, self._invoke_sync('session.list', parameters, timeout, cancel)) + + def artifact_list( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> ArtifactList: + parameters: dict[str, JsonValue] = {} + return cast(ArtifactList, self._invoke_sync('artifact.list', parameters, timeout, cancel)) + +class SyncSessionCommands: + def _invoke_sync( + self, command: CommandName, parameters: dict[str, JsonValue], + timeout: float | None, cancel: SyncCancellation | None, + ) -> object: + raise NotImplementedError + + def session_close( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> SessionClose: + parameters: dict[str, JsonValue] = {} + return cast(SessionClose, self._invoke_sync('session.close', parameters, timeout, cancel)) + + def visit( + self, + *, + url: str, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[PageState]: + parameters: dict[str, JsonValue] = { + 'url': cast(JsonValue, url), + } + return cast(Untrusted[PageState], self._invoke_sync('visit', parameters, timeout, cancel)) + + def inspect( + self, + *, + interactive: bool | None = None, + text: bool | None = None, + context: Literal['summary', 'outline', 'text', 'actions', 'full'] | None = None, + task: str | None = None, + within: str | None = None, + limit: int | None = None, + budget: int | None = None, + depth: int | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[Inspection]: + parameters: dict[str, JsonValue] = { + } + if interactive is not None: + parameters['interactive'] = cast(JsonValue, interactive) + if text is not None: + parameters['text'] = cast(JsonValue, text) + if context is not None: + parameters['context'] = cast(JsonValue, context) + if task is not None: + parameters['task'] = cast(JsonValue, task) + if within is not None: + parameters['within'] = cast(JsonValue, within) + if limit is not None: + parameters['limit'] = cast(JsonValue, limit) + if budget is not None: + parameters['budget'] = cast(JsonValue, budget) + if depth is not None: + parameters['depth'] = cast(JsonValue, depth) + return cast(Untrusted[Inspection], self._invoke_sync('inspect', parameters, timeout, cancel)) + + def click( + self, + *, + target: str | None = None, + role: str | None = None, + name: str | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[Click]: + parameters: dict[str, JsonValue] = { + } + if target is not None: + parameters['target'] = cast(JsonValue, target) + if role is not None: + parameters['role'] = cast(JsonValue, role) + if name is not None: + parameters['name'] = cast(JsonValue, name) + return cast(Untrusted[Click], self._invoke_sync('click', parameters, timeout, cancel)) + + def fill( + self, + *, + target: str | None = None, + role: str | None = None, + name: str | None = None, + value: str, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[Fill]: + parameters: dict[str, JsonValue] = { + 'value': cast(JsonValue, value), + } + if target is not None: + parameters['target'] = cast(JsonValue, target) + if role is not None: + parameters['role'] = cast(JsonValue, role) + if name is not None: + parameters['name'] = cast(JsonValue, name) + return cast(Untrusted[Fill], self._invoke_sync('fill', parameters, timeout, cancel)) + + def upload( + self, + *, + target: str | None = None, + role: str | None = None, + name: str | None = None, + artifact: str, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[Upload]: + parameters: dict[str, JsonValue] = { + 'artifact': cast(JsonValue, artifact), + } + if target is not None: + parameters['target'] = cast(JsonValue, target) + if role is not None: + parameters['role'] = cast(JsonValue, role) + if name is not None: + parameters['name'] = cast(JsonValue, name) + return cast(Untrusted[Upload], self._invoke_sync('upload', parameters, timeout, cancel)) + + def press( + self, + *, + key: str, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[Press]: + parameters: dict[str, JsonValue] = { + 'key': cast(JsonValue, key), + } + return cast(Untrusted[Press], self._invoke_sync('press', parameters, timeout, cancel)) + + def scroll( + self, + *, + direction: Literal['up', 'down', 'top', 'bottom'] | None = None, + amount: int | float | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[Scroll]: + parameters: dict[str, JsonValue] = { + } + if direction is not None: + parameters['direction'] = cast(JsonValue, direction) + if amount is not None: + parameters['amount'] = cast(JsonValue, amount) + return cast(Untrusted[Scroll], self._invoke_sync('scroll', parameters, timeout, cancel)) + + def back( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[PageState]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[PageState], self._invoke_sync('back', parameters, timeout, cancel)) + + def reload( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[PageState]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[PageState], self._invoke_sync('reload', parameters, timeout, cancel)) + + def wait( + self, + *, + settled: bool | None = None, + url: str | None = None, + text: str | None = None, + timeout_ms: int | float | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[PageState]: + parameters: dict[str, JsonValue] = { + } + if settled is not None: + parameters['settled'] = cast(JsonValue, settled) + if url is not None: + parameters['url'] = cast(JsonValue, url) + if text is not None: + parameters['text'] = cast(JsonValue, text) + if timeout_ms is not None: + parameters['timeoutMs'] = cast(JsonValue, timeout_ms) + return cast(Untrusted[PageState], self._invoke_sync('wait', parameters, timeout, cancel)) + + def tour( + self, + *, + full_page: bool | None = None, + pace: int | float | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[Tour]: + parameters: dict[str, JsonValue] = { + } + if full_page is not None: + parameters['fullPage'] = cast(JsonValue, full_page) + if pace is not None: + parameters['pace'] = cast(JsonValue, pace) + return cast(Untrusted[Tour], self._invoke_sync('tour', parameters, timeout, cancel)) + + def capture_info( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[CaptureInfo]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[CaptureInfo], self._invoke_sync('capture.info', parameters, timeout, cancel)) + + def screenshot( + self, + *, + target: str | None = None, + role: str | None = None, + name: str | None = None, + full_page: bool | None = None, + output: str | None = None, + series: Literal['viewport', 'section'] | None = None, + output_prefix: str | None = None, + format: str | None = None, + clipboard: bool | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Screenshot: + parameters: dict[str, JsonValue] = { + } + if target is not None: + parameters['target'] = cast(JsonValue, target) + if role is not None: + parameters['role'] = cast(JsonValue, role) + if name is not None: + parameters['name'] = cast(JsonValue, name) + if full_page is not None: + parameters['fullPage'] = cast(JsonValue, full_page) + if output is not None: + parameters['output'] = cast(JsonValue, output) + if series is not None: + parameters['series'] = cast(JsonValue, series) + if output_prefix is not None: + parameters['outputPrefix'] = cast(JsonValue, output_prefix) + if format is not None: + parameters['format'] = cast(JsonValue, format) + if clipboard is not None: + parameters['clipboard'] = cast(JsonValue, clipboard) + return cast(Screenshot, self._invoke_sync('screenshot', parameters, timeout, cancel)) + + def record_start( + self, + *, + output: str | None = None, + fps: int | float | None = None, + format: str | None = None, + quality: str | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Recording: + parameters: dict[str, JsonValue] = { + } + if output is not None: + parameters['output'] = cast(JsonValue, output) + if fps is not None: + parameters['fps'] = cast(JsonValue, fps) + if format is not None: + parameters['format'] = cast(JsonValue, format) + if quality is not None: + parameters['quality'] = cast(JsonValue, quality) + return cast(Recording, self._invoke_sync('record.start', parameters, timeout, cancel)) + + def record_status( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Recording: + parameters: dict[str, JsonValue] = {} + return cast(Recording, self._invoke_sync('record.status', parameters, timeout, cancel)) + + def record_stop( + self, + *, + output: str | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Recording: + parameters: dict[str, JsonValue] = { + } + if output is not None: + parameters['output'] = cast(JsonValue, output) + return cast(Recording, self._invoke_sync('record.stop', parameters, timeout, cancel)) + + def qa_report( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[QAReport]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[QAReport], self._invoke_sync('qa.report', parameters, timeout, cancel)) + + def qa_clear( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> QAClear: + parameters: dict[str, JsonValue] = {} + return cast(QAClear, self._invoke_sync('qa.clear', parameters, timeout, cancel)) + + def console_list( + self, + *, + level: Literal['all', 'log', 'info', 'debug', 'warn', 'error', 'assert'] | None = None, + limit: int | float | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[ConsoleList]: + parameters: dict[str, JsonValue] = { + } + if level is not None: + parameters['level'] = cast(JsonValue, level) + if limit is not None: + parameters['limit'] = cast(JsonValue, limit) + return cast(Untrusted[ConsoleList], self._invoke_sync('console.list', parameters, timeout, cancel)) + + def network_list( + self, + *, + failed: bool | None = None, + status: int | float | None = None, + limit: int | float | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[NetworkList]: + parameters: dict[str, JsonValue] = { + } + if failed is not None: + parameters['failed'] = cast(JsonValue, failed) + if status is not None: + parameters['status'] = cast(JsonValue, status) + if limit is not None: + parameters['limit'] = cast(JsonValue, limit) + return cast(Untrusted[NetworkList], self._invoke_sync('network.list', parameters, timeout, cancel)) + + def network_get( + self, + *, + request_id: str, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[NetworkDetail]: + parameters: dict[str, JsonValue] = { + 'requestId': cast(JsonValue, request_id), + } + return cast(Untrusted[NetworkDetail], self._invoke_sync('network.get', parameters, timeout, cancel)) + + def styles_get( + self, + *, + target: str | None = None, + role: str | None = None, + name: str | None = None, + properties: Sequence[str] | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[Styles]: + parameters: dict[str, JsonValue] = { + } + if target is not None: + parameters['target'] = cast(JsonValue, target) + if role is not None: + parameters['role'] = cast(JsonValue, role) + if name is not None: + parameters['name'] = cast(JsonValue, name) + if properties is not None: + parameters['properties'] = cast(JsonValue, properties) + return cast(Untrusted[Styles], self._invoke_sync('styles.get', parameters, timeout, cancel)) + + def cookies_list( + self, + *, + include_values: bool | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[CookieList]: + parameters: dict[str, JsonValue] = { + } + if include_values is not None: + parameters['includeValues'] = cast(JsonValue, include_values) + return cast(Untrusted[CookieList], self._invoke_sync('cookies.list', parameters, timeout, cancel)) + + def storage_list( + self, + *, + scope: Literal['local', 'session', 'all'] | None = None, + include_values: bool | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[StorageList]: + parameters: dict[str, JsonValue] = { + } + if scope is not None: + parameters['scope'] = cast(JsonValue, scope) + if include_values is not None: + parameters['includeValues'] = cast(JsonValue, include_values) + return cast(Untrusted[StorageList], self._invoke_sync('storage.list', parameters, timeout, cancel)) + + def visual_compare( + self, + *, + before: str, + after: str, + output: str | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> VisualComparison: + parameters: dict[str, JsonValue] = { + 'before': cast(JsonValue, before), + 'after': cast(JsonValue, after), + } + if output is not None: + parameters['output'] = cast(JsonValue, output) + return cast(VisualComparison, self._invoke_sync('visual.compare', parameters, timeout, cancel)) + + def performance_get( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[Performance]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[Performance], self._invoke_sync('performance.get', parameters, timeout, cancel)) + + def animation_list( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[AnimationList]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[AnimationList], self._invoke_sync('animation.list', parameters, timeout, cancel)) + + def report_create( + self, + *, + output: str | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[Artifact]: + parameters: dict[str, JsonValue] = { + } + if output is not None: + parameters['output'] = cast(JsonValue, output) + return cast(Untrusted[Artifact], self._invoke_sync('report.create', parameters, timeout, cancel)) + + def flow_start( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> FlowStart: + parameters: dict[str, JsonValue] = {} + return cast(FlowStart, self._invoke_sync('flow.start', parameters, timeout, cancel)) + + def flow_stop( + self, + *, + output: str | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Artifact: + parameters: dict[str, JsonValue] = { + } + if output is not None: + parameters['output'] = cast(JsonValue, output) + return cast(Artifact, self._invoke_sync('flow.stop', parameters, timeout, cancel)) + + def flow_run( + self, + *, + input: str, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[FlowRun]: + parameters: dict[str, JsonValue] = { + 'input': cast(JsonValue, input), + } + return cast(Untrusted[FlowRun], self._invoke_sync('flow.run', parameters, timeout, cancel)) + + def network_emulate( + self, + *, + offline: bool | None = None, + latency_ms: int | float | None = None, + download_kbps: int | float | None = None, + upload_kbps: int | float | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> NetworkEmulation: + parameters: dict[str, JsonValue] = { + } + if offline is not None: + parameters['offline'] = cast(JsonValue, offline) + if latency_ms is not None: + parameters['latencyMs'] = cast(JsonValue, latency_ms) + if download_kbps is not None: + parameters['downloadKbps'] = cast(JsonValue, download_kbps) + if upload_kbps is not None: + parameters['uploadKbps'] = cast(JsonValue, upload_kbps) + return cast(NetworkEmulation, self._invoke_sync('network.emulate', parameters, timeout, cancel)) + + def network_mock_set( + self, + *, + url: str, + status: int | float | None = None, + body: str, + content_type: str | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[NetworkMock]: + parameters: dict[str, JsonValue] = { + 'url': cast(JsonValue, url), + 'body': cast(JsonValue, body), + } + if status is not None: + parameters['status'] = cast(JsonValue, status) + if content_type is not None: + parameters['contentType'] = cast(JsonValue, content_type) + return cast(Untrusted[NetworkMock], self._invoke_sync('network.mock.set', parameters, timeout, cancel)) + + def network_mock_clear( + self, + *, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> NetworkMockClear: + parameters: dict[str, JsonValue] = {} + return cast(NetworkMockClear, self._invoke_sync('network.mock.clear', parameters, timeout, cancel)) + + def auth_login( + self, + *, + challenge: str | None = None, + account: str | None = None, + interactive: bool | None = None, + timeout: float | None = None, + cancel: SyncCancellation | None = None, + ) -> Untrusted[AuthenticationLogin]: + parameters: dict[str, JsonValue] = { + } + if challenge is not None: + parameters['challenge'] = cast(JsonValue, challenge) + if account is not None: + parameters['account'] = cast(JsonValue, account) + if interactive is not None: + parameters['interactive'] = cast(JsonValue, interactive) + return cast(Untrusted[AuthenticationLogin], self._invoke_sync('auth.login', parameters, timeout, cancel)) + +class AsyncHostCommands: + async def _invoke_async( + self, command: CommandName, parameters: dict[str, JsonValue], + timeout: float | None, cancel: AsyncCancellation | None, + ) -> object: + raise NotImplementedError + + async def ping( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> HostStatus: + parameters: dict[str, JsonValue] = {} + return cast(HostStatus, await self._invoke_async('ping', parameters, timeout, cancel)) + + async def shutdown( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Shutdown: + parameters: dict[str, JsonValue] = {} + return cast(Shutdown, await self._invoke_async('shutdown', parameters, timeout, cancel)) + + async def profile_clear( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> ProfileClear: + parameters: dict[str, JsonValue] = {} + return cast(ProfileClear, await self._invoke_async('profile.clear', parameters, timeout, cancel)) + + async def session_create( + self, + *, + name: str, + isolated: bool | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> SessionCreate: + parameters: dict[str, JsonValue] = { + 'name': cast(JsonValue, name), + } + if isolated is not None: + parameters['isolated'] = cast(JsonValue, isolated) + return cast(SessionCreate, await self._invoke_async('session.create', parameters, timeout, cancel)) + + async def session_list( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> SessionList: + parameters: dict[str, JsonValue] = {} + return cast(SessionList, await self._invoke_async('session.list', parameters, timeout, cancel)) + + async def artifact_list( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> ArtifactList: + parameters: dict[str, JsonValue] = {} + return cast(ArtifactList, await self._invoke_async('artifact.list', parameters, timeout, cancel)) + +class AsyncSessionCommands: + async def _invoke_async( + self, command: CommandName, parameters: dict[str, JsonValue], + timeout: float | None, cancel: AsyncCancellation | None, + ) -> object: + raise NotImplementedError + + async def session_close( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> SessionClose: + parameters: dict[str, JsonValue] = {} + return cast(SessionClose, await self._invoke_async('session.close', parameters, timeout, cancel)) + + async def visit( + self, + *, + url: str, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[PageState]: + parameters: dict[str, JsonValue] = { + 'url': cast(JsonValue, url), + } + return cast(Untrusted[PageState], await self._invoke_async('visit', parameters, timeout, cancel)) + + async def inspect( + self, + *, + interactive: bool | None = None, + text: bool | None = None, + context: Literal['summary', 'outline', 'text', 'actions', 'full'] | None = None, + task: str | None = None, + within: str | None = None, + limit: int | None = None, + budget: int | None = None, + depth: int | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[Inspection]: + parameters: dict[str, JsonValue] = { + } + if interactive is not None: + parameters['interactive'] = cast(JsonValue, interactive) + if text is not None: + parameters['text'] = cast(JsonValue, text) + if context is not None: + parameters['context'] = cast(JsonValue, context) + if task is not None: + parameters['task'] = cast(JsonValue, task) + if within is not None: + parameters['within'] = cast(JsonValue, within) + if limit is not None: + parameters['limit'] = cast(JsonValue, limit) + if budget is not None: + parameters['budget'] = cast(JsonValue, budget) + if depth is not None: + parameters['depth'] = cast(JsonValue, depth) + return cast(Untrusted[Inspection], await self._invoke_async('inspect', parameters, timeout, cancel)) + + async def click( + self, + *, + target: str | None = None, + role: str | None = None, + name: str | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[Click]: + parameters: dict[str, JsonValue] = { + } + if target is not None: + parameters['target'] = cast(JsonValue, target) + if role is not None: + parameters['role'] = cast(JsonValue, role) + if name is not None: + parameters['name'] = cast(JsonValue, name) + return cast(Untrusted[Click], await self._invoke_async('click', parameters, timeout, cancel)) + + async def fill( + self, + *, + target: str | None = None, + role: str | None = None, + name: str | None = None, + value: str, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[Fill]: + parameters: dict[str, JsonValue] = { + 'value': cast(JsonValue, value), + } + if target is not None: + parameters['target'] = cast(JsonValue, target) + if role is not None: + parameters['role'] = cast(JsonValue, role) + if name is not None: + parameters['name'] = cast(JsonValue, name) + return cast(Untrusted[Fill], await self._invoke_async('fill', parameters, timeout, cancel)) + + async def upload( + self, + *, + target: str | None = None, + role: str | None = None, + name: str | None = None, + artifact: str, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[Upload]: + parameters: dict[str, JsonValue] = { + 'artifact': cast(JsonValue, artifact), + } + if target is not None: + parameters['target'] = cast(JsonValue, target) + if role is not None: + parameters['role'] = cast(JsonValue, role) + if name is not None: + parameters['name'] = cast(JsonValue, name) + return cast(Untrusted[Upload], await self._invoke_async('upload', parameters, timeout, cancel)) + + async def press( + self, + *, + key: str, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[Press]: + parameters: dict[str, JsonValue] = { + 'key': cast(JsonValue, key), + } + return cast(Untrusted[Press], await self._invoke_async('press', parameters, timeout, cancel)) + + async def scroll( + self, + *, + direction: Literal['up', 'down', 'top', 'bottom'] | None = None, + amount: int | float | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[Scroll]: + parameters: dict[str, JsonValue] = { + } + if direction is not None: + parameters['direction'] = cast(JsonValue, direction) + if amount is not None: + parameters['amount'] = cast(JsonValue, amount) + return cast(Untrusted[Scroll], await self._invoke_async('scroll', parameters, timeout, cancel)) + + async def back( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[PageState]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[PageState], await self._invoke_async('back', parameters, timeout, cancel)) + + async def reload( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[PageState]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[PageState], await self._invoke_async('reload', parameters, timeout, cancel)) + + async def wait( + self, + *, + settled: bool | None = None, + url: str | None = None, + text: str | None = None, + timeout_ms: int | float | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[PageState]: + parameters: dict[str, JsonValue] = { + } + if settled is not None: + parameters['settled'] = cast(JsonValue, settled) + if url is not None: + parameters['url'] = cast(JsonValue, url) + if text is not None: + parameters['text'] = cast(JsonValue, text) + if timeout_ms is not None: + parameters['timeoutMs'] = cast(JsonValue, timeout_ms) + return cast(Untrusted[PageState], await self._invoke_async('wait', parameters, timeout, cancel)) + + async def tour( + self, + *, + full_page: bool | None = None, + pace: int | float | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[Tour]: + parameters: dict[str, JsonValue] = { + } + if full_page is not None: + parameters['fullPage'] = cast(JsonValue, full_page) + if pace is not None: + parameters['pace'] = cast(JsonValue, pace) + return cast(Untrusted[Tour], await self._invoke_async('tour', parameters, timeout, cancel)) + + async def capture_info( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[CaptureInfo]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[CaptureInfo], await self._invoke_async('capture.info', parameters, timeout, cancel)) + + async def screenshot( + self, + *, + target: str | None = None, + role: str | None = None, + name: str | None = None, + full_page: bool | None = None, + output: str | None = None, + series: Literal['viewport', 'section'] | None = None, + output_prefix: str | None = None, + format: str | None = None, + clipboard: bool | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Screenshot: + parameters: dict[str, JsonValue] = { + } + if target is not None: + parameters['target'] = cast(JsonValue, target) + if role is not None: + parameters['role'] = cast(JsonValue, role) + if name is not None: + parameters['name'] = cast(JsonValue, name) + if full_page is not None: + parameters['fullPage'] = cast(JsonValue, full_page) + if output is not None: + parameters['output'] = cast(JsonValue, output) + if series is not None: + parameters['series'] = cast(JsonValue, series) + if output_prefix is not None: + parameters['outputPrefix'] = cast(JsonValue, output_prefix) + if format is not None: + parameters['format'] = cast(JsonValue, format) + if clipboard is not None: + parameters['clipboard'] = cast(JsonValue, clipboard) + return cast(Screenshot, await self._invoke_async('screenshot', parameters, timeout, cancel)) + + async def record_start( + self, + *, + output: str | None = None, + fps: int | float | None = None, + format: str | None = None, + quality: str | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Recording: + parameters: dict[str, JsonValue] = { + } + if output is not None: + parameters['output'] = cast(JsonValue, output) + if fps is not None: + parameters['fps'] = cast(JsonValue, fps) + if format is not None: + parameters['format'] = cast(JsonValue, format) + if quality is not None: + parameters['quality'] = cast(JsonValue, quality) + return cast(Recording, await self._invoke_async('record.start', parameters, timeout, cancel)) + + async def record_status( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Recording: + parameters: dict[str, JsonValue] = {} + return cast(Recording, await self._invoke_async('record.status', parameters, timeout, cancel)) + + async def record_stop( + self, + *, + output: str | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Recording: + parameters: dict[str, JsonValue] = { + } + if output is not None: + parameters['output'] = cast(JsonValue, output) + return cast(Recording, await self._invoke_async('record.stop', parameters, timeout, cancel)) + + async def qa_report( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[QAReport]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[QAReport], await self._invoke_async('qa.report', parameters, timeout, cancel)) + + async def qa_clear( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> QAClear: + parameters: dict[str, JsonValue] = {} + return cast(QAClear, await self._invoke_async('qa.clear', parameters, timeout, cancel)) + + async def console_list( + self, + *, + level: Literal['all', 'log', 'info', 'debug', 'warn', 'error', 'assert'] | None = None, + limit: int | float | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[ConsoleList]: + parameters: dict[str, JsonValue] = { + } + if level is not None: + parameters['level'] = cast(JsonValue, level) + if limit is not None: + parameters['limit'] = cast(JsonValue, limit) + return cast(Untrusted[ConsoleList], await self._invoke_async('console.list', parameters, timeout, cancel)) + + async def network_list( + self, + *, + failed: bool | None = None, + status: int | float | None = None, + limit: int | float | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[NetworkList]: + parameters: dict[str, JsonValue] = { + } + if failed is not None: + parameters['failed'] = cast(JsonValue, failed) + if status is not None: + parameters['status'] = cast(JsonValue, status) + if limit is not None: + parameters['limit'] = cast(JsonValue, limit) + return cast(Untrusted[NetworkList], await self._invoke_async('network.list', parameters, timeout, cancel)) + + async def network_get( + self, + *, + request_id: str, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[NetworkDetail]: + parameters: dict[str, JsonValue] = { + 'requestId': cast(JsonValue, request_id), + } + return cast(Untrusted[NetworkDetail], await self._invoke_async('network.get', parameters, timeout, cancel)) + + async def styles_get( + self, + *, + target: str | None = None, + role: str | None = None, + name: str | None = None, + properties: Sequence[str] | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[Styles]: + parameters: dict[str, JsonValue] = { + } + if target is not None: + parameters['target'] = cast(JsonValue, target) + if role is not None: + parameters['role'] = cast(JsonValue, role) + if name is not None: + parameters['name'] = cast(JsonValue, name) + if properties is not None: + parameters['properties'] = cast(JsonValue, properties) + return cast(Untrusted[Styles], await self._invoke_async('styles.get', parameters, timeout, cancel)) + + async def cookies_list( + self, + *, + include_values: bool | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[CookieList]: + parameters: dict[str, JsonValue] = { + } + if include_values is not None: + parameters['includeValues'] = cast(JsonValue, include_values) + return cast(Untrusted[CookieList], await self._invoke_async('cookies.list', parameters, timeout, cancel)) + + async def storage_list( + self, + *, + scope: Literal['local', 'session', 'all'] | None = None, + include_values: bool | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[StorageList]: + parameters: dict[str, JsonValue] = { + } + if scope is not None: + parameters['scope'] = cast(JsonValue, scope) + if include_values is not None: + parameters['includeValues'] = cast(JsonValue, include_values) + return cast(Untrusted[StorageList], await self._invoke_async('storage.list', parameters, timeout, cancel)) + + async def visual_compare( + self, + *, + before: str, + after: str, + output: str | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> VisualComparison: + parameters: dict[str, JsonValue] = { + 'before': cast(JsonValue, before), + 'after': cast(JsonValue, after), + } + if output is not None: + parameters['output'] = cast(JsonValue, output) + return cast(VisualComparison, await self._invoke_async('visual.compare', parameters, timeout, cancel)) + + async def performance_get( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[Performance]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[Performance], await self._invoke_async('performance.get', parameters, timeout, cancel)) + + async def animation_list( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[AnimationList]: + parameters: dict[str, JsonValue] = {} + return cast(Untrusted[AnimationList], await self._invoke_async('animation.list', parameters, timeout, cancel)) + + async def report_create( + self, + *, + output: str | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[Artifact]: + parameters: dict[str, JsonValue] = { + } + if output is not None: + parameters['output'] = cast(JsonValue, output) + return cast(Untrusted[Artifact], await self._invoke_async('report.create', parameters, timeout, cancel)) + + async def flow_start( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> FlowStart: + parameters: dict[str, JsonValue] = {} + return cast(FlowStart, await self._invoke_async('flow.start', parameters, timeout, cancel)) + + async def flow_stop( + self, + *, + output: str | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Artifact: + parameters: dict[str, JsonValue] = { + } + if output is not None: + parameters['output'] = cast(JsonValue, output) + return cast(Artifact, await self._invoke_async('flow.stop', parameters, timeout, cancel)) + + async def flow_run( + self, + *, + input: str, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[FlowRun]: + parameters: dict[str, JsonValue] = { + 'input': cast(JsonValue, input), + } + return cast(Untrusted[FlowRun], await self._invoke_async('flow.run', parameters, timeout, cancel)) + + async def network_emulate( + self, + *, + offline: bool | None = None, + latency_ms: int | float | None = None, + download_kbps: int | float | None = None, + upload_kbps: int | float | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> NetworkEmulation: + parameters: dict[str, JsonValue] = { + } + if offline is not None: + parameters['offline'] = cast(JsonValue, offline) + if latency_ms is not None: + parameters['latencyMs'] = cast(JsonValue, latency_ms) + if download_kbps is not None: + parameters['downloadKbps'] = cast(JsonValue, download_kbps) + if upload_kbps is not None: + parameters['uploadKbps'] = cast(JsonValue, upload_kbps) + return cast(NetworkEmulation, await self._invoke_async('network.emulate', parameters, timeout, cancel)) + + async def network_mock_set( + self, + *, + url: str, + status: int | float | None = None, + body: str, + content_type: str | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[NetworkMock]: + parameters: dict[str, JsonValue] = { + 'url': cast(JsonValue, url), + 'body': cast(JsonValue, body), + } + if status is not None: + parameters['status'] = cast(JsonValue, status) + if content_type is not None: + parameters['contentType'] = cast(JsonValue, content_type) + return cast(Untrusted[NetworkMock], await self._invoke_async('network.mock.set', parameters, timeout, cancel)) + + async def network_mock_clear( + self, + *, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> NetworkMockClear: + parameters: dict[str, JsonValue] = {} + return cast(NetworkMockClear, await self._invoke_async('network.mock.clear', parameters, timeout, cancel)) + + async def auth_login( + self, + *, + challenge: str | None = None, + account: str | None = None, + interactive: bool | None = None, + timeout: float | None = None, + cancel: AsyncCancellation | None = None, + ) -> Untrusted[AuthenticationLogin]: + parameters: dict[str, JsonValue] = { + } + if challenge is not None: + parameters['challenge'] = cast(JsonValue, challenge) + if account is not None: + parameters['account'] = cast(JsonValue, account) + if interactive is not None: + parameters['interactive'] = cast(JsonValue, interactive) + return cast(Untrusted[AuthenticationLogin], await self._invoke_async('auth.login', parameters, timeout, cancel)) diff --git a/packages/headless-python/src/headless_sdk/lifecycle.py b/packages/headless-python/src/headless_sdk/lifecycle.py new file mode 100644 index 0000000..be575c3 --- /dev/null +++ b/packages/headless-python/src/headless_sdk/lifecycle.py @@ -0,0 +1,618 @@ +from __future__ import annotations + +import asyncio +import atexit +import contextlib +import json +import os +import selectors +import shutil +import subprocess +import threading +import time +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import TypeVar, cast + +from ._protocol import decode_response +from ._transport import default_socket_path, validate_socket_location +from ._types import AsyncCancellation, SyncCancellation, is_finite_number +from .client import AsyncClient, Client, aconnect, connect +from .errors import CommandError, HeadlessError, HostLaunchError, ValidationError +from .generated import ( + LAUNCH_PRESENTATIONS, + LIFECYCLE_ERROR_CODES, + LOCAL_LIFECYCLE, + MAXIMUM_MESSAGE_BYTES, + HostStatus, + LaunchPresentation, + LifecycleErrorCode, +) + +_STARTUP_DIAGNOSTIC_LIMIT = 64 * 1024 +_DEFAULT_STARTUP_TIMEOUT = 10.0 +_DEFAULT_SHUTDOWN_TIMEOUT = 5.0 +T = TypeVar("T") + + +class _OwnershipRegistry: + def __init__(self) -> None: + self.hosts: set[HeadlessHost] = set() + self.lock = threading.Lock() + + +_OWNERS = _OwnershipRegistry() + + +class _Diagnostics: + def __init__(self) -> None: + self._value = bytearray() + self._lock = threading.Lock() + + def append(self, chunk: bytes) -> None: + with self._lock: + remaining = _STARTUP_DIAGNOSTIC_LIMIT - len(self._value) + if remaining > 0: + self._value.extend(chunk[:remaining]) + + def text(self) -> str: + with self._lock: + return self._value.decode("utf-8", errors="replace").strip() + + +def _drain_stderr(process: subprocess.Popen[bytes], diagnostics: _Diagnostics) -> None: + if process.stderr is None: + return + try: + while chunk := process.stderr.read(64 * 1024): + diagnostics.append(chunk) + except (OSError, ValueError): + return + + +def _cancelled(cancel: SyncCancellation | None) -> bool: + return cancel is not None and cancel.is_set() + + +async def _complete_shielded( + task: asyncio.Task[T], +) -> tuple[T, asyncio.CancelledError | None]: + interrupted: asyncio.CancelledError | None = None + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError as error: + if task.cancelled(): + raise + if interrupted is None: + interrupted = error + return task.result(), interrupted + + +def _read_startup_frame( + process: subprocess.Popen[bytes], + diagnostics: _Diagnostics, + deadline: float, + cancel: SyncCancellation | None, +) -> bytes: + if process.stdout is None: + raise HostLaunchError("supervised launcher stdout is unavailable") + output = bytearray() + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + while True: + if _cancelled(cancel): + raise HostLaunchError("supervised launch was cancelled during startup") + if process.poll() is not None and not selector.select(0): + detail = diagnostics.text() + suffix = f": {detail}" if detail else "" + raise _process_exit_error( + f"supervised Headless launcher exited before readiness{suffix}", + process.returncode, + ) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise HostLaunchError( + "supervised Headless launcher did not become ready before the deadline" + ) + wait = min(remaining, 0.05) if cancel is not None else remaining + if not selector.select(wait): + continue + chunk = os.read(process.stdout.fileno(), 64 * 1024) + if not chunk: + detail = diagnostics.text() + suffix = f": {detail}" if detail else "" + raise _process_exit_error( + f"supervised Headless launcher closed stdout before readiness{suffix}", + process.poll(), + ) + diagnostics.append(chunk) + output.extend(chunk) + if len(output) > MAXIMUM_MESSAGE_BYTES: + raise HostLaunchError( + "supervised launcher startup response exceeded the frame limit" + ) + newline = output.find(b"\n") + if newline < 0: + continue + if newline != len(output) - 1: + raise HostLaunchError("supervised launcher emitted multiple startup frames") + return bytes(output[:newline]) + + +def _decode_startup(frame: bytes) -> HostStatus: + try: + envelope = json.loads(frame) + if not isinstance(envelope, dict) or not isinstance(envelope.get("id"), str): + raise ValueError("startup response has no request id") + status = cast(HostStatus, decode_response(frame, envelope["id"], "ping")) + if status["protocolVersion"] != envelope.get("version"): + raise ValueError("startup result protocolVersion does not match its envelope") + return status + except CommandError as error: + if error.code in LIFECYCLE_ERROR_CODES: + raise HostLaunchError( + str(error), + code=cast(LifecycleErrorCode, error.code), + suggestion=error.suggestion, + details=error.details, + ) from error + raise HostLaunchError("supervised launcher returned an invalid startup response") from error + except (HeadlessError, UnicodeDecodeError, ValueError) as error: + raise HostLaunchError("supervised launcher returned an invalid startup response") from error + + +def _terminate_and_reap(process: subprocess.Popen[bytes], timeout: float) -> None: + if process.stdin is not None and not process.stdin.closed: + with contextlib.suppress(OSError): + process.stdin.close() + try: + process.wait(timeout=timeout) + return + except subprocess.TimeoutExpired: + process.terminate() + try: + process.wait(timeout=timeout) + return + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def _process_exit_error(message: str, returncode: int | None) -> HostLaunchError: + return HostLaunchError( + message, + exit_code=returncode if returncode is not None and returncode >= 0 else None, + signal=-returncode if returncode is not None and returncode < 0 else None, + ) + + +class HeadlessHost: + def __init__( + self, + client: Client, + process: subprocess.Popen[bytes], + host_pid: int, + shutdown_timeout: float, + ) -> None: + self.client = client + self.pid = host_pid + self.launcher_pid = process.pid + self._process = process + self._shutdown_timeout = shutdown_timeout + self._close_lock = threading.Lock() + self._cleaned = False + self._detached_after_fork = False + self._exited = threading.Event() + self._exit_callbacks: list[Callable[[int], None]] = [] + self._fork_detachers: list[Callable[[], None]] = [client._after_fork_child] + with _OWNERS.lock: + _OWNERS.hosts.add(self) + threading.Thread(target=self._watch_stdout, daemon=True).start() + threading.Thread(target=self._watch_exit, daemon=True).start() + + @property + def returncode(self) -> int | None: + return self._process.poll() + + def _watch_stdout(self) -> None: + if self._process.stdout is None: + return + try: + if self._process.stdout.read(1): + self.close() + except (OSError, ValueError): + return + + def _watch_exit(self) -> None: + self._process.wait() + with self._close_lock: + callbacks = self._finish_cleanup() + self._notify_exit(callbacks) + + def _finish_cleanup(self) -> tuple[Callable[[int], None], ...]: + if self._cleaned: + return () + self.client.close() + for stream in (self._process.stdin, self._process.stdout, self._process.stderr): + if stream is not None: + with contextlib.suppress(OSError, ValueError): + stream.close() + with _OWNERS.lock: + _OWNERS.hosts.discard(self) + self._cleaned = True + callbacks = tuple(self._exit_callbacks) + self._exit_callbacks.clear() + self._fork_detachers.clear() + self._exited.set() + return callbacks + + def _notify_exit(self, callbacks: tuple[Callable[[int], None], ...]) -> None: + returncode = cast(int, self._process.returncode) + for callback in callbacks: + with contextlib.suppress(BaseException): + callback(returncode) + + def _register_exit_callback(self, callback: Callable[[int], None]) -> None: + with self._close_lock: + completed = self._cleaned + if not completed: + self._exit_callbacks.append(callback) + if completed: + callback(cast(int, self._process.returncode)) + + def _register_fork_detacher(self, detacher: Callable[[], None]) -> None: + with self._close_lock: + if self._detached_after_fork: + detacher() + return + self._fork_detachers.append(detacher) + + def _after_fork_child(self) -> None: + self._detached_after_fork = True + self._close_lock = threading.Lock() + for detacher in self._fork_detachers: + detacher() + self._fork_detachers.clear() + self._exit_callbacks.clear() + for stream in (self._process.stdin, self._process.stdout, self._process.stderr): + if stream is not None and not stream.closed: + with contextlib.suppress(OSError, ValueError): + stream.close() + + def wait(self, timeout: float | None = None) -> int: + if self._detached_after_fork: + raise RuntimeError("an inherited Headless host cannot be awaited after fork") + if not self._exited.wait(timeout): + raise TimeoutError("owned Headless launcher did not exit before the deadline") + return cast(int, self._process.returncode) + + def close(self) -> None: + with self._close_lock: + if self._detached_after_fork or self._cleaned: + return + try: + _terminate_and_reap(self._process, self._shutdown_timeout) + finally: + callbacks = self._finish_cleanup() + self._notify_exit(callbacks) + + def __enter__(self) -> HeadlessHost: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +def _close_owned_hosts() -> None: + with _OWNERS.lock: + hosts = tuple(_OWNERS.hosts) + for host in hosts: + with contextlib.suppress(BaseException): + host.close() + + +def _after_fork_in_child() -> None: + inherited_hosts = tuple(_OWNERS.hosts) + for host in inherited_hosts: + host._after_fork_child() + _OWNERS.hosts.clear() + _OWNERS.lock = threading.Lock() + + +atexit.register(_close_owned_hosts) +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_after_fork_in_child) + + +def _selected_executable(executable: str | os.PathLike[str] | None) -> str: + if executable is not None: + selected = os.fspath(executable) + if not isinstance(selected, str) or not os.path.isabs(selected): + raise ValidationError("executable must be an absolute path") + return selected + discovered = shutil.which("headless") + if discovered is None: + raise HostLaunchError("headless executable was not found on PATH") + return str(Path(discovered).resolve()) + + +def _bounded_timeout(name: str, value: float, maximum: float) -> None: + if not is_finite_number(value) or value <= 0 or value > maximum: + raise ValidationError(f"{name} must be greater than zero and at most {maximum} seconds") + + +def launch( + *, + executable: str | os.PathLike[str] | None = None, + socket_path: str | None = None, + presentation: LaunchPresentation | None = None, + allow: Sequence[str] = (), + environment: Mapping[str, str] | None = None, + startup_timeout: float = _DEFAULT_STARTUP_TIMEOUT, + shutdown_timeout: float = _DEFAULT_SHUTDOWN_TIMEOUT, + cancel: SyncCancellation | None = None, +) -> HeadlessHost: + _bounded_timeout("startup_timeout", startup_timeout, 120.0) + _bounded_timeout("shutdown_timeout", shutdown_timeout, 30.0) + deadline = time.monotonic() + startup_timeout + if _cancelled(cancel): + raise HostLaunchError("supervised launch was cancelled before spawn") + selected_executable = _selected_executable(executable) + selected_environment = dict(os.environ) + if environment is not None: + if not isinstance(environment, Mapping) or any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in environment.items() + ): + raise ValidationError("environment must map string names to string values") + selected_environment.update(environment) + host_executable = selected_environment.get("HEADLESS_HOST_EXECUTABLE") + if host_executable is not None and not os.path.isabs(host_executable): + raise ValidationError("HEADLESS_HOST_EXECUTABLE must be an absolute path") + selected_socket = ( + default_socket_path(selected_environment) if socket_path is None else socket_path + ) + validate_socket_location(selected_socket) + if presentation is not None and ( + not isinstance(presentation, str) or presentation not in LAUNCH_PRESENTATIONS + ): + raise ValidationError(f"presentation must be one of {', '.join(LAUNCH_PRESENTATIONS)}") + if isinstance(allow, (str, bytes)) or not isinstance(allow, Sequence): + raise ValidationError("allow must be a sequence of host patterns") + presentation_flags = {f"--{value}" for value in LAUNCH_PRESENTATIONS} + argv = cast(list[str], list(LOCAL_LIFECYCLE["launch"]["argv"])) + existing_flags = [argument for argument in argv if argument in presentation_flags] + if existing_flags: + raise ValidationError("generated launch argv has an invalid presentation flag") + supervised_indexes = [ + index for index, argument in enumerate(argv) if argument == "--supervised" + ] + if len(supervised_indexes) != 1: + raise ValidationError("generated launch argv has an invalid supervised flag") + if presentation is not None: + argv.insert(supervised_indexes[0], f"--{presentation}") + allow_definition = next( + (option for option in LOCAL_LIFECYCLE["launch"]["options"] if option["name"] == "allow"), + None, + ) + if allow_definition is None or len(allow) > allow_definition["maximumItems"]: + raise ValidationError("allowlist has too many patterns") + for pattern in allow: + if ( + not isinstance(pattern, str) + or not pattern + or len(pattern.encode("utf-8")) > allow_definition["itemMaximumBytes"] + ): + raise ValidationError("allowlist patterns are empty or exceed the schema limit") + argv.extend(("--allow", pattern)) + selected_environment["HEADLESS_SOCKET"] = selected_socket + try: + process = subprocess.Popen( + [selected_executable, *argv], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + env=selected_environment, + ) + except OSError as error: + raise HostLaunchError(f"could not start supervised Headless: {error}") from error + diagnostics = _Diagnostics() + threading.Thread(target=_drain_stderr, args=(process, diagnostics), daemon=True).start() + client: Client | None = None + try: + startup = _decode_startup(_read_startup_frame(process, diagnostics, deadline, cancel)) + startup_pid = startup["pid"] + if not isinstance(startup_pid, int) or isinstance(startup_pid, bool) or startup_pid <= 0: + raise HostLaunchError("supervised launcher returned an invalid host pid") + if startup["ready"] is not True: + raise HostLaunchError("supervised launcher reported a host that is not ready") + if process.poll() is not None: + raise _process_exit_error( + "supervised Headless launcher exited before ownership was established", + process.returncode, + ) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise HostLaunchError( + "supervised Headless launcher did not become ready before the deadline" + ) + client = connect(selected_socket, timeout=remaining, cancel=cancel) + connected_pid = client.host_status["pid"] + if ( + not isinstance(connected_pid, int) + or isinstance(connected_pid, bool) + or connected_pid <= 0 + ): + raise HostLaunchError("connected Headless host returned an invalid pid") + if connected_pid != startup_pid: + raise HostLaunchError( + f"supervised launcher reported host pid {startup_pid}, " + f"but the socket belongs to pid {connected_pid}" + ) + if process.poll() is not None: + raise _process_exit_error( + "supervised Headless launcher exited before ownership was established", + process.returncode, + ) + return HeadlessHost(client, process, startup_pid, shutdown_timeout) + except BaseException as error: + if client is not None: + client.close() + with contextlib.suppress(BaseException): + _terminate_and_reap(process, shutdown_timeout) + if isinstance(error, HeadlessError): + raise + raise HostLaunchError("supervised Headless launch failed") from error + + +class AsyncHeadlessHost: + def __init__(self, owner: HeadlessHost, client: AsyncClient) -> None: + self._owner = owner + self.client = client + self.pid = owner.pid + self._close_lock = asyncio.Lock() + self._close_task: asyncio.Task[None] | None = None + self._loop = asyncio.get_running_loop() + self._exit_future: asyncio.Future[int] = self._loop.create_future() + self._exit_cleanup_task: asyncio.Task[None] | None = None + self._detached_after_fork = False + owner._register_fork_detacher(client._after_fork_child) + owner._register_fork_detacher(self._after_fork_child) + owner._register_exit_callback(self._owner_exited) + + @property + def returncode(self) -> int | None: + return self._owner.returncode + + async def wait(self, timeout: float | None = None) -> int: + if self._detached_after_fork: + raise RuntimeError("an inherited Headless host cannot be awaited after fork") + completion = asyncio.shield(self._exit_future) + if timeout is None: + return await completion + try: + return await asyncio.wait_for(completion, timeout) + except TimeoutError as error: + raise TimeoutError( + "owned Headless launcher did not exit before the deadline" + ) from error + + def _owner_exited(self, returncode: int) -> None: + with contextlib.suppress(RuntimeError): + self._loop.call_soon_threadsafe(self._start_exit_cleanup, returncode) + + def _start_exit_cleanup(self, returncode: int) -> None: + if self._detached_after_fork or self._exit_cleanup_task is not None: + return + self._exit_cleanup_task = asyncio.create_task(self._complete_exit(returncode)) + + async def _complete_exit(self, returncode: int) -> None: + await self.client.close() + if not self._exit_future.done(): + self._exit_future.set_result(returncode) + + def _after_fork_child(self) -> None: + self._detached_after_fork = True + + async def close(self) -> None: + if self._detached_after_fork: + return + async with self._close_lock: + if self._close_task is None: + self._close_task = asyncio.create_task(self._close()) + close_task = self._close_task + _, interrupted = await _complete_shielded(close_task) + if interrupted is not None: + raise interrupted + + async def _close(self) -> None: + await self.client.close() + await asyncio.to_thread(self._owner.close) + await self.wait() + + async def __aenter__(self) -> AsyncHeadlessHost: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + +async def alaunch( + *, + executable: str | os.PathLike[str] | None = None, + socket_path: str | None = None, + presentation: LaunchPresentation | None = None, + allow: Sequence[str] = (), + environment: Mapping[str, str] | None = None, + startup_timeout: float = _DEFAULT_STARTUP_TIMEOUT, + shutdown_timeout: float = _DEFAULT_SHUTDOWN_TIMEOUT, + cancel: AsyncCancellation | None = None, +) -> AsyncHeadlessHost: + _bounded_timeout("startup_timeout", startup_timeout, 120.0) + deadline = time.monotonic() + startup_timeout + sync_cancel = threading.Event() + + async def forward_cancellation() -> None: + if cancel is not None: + await cancel.wait() + sync_cancel.set() + + cancellation_task = asyncio.create_task(forward_cancellation()) + worker = asyncio.create_task( + asyncio.to_thread( + launch, + executable=executable, + socket_path=socket_path, + presentation=presentation, + allow=allow, + environment=environment, + startup_timeout=max(0.001, deadline - time.monotonic()), + shutdown_timeout=shutdown_timeout, + cancel=sync_cancel, + ) + ) + owner: HeadlessHost | None = None + try: + owner = await asyncio.shield(worker) + owner.client.close() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise HostLaunchError( + "supervised Headless launcher did not become ready before the deadline" + ) + async_client = await aconnect( + owner.client.socket_path, + timeout=remaining, + cancel=cancel, + ) + if async_client.host_status["pid"] != owner.pid: + await async_client.close() + raise HostLaunchError("async client connected to a different Headless host") + return AsyncHeadlessHost(owner, async_client) + except asyncio.CancelledError as cancellation: + sync_cancel.set() + try: + owner, _ = await _complete_shielded(worker) + except BaseException: + owner = None + if owner is not None: + cleanup = asyncio.create_task(asyncio.to_thread(owner.close)) + with contextlib.suppress(BaseException): + await _complete_shielded(cleanup) + raise cancellation + except BaseException as error: + if owner is not None: + cleanup = asyncio.create_task(asyncio.to_thread(owner.close)) + try: + _, interrupted = await _complete_shielded(cleanup) + except BaseException: + interrupted = None + if interrupted is not None: + raise interrupted from error + raise + finally: + cancellation_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cancellation_task diff --git a/packages/headless-python/src/headless_sdk/py.typed b/packages/headless-python/src/headless_sdk/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/headless-python/src/headless_sdk/py.typed @@ -0,0 +1 @@ + diff --git a/packages/headless-python/tests/__init__.py b/packages/headless-python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/headless-python/tests/fixtures/auth-required.json b/packages/headless-python/tests/fixtures/auth-required.json new file mode 100644 index 0000000..9b39b7b --- /dev/null +++ b/packages/headless-python/tests/fixtures/auth-required.json @@ -0,0 +1,53 @@ +{ + "valid": { + "challenge": "11111111-1111-4111-8111-111111111111", + "origin": "https://example.com", + "detection": "password-field", + "accounts": [ + { + "alias": "work", + "username": "person@example.com" + } + ], + "expiresInSeconds": 300, + "userPresenceRequired": true, + "credentialUseAvailable": true, + "vaultAvailable": true, + "vaultStatus": "available", + "untrustedContent": true, + "originalActionReplayed": false + }, + "invalid": [ + { + "name": "missing challenge", + "details": { + "origin": "https://example.com", + "detection": "password-field", + "accounts": [], + "expiresInSeconds": 300, + "userPresenceRequired": true, + "credentialUseAvailable": true, + "vaultAvailable": true, + "vaultStatus": "available", + "untrustedContent": true, + "originalActionReplayed": false + } + }, + { + "name": "invalid accounts", + "details": { + "challenge": "11111111-1111-4111-8111-111111111111", + "origin": "https://example.com", + "detection": "password-field", + "accounts": "work", + "expiresInSeconds": 300, + "userPresenceRequired": true, + "credentialUseAvailable": true, + "vaultAvailable": true, + "vaultStatus": "available", + "untrustedContent": true, + "originalActionReplayed": false + } + } + ] +} diff --git a/packages/headless-python/tests/helpers.py b/packages/headless-python/tests/helpers.py new file mode 100644 index 0000000..8a12624 --- /dev/null +++ b/packages/headless-python/tests/helpers.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import atexit +import json +import os +import socket +import stat +import threading +import uuid +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from headless_sdk.generated import COMMAND_METADATA, PROTOCOL_VERSION + +ResponseFactory = Callable[[dict[str, Any], socket.socket], bytes | None] +_TEST_SOCKET_PATHS: set[Path] = set() + + +def _cleanup_test_sockets() -> None: + for path in _TEST_SOCKET_PATHS: + path.unlink(missing_ok=True) + + +atexit.register(_cleanup_test_sockets) + + +def unique_socket_path(prefix: str) -> str: + directory = Path(f"/tmp/headless-{os.getuid()}") + directory.mkdir(mode=0o700, exist_ok=True) + mode = directory.lstat().st_mode + if directory.is_symlink() or stat.S_IMODE(mode) & 0o077: + raise RuntimeError("Headless runtime directory is not private") + path = directory / f"python-{os.getpid()}-{prefix}-{uuid.uuid4().hex}.sock" + _TEST_SOCKET_PATHS.add(path) + return str(path) + + +def json_frame(value: object) -> bytes: + return json.dumps(value, separators=(",", ":")).encode() + b"\n" + + +def host_status( + request_id: str, + pid: int = 7001, + commands: list[str] | None = None, +) -> dict[str, Any]: + return { + "id": request_id, + "version": PROTOCOL_VERSION, + "ok": True, + "result": { + "ready": True, + "pid": pid, + "engine": "chromium", + "platform": "linux", + "productVersion": "1.1.0-test", + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { + "commands": list(COMMAND_METADATA) if commands is None else commands, + }, + "recordingAvailable": False, + "artifactDirectory": "/private/test-artifacts", + "navigationAllowlist": [], + }, + } + + +class PrivateSocketServer: + def __init__(self, response: ResponseFactory, prefix: str = "server") -> None: + self.socket_path = unique_socket_path(prefix) + self.requests: list[dict[str, Any]] = [] + self._response = response + self._closed = threading.Event() + self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._server.bind(self.socket_path) + os.chmod(self.socket_path, 0o600) + self._server.listen() + self._server.settimeout(0.05) + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self) -> None: + while not self._closed.is_set(): + try: + connection, _ = self._server.accept() + except TimeoutError: + continue + except OSError: + return + threading.Thread( + target=self._handle, + args=(connection,), + daemon=True, + ).start() + + def _handle(self, connection: socket.socket) -> None: + with connection: + data = bytearray() + while b"\n" not in data and len(data) <= 1024 * 1024: + chunk = connection.recv(64 * 1024) + if not chunk: + return + data.extend(chunk) + request = json.loads(bytes(data).split(b"\n", 1)[0]) + self.requests.append(request) + response = self._response(request, connection) + if response is not None: + try: + connection.sendall(response) + except (BrokenPipeError, ConnectionResetError): + return + + def close(self) -> None: + if self._closed.is_set(): + return + self._closed.set() + self._server.close() + self._thread.join(timeout=1) + Path(self.socket_path).unlink(missing_ok=True) + + def __enter__(self) -> PrivateSocketServer: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +def write_mock_launcher(directory: Path) -> Path: + executable = directory / "headless-test.py" + executable.write_text( + f"""#!/usr/bin/env python3 +import json +import os +import socket +import sys +import threading +import time + +PROTOCOL_VERSION = {PROTOCOL_VERSION!r} +mode = os.environ.get("HEADLESS_TEST_MODE", "owned") +expected_presentation = os.environ.get("HEADLESS_TEST_PRESENTATION") +expected = ["start"] +if expected_presentation: + expected.append("--" + expected_presentation) +expected.append("--supervised") +if sys.argv[1:1 + len(expected)] != expected: + sys.exit(64) +socket_path = os.environ["HEADLESS_SOCKET"] +commands = json.loads(os.environ["HEADLESS_TEST_COMMANDS"]) +pid_file = os.environ.get("HEADLESS_TEST_PID_FILE") +if pid_file: + open(pid_file, "w", encoding="utf-8").write(str(os.getpid())) +if mode == "failure": + sys.exit(7) + +def status(request_id, pid): + return {{ + "id": request_id, + "version": PROTOCOL_VERSION, + "ok": True, + "result": {{ + "ready": True, + "pid": pid, + "engine": "chromium", + "platform": "linux", + "productVersion": "1.1.0-test", + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {{"commands": commands}}, + "recordingAvailable": False, + "artifactDirectory": "/private/test-artifacts", + "navigationAllowlist": [], + }}, + }} + +if mode == "failure-envelope": + print(json.dumps({{ + "id": "startup-failure", + "version": PROTOCOL_VERSION, + "ok": False, + "error": {{ + "code": "NAVIGATION_ALLOWLIST_CONFLICT", + "message": "an incompatible host is already running", + "suggestion": "stop the existing host", + "details": {{"requested": ["two.example"]}}, + }}, + }}), flush=True) + sys.stdin.buffer.read() + sys.exit(0) + +server = None +if mode not in {{"existing", "no-frame", "malformed", "multiple"}}: + os.makedirs(os.path.dirname(socket_path), mode=0o700, exist_ok=True) + try: + os.unlink(socket_path) + except FileNotFoundError: + pass + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(socket_path) + os.chmod(socket_path, 0o600) + server.listen() + server.settimeout(0.05) + +if mode == "malformed": + print("not-json", flush=True) +elif mode == "multiple": + line = json.dumps(status("startup", os.getpid())) + sys.stdout.write(line + "\\n" + line + "\\n") + sys.stdout.flush() +elif mode != "no-frame": + startup_delay = os.environ.get("HEADLESS_TEST_STARTUP_DELAY") + if startup_delay: + time.sleep(float(startup_delay)) + startup_pid = int(os.environ.get("HEADLESS_TEST_STARTUP_PID", os.getpid())) + print(json.dumps(status("startup", startup_pid)), flush=True) + if mode == "delayed-multiple": + def extra(): + print(json.dumps(status("late", os.getpid())), flush=True) + threading.Timer(0.1, extra).start() + +stopping = False +def owner_closed(): + global stopping + while os.read(sys.stdin.fileno(), 65536): + pass + stopping = True + if server is not None: + server.close() +threading.Thread(target=owner_closed, daemon=True).start() + +exit_after = os.environ.get("HEADLESS_TEST_EXIT_AFTER") +deadline = time.monotonic() + float(exit_after) if exit_after else None +request_count = 0 +while not stopping: + if deadline is not None and time.monotonic() >= deadline: + break + if server is None: + time.sleep(0.02) + continue + try: + connection, _ = server.accept() + except TimeoutError: + continue + except OSError: + break + with connection: + data = bytearray() + while b"\\n" not in data: + chunk = connection.recv(65536) + if not chunk: + break + data.extend(chunk) + if data: + request = json.loads(bytes(data).split(b"\\n", 1)[0]) + request_count += 1 + if request_count == 2 and os.environ.get("HEADLESS_TEST_SECOND_RESPONSE_DELAY"): + time.sleep(float(os.environ["HEADLESS_TEST_SECOND_RESPONSE_DELAY"])) + connection.sendall((json.dumps(status(request["id"], os.getpid())) + "\\n").encode()) +if server is not None: + server.close() + try: + os.unlink(socket_path) + except FileNotFoundError: + pass +""", + encoding="utf-8", + ) + executable.chmod(0o755) + return executable diff --git a/packages/headless-python/tests/swift_integration.py b/packages/headless-python/tests/swift_integration.py new file mode 100644 index 0000000..7e2ec10 --- /dev/null +++ b/packages/headless-python/tests/swift_integration.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import asyncio +import os +import uuid +from pathlib import Path + +from headless_sdk import PROTOCOL_VERSION, alaunch, launch +from headless_sdk._transport import runtime_directory + + +def required_absolute_environment(name: str) -> str: + value = os.environ.get(name) + if value is None or not os.path.isabs(value): + raise RuntimeError(f"{name} must be an absolute path") + return value + + +def socket_path(label: str) -> str: + return os.path.join(runtime_directory(), f"python-swift-{label}-{uuid.uuid4().hex}.sock") + + +def run_sync(executable: str, host_executable: str) -> None: + path = socket_path("sync") + host = None + try: + host = launch( + executable=executable, + socket_path=path, + environment={"HEADLESS_HOST_EXECUTABLE": host_executable}, + ) + assert host.client.host_status["ready"] is True + assert host.client.host_status["protocolVersion"] == PROTOCOL_VERSION + assert host.client.ping()["pid"] == host.pid + assert isinstance(host.client.session_list()["sessions"], list) + finally: + if host is not None: + host.close() + Path(path).unlink(missing_ok=True) + + +async def run_async(executable: str, host_executable: str) -> None: + path = socket_path("async") + host = None + try: + host = await alaunch( + executable=executable, + socket_path=path, + environment={"HEADLESS_HOST_EXECUTABLE": host_executable}, + ) + assert host.client.host_status["ready"] is True + assert host.client.host_status["protocolVersion"] == PROTOCOL_VERSION + assert (await host.client.ping())["pid"] == host.pid + assert isinstance((await host.client.session_list())["sessions"], list) + finally: + if host is not None: + await host.close() + await asyncio.to_thread(Path(path).unlink, missing_ok=True) + + +def main() -> None: + if os.uname().sysname not in {"Darwin", "Linux"}: + raise RuntimeError("Swift SDK integration requires macOS or Linux") + executable = required_absolute_environment("HEADLESS_TEST_CLI") + host_executable = required_absolute_environment("HEADLESS_TEST_HOST") + run_sync(executable, host_executable) + asyncio.run(run_async(executable, host_executable)) + + +if __name__ == "__main__": + main() diff --git a/packages/headless-python/tests/test_lifecycle.py b/packages/headless-python/tests/test_lifecycle.py new file mode 100644 index 0000000..aeed504 --- /dev/null +++ b/packages/headless-python/tests/test_lifecycle.py @@ -0,0 +1,533 @@ +from __future__ import annotations + +import asyncio +import gc +import json +import os +import signal +import subprocess +import sys +import threading +import time +import traceback +import warnings +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any + +import pytest + +from headless_sdk import ( + ClientClosedError, + HostLaunchError, + OperationOutcomeUnknown, + ValidationError, + aconnect, + alaunch, + connect, + launch, +) +from headless_sdk.generated import COMMAND_METADATA, LOCAL_LIFECYCLE + +from .helpers import ( + PrivateSocketServer, + host_status, + json_frame, + unique_socket_path, + write_mock_launcher, +) + + +def environment(**extra: str) -> dict[str, str]: + return {"HEADLESS_TEST_COMMANDS": json.dumps(list(COMMAND_METADATA)), **extra} + + +def process_is_gone(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + return False + + +def wait_until(predicate: Any, timeout: float = 2) -> None: + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() >= deadline: + raise AssertionError("condition was not met before deadline") + time.sleep(0.01) + + +def test_supervised_launch_owns_only_exact_matching_host(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + socket_path = unique_socket_path("owned") + signal_handlers = { + signal.SIGINT: signal.getsignal(signal.SIGINT), + signal.SIGTERM: signal.getsignal(signal.SIGTERM), + } + host = launch(executable=executable, socket_path=socket_path, environment=environment()) + try: + assert LOCAL_LIFECYCLE["launch"]["argv"] == ["start", "--supervised"] + assert host.pid == host.client.host_status["pid"] + assert host.launcher_pid > 0 + assert signal.getsignal(signal.SIGINT) == signal_handlers[signal.SIGINT] + assert signal.getsignal(signal.SIGTERM) == signal_handlers[signal.SIGTERM] + host.client.close() + time.sleep(0.05) + assert host.returncode is None + finally: + host.close() + assert host.wait(1) == 0 + assert process_is_gone(host.launcher_pid) + + +def test_foreground_presentation_allowlist_and_absolute_paths(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + host = launch( + executable=executable, + socket_path=unique_socket_path("foreground"), + presentation="foreground", + allow=["example.com"], + environment=environment(HEADLESS_TEST_PRESENTATION="foreground"), + ) + host.close() + with pytest.raises(ValidationError, match="presentation"): + launch( + executable=executable, + socket_path=unique_socket_path("presentation"), + presentation="sideways", # type: ignore[arg-type] + environment=environment(), + ) + with pytest.raises(ValidationError, match="presentation"): + launch( + executable=executable, + socket_path=unique_socket_path("presentation-type"), + presentation=1, # type: ignore[arg-type] + environment=environment(), + ) + for invalid_allow in ("example.com", b"example.com", [""], [1]): + with pytest.raises(ValidationError, match="allow"): + launch( + executable=executable, + socket_path=unique_socket_path("allow"), + allow=invalid_allow, # type: ignore[arg-type] + environment=environment(), + ) + with pytest.raises(ValidationError, match="absolute"): + launch(executable="relative/headless") + with pytest.raises(ValidationError, match="HEADLESS_HOST_EXECUTABLE"): + launch( + executable=executable, + socket_path=unique_socket_path("host-executable"), + environment=environment(HEADLESS_HOST_EXECUTABLE="relative/host"), + ) + with pytest.raises(ValidationError, match="environment"): + launch( + executable=executable, + socket_path=unique_socket_path("environment"), + environment={"BAD": 1}, # type: ignore[dict-item] + ) + with pytest.raises(ValidationError, match="direct child"): + launch(executable=executable, socket_path=str(tmp_path / "host.sock")) + with pytest.raises(ValidationError, match="must be absolute"): + launch(executable=executable, socket_path="", environment=environment()) + with pytest.raises(ValidationError, match="startup_timeout"): + launch(executable=executable, startup_timeout=10**1000) + + +def test_concurrent_shared_host_is_not_claimed_or_stopped(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + with PrivateSocketServer( + lambda request, _: json_frame(host_status(request["id"], 7201)), + "race", + ) as shared: + pid_file = tmp_path / "race.pid" + with pytest.raises(HostLaunchError, match=r"pid 7202.*pid 7201"): + launch( + executable=executable, + socket_path=shared.socket_path, + environment=environment( + HEADLESS_TEST_MODE="existing", + HEADLESS_TEST_STARTUP_PID="7202", + HEADLESS_TEST_PID_FILE=str(pid_file), + ), + ) + launcher_pid = int(pid_file.read_text()) + assert process_is_gone(launcher_pid) + client = connect(shared.socket_path) + assert client.host_status["pid"] == 7201 + client.close() + assert len(shared.requests) == 2 + + +def test_missing_binary_and_structured_startup_failures(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + with pytest.raises(HostLaunchError) as missing: + launch(executable=tmp_path / "missing", socket_path=unique_socket_path("missing")) + assert missing.value.code == "HOST_START_FAILED" + + pid_file = tmp_path / "failure.pid" + with pytest.raises(HostLaunchError) as failed: + launch( + executable=executable, + socket_path=unique_socket_path("failure"), + environment=environment( + HEADLESS_TEST_MODE="failure", + HEADLESS_TEST_PID_FILE=str(pid_file), + ), + ) + assert failed.value.exit_code == 7 + assert process_is_gone(int(pid_file.read_text())) + + with pytest.raises(HostLaunchError) as envelope: + launch( + executable=executable, + socket_path=unique_socket_path("failure-envelope"), + environment=environment(HEADLESS_TEST_MODE="failure-envelope"), + ) + assert envelope.value.code == "NAVIGATION_ALLOWLIST_CONFLICT" + assert envelope.value.suggestion == "stop the existing host" + assert envelope.value.details["requested"] == ["two.example"] + + +def test_timeout_cancellation_and_invalid_startup_frames_reap_launcher(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + for mode in ("no-frame", "malformed", "multiple"): + pid_file = tmp_path / f"{mode}.pid" + with pytest.raises(HostLaunchError): + launch( + executable=executable, + socket_path=unique_socket_path(mode), + startup_timeout=2, + shutdown_timeout=0.1, + environment=environment( + HEADLESS_TEST_MODE=mode, + HEADLESS_TEST_PID_FILE=str(pid_file), + ), + ) + assert process_is_gone(int(pid_file.read_text())) + + cancel = threading.Event() + cancel.set() + with pytest.raises(HostLaunchError, match="cancelled before spawn"): + launch(executable=executable, cancel=cancel) + + +def test_host_exit_and_delayed_startup_violation_close_client(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + host = launch( + executable=executable, + socket_path=unique_socket_path("exit"), + environment=environment(HEADLESS_TEST_EXIT_AFTER="0.15"), + ) + wait_until(lambda: host.returncode is not None) + host.wait(1) + with pytest.raises(ClientClosedError): + host.client.ping() + assert all( + stream is None or stream.closed + for stream in (host._process.stdin, host._process.stdout, host._process.stderr) + ) + host.close() + + delayed = launch( + executable=executable, + socket_path=unique_socket_path("delayed"), + environment=environment(HEADLESS_TEST_MODE="delayed-multiple"), + ) + wait_until(lambda: delayed.returncode is not None) + delayed.wait(1) + with pytest.raises(ClientClosedError): + delayed.client.ping() + assert process_is_gone(delayed.launcher_pid) + + +@pytest.mark.skipif( + not hasattr(os, "fork") or not hasattr(os, "register_at_fork"), + reason="fork safety is POSIX-only", +) +def test_forked_child_detaches_owner_without_stopping_parent(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + host = launch( + executable=executable, + socket_path=unique_socket_path("fork"), + environment=environment(), + ) + streams = tuple( + stream + for stream in (host._process.stdin, host._process.stdout, host._process.stderr) + if stream is not None + ) + inherited_fds = {stream.fileno() for stream in streams} + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + child_pid = os.fork() + if child_pid == 0: + status = 0 + try: + assert host._detached_after_fork + assert all(stream.closed for stream in streams) + with pytest.raises(ClientClosedError): + host.client.ping() + host.close() + replacement_fds = [os.open(os.devnull, os.O_RDONLY) for _ in streams] + assert inherited_fds.intersection(replacement_fds) + host._process.stdin = None + host._process.stdout = None + host._process.stderr = None + del streams + gc.collect() + for descriptor in replacement_fds: + os.fstat(descriptor) + os.close(descriptor) + except BaseException: + status = 1 + os._exit(status) + _, child_status = os.waitpid(child_pid, 0) + try: + assert os.waitstatus_to_exitcode(child_status) == 0 + assert host.returncode is None + assert host.client.ping()["pid"] == host.pid + finally: + host.close() + + +def test_async_launch_uses_async_client_and_preserves_ownership( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + executable = write_mock_launcher(tmp_path) + + async def scenario() -> None: + host = await alaunch( + executable=executable, + socket_path=unique_socket_path("async"), + environment=environment(), + ) + assert host.pid == host.client.host_status["pid"] + assert (await host.client.ping())["pid"] == host.pid + original_close = host._owner.close + cleanup_started = threading.Event() + + def delayed_close() -> None: + cleanup_started.set() + time.sleep(0.05) + original_close() + + monkeypatch.setattr(host._owner, "close", delayed_close) + closing = asyncio.create_task(host.close()) + assert await asyncio.to_thread(cleanup_started.wait, 1) + closing.cancel() + await asyncio.sleep(0.01) + closing.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + assert process_is_gone(host._owner.launcher_pid) + await host.close() + assert await host.wait(1) == 0 + + asyncio.run(scenario()) + + +def test_repeated_async_launch_cancellation_cannot_orphan_owner( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + executable = write_mock_launcher(tmp_path) + owner_ready = threading.Event() + owner_holder: list[Any] = [] + real_launch = launch + + def delayed_launch(**options: Any) -> Any: + options["cancel"] = None + owner = real_launch(**options) + owner_holder.append(owner) + owner_ready.set() + time.sleep(0.1) + return owner + + monkeypatch.setattr("headless_sdk.lifecycle.launch", delayed_launch) + + async def scenario() -> None: + launching = asyncio.create_task( + alaunch( + executable=executable, + socket_path=unique_socket_path("repeated-launch-cancel"), + environment=environment(), + ) + ) + assert await asyncio.to_thread(owner_ready.wait, 1) + launching.cancel() + await asyncio.sleep(0.01) + launching.cancel() + with pytest.raises(asyncio.CancelledError): + await launching + + asyncio.run(scenario()) + assert len(owner_holder) == 1 + assert process_is_gone(owner_holder[0].launcher_pid) + + +def test_async_host_does_not_occupy_the_default_executor(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + + async def scenario() -> None: + loop = asyncio.get_running_loop() + executor = ThreadPoolExecutor(max_workers=1) + loop.set_default_executor(executor) + host = await alaunch( + executable=executable, + socket_path=unique_socket_path("single-worker"), + environment=environment(), + ) + try: + assert await asyncio.wait_for(asyncio.to_thread(lambda: 42), 1) == 42 + await asyncio.wait_for(host.close(), 2) + assert process_is_gone(host._owner.launcher_pid) + finally: + await host.close() + executor.shutdown(wait=True) + + asyncio.run(scenario()) + + +@pytest.mark.skipif( + not hasattr(os, "fork") or not hasattr(os, "register_at_fork"), + reason="fork safety is POSIX-only", +) +def test_async_fork_detaches_replacement_client(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + child_error = tmp_path / "async-fork-child.txt" + + async def scenario() -> None: + host = await alaunch( + executable=executable, + socket_path=unique_socket_path("async-fork"), + environment=environment(), + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + child_pid = os.fork() + if child_pid == 0: + status = 0 + try: + assert host._owner._detached_after_fork + assert host.client._closed + assert all( + stream is None or stream.closed + for stream in ( + host._owner._process.stdin, + host._owner._process.stdout, + host._owner._process.stderr, + ) + ) + asyncio.run(host.close()) + except BaseException: + child_error.write_text(traceback.format_exc()) + status = 1 + os._exit(status) + _, child_status = await asyncio.to_thread(os.waitpid, child_pid, 0) + try: + assert os.waitstatus_to_exitcode(child_status) == 0, ( + child_error.read_text() if child_error.exists() else "child returned no traceback" + ) + assert (await host.client.ping())["pid"] == host.pid + finally: + await host.close() + + asyncio.run(scenario()) + + +def test_async_client_closes_after_natural_launcher_exit(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + + async def scenario() -> None: + host = await alaunch( + executable=executable, + socket_path=unique_socket_path("async-exit"), + environment=environment(HEADLESS_TEST_EXIT_AFTER="0.15"), + ) + await asyncio.wait_for(host.wait(), 1) + with pytest.raises(ClientClosedError): + await host.client.ping() + assert all( + stream is None or stream.closed + for stream in ( + host._owner._process.stdin, + host._owner._process.stdout, + host._owner._process.stderr, + ) + ) + await host.close() + + asyncio.run(scenario()) + + +def test_async_launch_uses_one_total_startup_deadline(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + pid_file = tmp_path / "deadline.pid" + + async def scenario() -> None: + started = time.monotonic() + with pytest.raises((HostLaunchError, OperationOutcomeUnknown)): + await alaunch( + executable=executable, + socket_path=unique_socket_path("async-deadline"), + startup_timeout=0.3, + shutdown_timeout=0.1, + environment=environment( + HEADLESS_TEST_STARTUP_DELAY="0.16", + HEADLESS_TEST_SECOND_RESPONSE_DELAY="0.18", + HEADLESS_TEST_PID_FILE=str(pid_file), + ), + ) + # Cleanup has its own 100 ms budget after the single 300 ms startup budget. + assert time.monotonic() - started < 0.5 + + asyncio.run(scenario()) + assert process_is_gone(int(pid_file.read_text())) + + +def test_interpreter_exit_closes_owner_pipe_and_reaps_launcher(tmp_path: Path) -> None: + executable = write_mock_launcher(tmp_path) + socket_path = unique_socket_path("interpreter-exit") + pid_file = tmp_path / "interpreter-exit.pid" + source_root = Path(__file__).resolve().parents[1] / "src" + script = f""" +from headless_sdk import launch +launch( + executable={str(executable)!r}, + socket_path={socket_path!r}, + environment={{ + "HEADLESS_TEST_COMMANDS": {json.dumps(list(COMMAND_METADATA))!r}, + "HEADLESS_TEST_PID_FILE": {str(pid_file)!r}, + }}, +) +""" + child_environment = dict(os.environ) + child_environment["PYTHONPATH"] = str(source_root) + result = subprocess.run( + [sys.executable, "-c", script], + env=child_environment, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + assert result.returncode == 0, result.stderr + assert process_is_gone(int(pid_file.read_text())) + + +def test_async_connect_remains_shared(tmp_path: Path) -> None: + del tmp_path + + async def scenario(server: PrivateSocketServer) -> None: + client = await aconnect(server.socket_path) + assert client.host_status["pid"] == 7301 + await client.close() + second = await aconnect(server.socket_path) + await second.close() + + with PrivateSocketServer( + lambda request, _: json_frame(host_status(request["id"], 7301)) + ) as server: + asyncio.run(scenario(server)) + assert len(server.requests) == 2 diff --git a/packages/headless-python/tests/test_package.py b/packages/headless-python/tests/test_package.py new file mode 100644 index 0000000..9771f2a --- /dev/null +++ b/packages/headless-python/tests/test_package.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import inspect +import json +import os +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +from headless_sdk import AsyncSession, Session +from scripts import generate as generator + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = PACKAGE_ROOT.parents[1] + + +def test_metadata_support_provenance_and_zero_runtime_dependencies() -> None: + metadata = tomllib.loads((PACKAGE_ROOT / "pyproject.toml").read_text()) + project = metadata["project"] + assert project["requires-python"] == ">=3.11,<3.15" + assert project["license"] == "MIT" + assert project["dependencies"] == [] + assert project["urls"]["Repository"] == "https://github.com/LockInTime/headless.git" + assert "Programming Language :: Python :: 3.14" in project["classifiers"] + assert metadata["build-system"]["build-backend"] == "hatchling.build" + + +def test_generated_file_has_a_clean_diff() -> None: + result = subprocess.run( + [sys.executable, "scripts/generate.py", "--check"], + cwd=PACKAGE_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + + +def test_authentication_surface_cannot_accept_a_password() -> None: + for method in (Session.auth_login, AsyncSession.auth_login): + parameters = inspect.signature(method).parameters + assert set(parameters) == { + "self", + "challenge", + "account", + "interactive", + "timeout", + "cancel", + } + assert "password" not in parameters + + +def test_source_checkout_import_does_not_require_distribution_metadata() -> None: + environment = dict(os.environ) + environment["PYTHONPATH"] = str(PACKAGE_ROOT / "src") + result = subprocess.run( + [sys.executable, "-S", "-c", "import headless_sdk; print(headless_sdk.__version__)"], + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "0+source" + + +def test_python_release_actions_are_immutable() -> None: + workflow = (REPOSITORY_ROOT / ".github/workflows/python-release.yml").read_text() + references = re.findall(r"^\s*- uses: [^@\s]+@([^\s]+)$", workflow, re.MULTILINE) + assert references + assert all(re.fullmatch(r"[0-9a-f]{40}", reference) for reference in references) + + +def test_published_license_matches_repository() -> None: + assert (PACKAGE_ROOT / "LICENSE").read_bytes() == (REPOSITORY_ROOT / "LICENSE").read_bytes() + + +def test_generator_rejects_conflicting_error_detail_types( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + schema = json.loads((REPOSITORY_ROOT / "sdk/protocol-schema.json").read_text()) + schema["errorDetails"]["FUTURE_ERROR"] = { + "mayContainUntrustedContent": False, + "schema": { + "additionalProperties": True, + "fields": [{"name": "different", "required": True, "type": "string"}], + "name": "Shutdown", + "type": "object", + }, + } + schema_path = tmp_path / "schema.json" + schema_path.write_text(json.dumps(schema)) + monkeypatch.setattr(generator, "SCHEMA_PATH", schema_path) + with pytest.raises(ValueError, match="conflicting result schema: Shutdown"): + generator.generate() diff --git a/packages/headless-python/tests/test_protocol.py b/packages/headless-python/tests/test_protocol.py new file mode 100644 index 0000000..7fbbbd0 --- /dev/null +++ b/packages/headless-python/tests/test_protocol.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +import pytest + +from headless_sdk import ( + AuthenticationRequiredError, + CommandError, + ProtocolMismatchError, + ResponseIdMismatchError, + UnsupportedCapabilityError, + Untrusted, + ValidationError, +) +from headless_sdk._protocol import create_request, decode_response, encode_request +from headless_sdk.errors import MalformedResponseError +from headless_sdk.generated import ( + COMMAND_METADATA, + PROTOCOL_FIXTURES_SHA256, + PROTOCOL_SCHEMA_SHA256, + PROTOCOL_VERSION, + command_timeout_seconds, +) + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = PACKAGE_ROOT.parents[1] + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def response_frame(value: object) -> bytes: + return json.dumps(value, separators=(",", ":")).encode() + + +def test_generated_contract_matches_canonical_schema_and_fixtures() -> None: + schema_bytes = (REPOSITORY_ROOT / "sdk/protocol-schema.json").read_bytes() + fixture_bytes = (REPOSITORY_ROOT / "sdk/protocol-fixtures.json").read_bytes() + schema = json.loads(schema_bytes) + fixtures = json.loads(fixture_bytes) + assert hashlib.sha256(schema_bytes).hexdigest() == PROTOCOL_SCHEMA_SHA256 + assert hashlib.sha256(fixture_bytes).hexdigest() == PROTOCOL_FIXTURES_SHA256 + assert fixtures["protocolVersion"] == PROTOCOL_VERSION + assert len(COMMAND_METADATA) == len(schema["commands"]) + + +def test_canonical_fixtures_have_identical_wire_shapes() -> None: + fixtures = load_json(REPOSITORY_ROOT / "sdk/protocol-fixtures.json") + for fixture in fixtures["cases"]: + request = create_request( + fixture["request"]["command"], + fixture["request"]["parameters"], + fixture["request"].get("session"), + fixture["request"]["id"], + ) + assert request == fixture["request"], fixture["name"] + result = decode_response( + response_frame(fixture["response"]), + fixture["request"]["id"], + fixture["request"]["command"], + ) + if COMMAND_METADATA[fixture["request"]["command"]]["result"]["mayContainUntrustedContent"]: + assert result == Untrusted(fixture["response"]["result"]) + else: + assert result == fixture["response"]["result"] + for request in fixtures["directRequests"]: + create_request(request["command"], request["parameters"], request_id=request["id"]) + for request in fixtures["invalidRequests"]: + with pytest.raises(ValidationError): + create_request(request["command"], request["parameters"], request_id=request["id"]) + + +def test_schema_driven_validation_matches_swift_bounds() -> None: + with pytest.raises(ValidationError, match="unknown Headless command"): + create_request("unknown.command", {}) # type: ignore[arg-type] + with pytest.raises(ValidationError, match="must not be empty"): + create_request("visit", {"url": ""}) + with pytest.raises(ValidationError, match="non-empty strings"): + create_request("styles.get", {"target": "@1", "properties": [""]}) + create_request("screenshot", {"format": "PnG"}) + create_request("visit", {"url": "https://example.com"}, "session.one_2-test") + with pytest.raises(ValidationError, match="letters, digits"): + create_request("visit", {"url": "https://example.com"}, "unsafe session") + with pytest.raises(ValidationError, match="letters, digits"): + create_request("session.create", {"name": "unsafe session"}) + with pytest.raises(ValidationError, match="host-scoped"): + create_request("ping", {}, "session") + with pytest.raises(ValidationError, match="unknown parameter password"): + create_request( + "auth.login", + {"challenge": "id", "account": "work", "password": "secret"}, + ) + with pytest.raises(ValidationError, match="finite number"): + create_request("wait", {"timeoutMs": 10**1000}) + with pytest.raises(ValidationError, match="request id is invalid"): + create_request("ping", {}, request_id="") + with pytest.raises(ValidationError, match="request id is invalid"): + create_request("ping", {}, request_id=1) # type: ignore[arg-type] + with pytest.raises(ValidationError, match="session must contain"): + create_request("visit", {"url": "https://example.com"}, 1) # type: ignore[arg-type] + + +def test_generated_timeout_policies_are_used() -> None: + assert COMMAND_METADATA["ping"]["scope"] == "host" + assert COMMAND_METADATA["visit"]["scope"] == "session" + assert command_timeout_seconds("ping", {}) == 15 + assert command_timeout_seconds("wait", {"timeoutMs": 100}) == 10 + assert command_timeout_seconds("wait", {"timeoutMs": 120_000}) == 125 + assert command_timeout_seconds("tour", {}) == 125 + assert command_timeout_seconds("screenshot", {"series": "viewport"}) == 125 + assert command_timeout_seconds("record.stop", {}) == 30 + + +def test_request_has_exactly_one_terminal_newline() -> None: + frame = encode_request( + create_request("fill", {"target": "@1", "value": "line one\nline two"}, request_id="one") + ) + assert frame.endswith(b"\n") + assert b"\n" not in frame[:-1] + assert json.loads(frame[:-1])["parameters"]["value"] == "line one\nline two" + + +def test_response_validation_accepts_schema_allowed_additive_fields() -> None: + valid = { + "id": "one", + "version": PROTOCOL_VERSION, + "ok": True, + "result": {"stopping": True, "futureResultField": "accepted"}, + "futureEnvelopeField": {"accepted": True}, + } + assert decode_response(response_frame(valid), "one", "shutdown") == valid["result"] + with pytest.raises(MalformedResponseError): + decode_response(b"not-json", "one", "shutdown") + with pytest.raises(MalformedResponseError): + decode_response( + b'{"id":"one","version":"0.5","ok":true,"result":{"stopping":true},"x":NaN}', + "one", + "shutdown", + ) + with pytest.raises(ResponseIdMismatchError): + decode_response(response_frame(valid | {"id": "two"}), "one", "shutdown") + with pytest.raises(ProtocolMismatchError): + decode_response(response_frame(valid | {"version": "9.9"}), "one", "shutdown") + with pytest.raises(MalformedResponseError, match="missing stopping"): + decode_response(response_frame(valid | {"result": {}}), "one", "shutdown") + with pytest.raises(MalformedResponseError, match="valid JSON"): + decode_response( + response_frame(valid | {"result": {"stopping": True, "future": 10**1000}}), + "one", + "shutdown", + ) + + +def test_command_and_authentication_errors_are_typed() -> None: + def failure(code: str, details: object | None = None) -> bytes: + return response_frame( + { + "id": "failed", + "version": PROTOCOL_VERSION, + "ok": False, + "error": {"code": code, "message": "failed safely", "details": details}, + } + ) + + with pytest.raises(CommandError): + decode_response(failure("TIMEOUT"), "failed", "wait") + with pytest.raises(UnsupportedCapabilityError): + decode_response(failure("UNSUPPORTED_CAPABILITY"), "failed", "upload") + + auth = load_json(PACKAGE_ROOT / "tests/fixtures/auth-required.json") + with pytest.raises(AuthenticationRequiredError) as caught: + decode_response(failure("AUTH_REQUIRED", auth["valid"]), "failed", "click") + assert caught.value.details.untrusted_content is True + assert caught.value.details.value["challenge"] == auth["valid"]["challenge"] + for fixture in auth["invalid"]: + with pytest.raises(MalformedResponseError): + decode_response(failure("AUTH_REQUIRED", fixture["details"]), "failed", "click") + with pytest.raises(MalformedResponseError): + decode_response(failure("AUTH_REQUIRED"), "failed", "click") diff --git a/packages/headless-python/tests/test_transport.py b/packages/headless-python/tests/test_transport.py new file mode 100644 index 0000000..874dfb5 --- /dev/null +++ b/packages/headless-python/tests/test_transport.py @@ -0,0 +1,520 @@ +from __future__ import annotations + +import asyncio +import os +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any + +import pytest + +from headless_sdk import ( + CancelledBeforeSend, + ClientClosedError, + CommandError, + MalformedResponseError, + OperationOutcomeUnknown, + ProtocolMismatchError, + ResponseIdMismatchError, + ResponseTooLargeError, + TimeoutBeforeSend, + UnsupportedCapabilityError, + Untrusted, + ValidationError, + aconnect, + connect, +) +from headless_sdk._transport import AsyncUnixSocketTransport, SyncUnixSocketTransport +from headless_sdk.generated import MAXIMUM_MESSAGE_BYTES, PROTOCOL_VERSION + +from .helpers import PrivateSocketServer, host_status, json_frame, unique_socket_path + + +def test_connect_negotiates_capabilities_and_preserves_untrusted_results() -> None: + def response(request: dict[str, Any], _: object) -> bytes: + if request["command"] == "ping": + return json_frame(host_status(request["id"])) + assert request["command"] == "visit" + return json_frame( + { + "id": request["id"], + "version": PROTOCOL_VERSION, + "ok": True, + "result": { + "url": request["parameters"]["url"], + "title": "Untrusted", + "readyState": "complete", + "text": "page text", + "runningAnimations": 0, + "mutationQuietMs": 500, + "scrollY": 0, + "contentHeight": 900, + }, + } + ) + + with PrivateSocketServer(response) as server, connect(server.socket_path) as client: + result = client.session("work.one").visit(url="https://example.com") + assert isinstance(result, Untrusted) + assert result.value["title"] == "Untrusted" + assert server.requests[1]["session"] == "work.one" + assert "visit" in client.capabilities["commands"] + with pytest.raises(ValidationError, match="timeout"): + client.ping(timeout=10**1000) + + +def test_connect_close_never_stops_shared_host() -> None: + with PrivateSocketServer( + lambda request, _: json_frame(host_status(request["id"], 7101)) + ) as server: + connect(server.socket_path).close() + second = connect(server.socket_path) + assert second.host_status["pid"] == 7101 + second.close() + assert len(server.requests) == 2 + + +def test_direct_session_close_updates_sync_and_async_wrapper_state() -> None: + def response(request: dict[str, Any], _: object) -> bytes: + if request["command"] == "ping": + return json_frame(host_status(request["id"])) + assert request["command"] == "session.close" + time.sleep(0.05) + return json_frame( + { + "id": request["id"], + "version": PROTOCOL_VERSION, + "ok": True, + "result": {"closed": request["session"]}, + } + ) + + async def async_scenario(socket_path: str) -> None: + client = await aconnect(socket_path) + session = client.session("async") + first, second = await asyncio.gather(session.session_close(), session.session_close()) + assert first["closed"] == "async" + assert second["closed"] == "async" + with pytest.raises(ClientClosedError): + await session.visit(url="https://example.com") + await session.close() + await client.close() + + with PrivateSocketServer(response) as server: + client = connect(server.socket_path) + session = client.session("sync") + with ThreadPoolExecutor(max_workers=2) as executor: + first, second = executor.map(lambda _: session.session_close(), range(2)) + assert first["closed"] == "sync" + assert second["closed"] == "sync" + with pytest.raises(ClientClosedError): + session.visit(url="https://example.com") + session.close() + client.close() + asyncio.run(async_scenario(server.socket_path)) + assert [request["command"] for request in server.requests] == [ + "ping", + "session.close", + "ping", + "session.close", + ] + + +def test_sync_session_close_retries_before_send_and_quarantines_unknown_outcomes() -> None: + def response(request: dict[str, Any], _: object) -> bytes | None: + if request["command"] == "ping": + return json_frame(host_status(request["id"])) + if request["command"] == "session.close" and request["session"].endswith("unknown"): + return None + assert request["command"] == "session.close" + return json_frame( + { + "id": request["id"], + "version": PROTOCOL_VERSION, + "ok": True, + "result": {"closed": request["session"]}, + } + ) + + with PrivateSocketServer(response) as server: + client = connect(server.socket_path) + retry = client.session("sync-retry") + cancel = threading.Event() + cancel.set() + with pytest.raises(CancelledBeforeSend): + retry.session_close(cancel=cancel) + cancel.clear() + assert retry.session_close()["closed"] == "sync-retry" + + unknown = client.session("sync-unknown") + with pytest.raises(OperationOutcomeUnknown): + unknown.session_close() + with pytest.raises(ClientClosedError): + unknown.visit(url="https://example.com") + with pytest.raises(OperationOutcomeUnknown): + unknown.session_close() + client.close() + close_sessions = [ + request["session"] + for request in server.requests + if request["command"] == "session.close" + ] + assert close_sessions == [ + "sync-retry", + "sync-unknown", + ] + + +def test_async_session_close_preserves_cancellation_certainty() -> None: + close_started = threading.Event() + + def response(request: dict[str, Any], _: object) -> bytes | None: + if request["command"] == "ping": + return json_frame(host_status(request["id"])) + if request["command"] == "session.close" and request["session"].endswith("unknown"): + return None + assert request["command"] == "session.close" + if request["session"] == "async-caller-cancel": + close_started.set() + time.sleep(0.1) + return json_frame( + { + "id": request["id"], + "version": PROTOCOL_VERSION, + "ok": True, + "result": {"closed": request["session"]}, + } + ) + + async def scenario(socket_path: str) -> None: + client = await aconnect(socket_path) + retry = client.session("async-retry") + cancel = asyncio.Event() + cancel.set() + with pytest.raises(CancelledBeforeSend): + await retry.session_close(cancel=cancel) + cancel.clear() + assert (await retry.session_close())["closed"] == "async-retry" + + caller_cancel = client.session("async-caller-cancel") + pending = asyncio.create_task(caller_cancel.session_close()) + assert await asyncio.to_thread(close_started.wait, 1) + pending.cancel() + with pytest.raises(OperationOutcomeUnknown): + await pending + with pytest.raises(ClientClosedError): + await caller_cancel.visit(url="https://example.com") + with pytest.raises(OperationOutcomeUnknown): + await caller_cancel.session_close() + + unknown = client.session("async-unknown") + with pytest.raises(OperationOutcomeUnknown): + await unknown.session_close() + with pytest.raises(ClientClosedError): + await unknown.visit(url="https://example.com") + with pytest.raises(OperationOutcomeUnknown): + await unknown.session_close() + await client.close() + + with PrivateSocketServer(response) as server: + asyncio.run(scenario(server.socket_path)) + close_sessions = [ + request["session"] + for request in server.requests + if request["command"] == "session.close" + ] + assert close_sessions == [ + "async-retry", + "async-caller-cancel", + "async-unknown", + ] + + +def test_async_session_close_honors_a_concurrent_followers_cancellation() -> None: + close_started = threading.Event() + + def response(request: dict[str, Any], _: object) -> bytes: + if request["command"] == "ping": + return json_frame(host_status(request["id"])) + close_started.set() + time.sleep(0.1) + return json_frame( + { + "id": request["id"], + "version": PROTOCOL_VERSION, + "ok": True, + "result": {"closed": request["session"]}, + } + ) + + async def scenario(socket_path: str) -> None: + client = await aconnect(socket_path) + session = client.session("async-follower-cancel") + leader = asyncio.create_task(session.session_close()) + assert await asyncio.to_thread(close_started.wait, 1) + cancel = asyncio.Event() + follower = asyncio.create_task(session.session_close(cancel=cancel)) + await asyncio.sleep(0) + cancel.set() + with pytest.raises(CancelledBeforeSend): + await follower + assert (await leader)["closed"] == "async-follower-cancel" + await client.close() + + with PrivateSocketServer(response) as server: + asyncio.run(scenario(server.socket_path)) + assert [request["command"] for request in server.requests] == ["ping", "session.close"] + + +def test_commands_cannot_bypass_connection_or_capability_negotiation() -> None: + path = unique_socket_path("unused") + client = connect + from headless_sdk import Client + + disconnected = Client(path) + with pytest.raises(ValidationError, match=r"connect\(\) must complete"): + disconnected.session_create(name="one") + disconnected.close() + del client + + with ( + PrivateSocketServer( + lambda request, _: json_frame(host_status(request["id"], commands=["ping"])) + ) as server, + connect(server.socket_path) as limited, + ): + with pytest.raises(UnsupportedCapabilityError): + limited.session("one").visit(url="https://example.com") + assert len(server.requests) == 1 + + +def test_socket_location_permissions_and_outbound_frame_validation(tmp_path: Path) -> None: + with pytest.raises(ValidationError, match="must be absolute"): + SyncUnixSocketTransport("") + with pytest.raises(ValidationError, match="must be absolute"): + connect("") + with PrivateSocketServer(lambda request, _: json_frame(host_status(request["id"]))) as server: + transport = SyncUnixSocketTransport(server.socket_path) + with pytest.raises(ValidationError, match="timeout"): + transport.send(b"{}\n", "huge-timeout", 10**1000) + with pytest.raises(ValidationError, match="terminal newline"): + transport.send(b"{}", "missing-newline", 1) + with pytest.raises(ValidationError, match="exactly one"): + transport.send(b"{}\n{}\n", "multiple", 1) + os.chmod(server.socket_path, 0o666) + with pytest.raises(Exception, match="socket is not private"): + transport.send(b"{}\n", "public", 1) + os.chmod(server.socket_path, 0o600) + transport.close() + + with pytest.raises(ValidationError, match="direct child"): + SyncUnixSocketTransport(str(tmp_path / "host.sock")) + with pytest.raises(ValidationError, match="direct child"): + connect(str(tmp_path / "connect.sock")) + + target = unique_socket_path("target") + link = unique_socket_path("symlink") + Path(target).touch(mode=0o600) + Path(link).symlink_to(target) + try: + with pytest.raises(Exception, match="socket is not private"): + SyncUnixSocketTransport(link).send(b"{}\n", "symlink", 1) + finally: + Path(link).unlink(missing_ok=True) + Path(target).unlink(missing_ok=True) + + +def test_timeout_and_cancellation_before_write_are_retry_safe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with PrivateSocketServer(lambda request, _: json_frame(host_status(request["id"]))) as server: + transport = SyncUnixSocketTransport(server.socket_path) + cancel = threading.Event() + cancel.set() + with pytest.raises(CancelledBeforeSend) as cancelled: + transport.send(b"{}\n", "cancelled", 1, cancel) + assert cancelled.value.retry_safe + + clock = iter((0.0, 2.0)) + monkeypatch.setattr("headless_sdk._transport.time.monotonic", lambda: next(clock, 2.0)) + with pytest.raises(TimeoutBeforeSend) as timed_out: + transport.send(b"{}\n", "timeout", 1) + assert timed_out.value.retry_safe + transport.close() + + +def test_post_write_timeout_cancellation_and_read_failure_are_unknown() -> None: + received = threading.Event() + + def response(request: dict[str, Any], _: object) -> bytes | None: + received.set() + if request["id"] == "closed": + return None + time.sleep(0.5) + return None + + with PrivateSocketServer(response) as server: + transport = SyncUnixSocketTransport(server.socket_path) + with pytest.raises(OperationOutcomeUnknown) as timed_out: + transport.send(b'{"id":"timeout"}\n', "timeout", 0.02) + assert timed_out.value.reason == "timed-out" + assert not timed_out.value.retry_safe + + cancel = threading.Event() + result: list[BaseException] = [] + + def send() -> None: + try: + transport.send(b'{"id":"cancel"}\n', "cancel", 1, cancel) + except BaseException as error: + result.append(error) + + received.clear() + thread = threading.Thread(target=send) + thread.start() + assert received.wait(1) + cancel.set() + thread.join(1) + assert isinstance(result[0], OperationOutcomeUnknown) + assert result[0].reason == "cancelled" # type: ignore[union-attr] + + with pytest.raises(OperationOutcomeUnknown) as closed: + transport.send(b'{"id":"closed"}\n', "closed", 1) + assert isinstance(closed.value.__cause__, MalformedResponseError) + transport.close() + + +def test_all_post_write_response_failures_preserve_specific_causes() -> None: + mode = "valid" + + def response(request: dict[str, Any], connection: Any) -> bytes | None: + nonlocal mode + if request["command"] == "ping": + return json_frame(host_status(request["id"])) + valid = { + "id": request["id"], + "version": PROTOCOL_VERSION, + "ok": True, + "result": {"stopping": True}, + } + if mode == "malformed": + return b"not-json\n" + if mode == "empty": + return None + if mode == "partial": + return b'{"id":"partial"' + if mode == "oversized": + return b"a" * (MAXIMUM_MESSAGE_BYTES + 1) + if mode == "multiple": + return b"{}\n{}\n" + if mode == "delayed-multiple": + connection.sendall(json_frame(valid)) + time.sleep(0.02) + return b"{}\n" + if mode == "mismatch": + return json_frame(valid | {"id": "other"}) + if mode == "version": + return json_frame(valid | {"version": "9.9"}) + if mode == "result": + return json_frame(valid | {"result": {}}) + if mode == "command-error": + return json_frame( + { + "id": request["id"], + "version": PROTOCOL_VERSION, + "ok": False, + "error": {"code": "TIMEOUT", "message": "host timed out"}, + } + ) + raise AssertionError(mode) + + expected = { + "malformed": MalformedResponseError, + "empty": MalformedResponseError, + "partial": MalformedResponseError, + "oversized": ResponseTooLargeError, + "multiple": MalformedResponseError, + "delayed-multiple": MalformedResponseError, + "mismatch": ResponseIdMismatchError, + "version": ProtocolMismatchError, + "result": MalformedResponseError, + } + with PrivateSocketServer(response) as server, connect(server.socket_path) as client: + for mode, cause_type in expected.items(): + with pytest.raises(OperationOutcomeUnknown) as caught: + client.shutdown() + assert isinstance(caught.value.__cause__, cause_type), mode + mode = "command-error" + with pytest.raises(CommandError) as command_error: + client.shutdown() + assert command_error.value.code == "TIMEOUT" + + +def test_async_client_transport_and_task_cancellation() -> None: + received = threading.Event() + + def response(request: dict[str, Any], _: object) -> bytes | None: + if request["command"] == "ping": + return json_frame(host_status(request["id"])) + received.set() + time.sleep(0.5) + return None + + async def scenario(server: PrivateSocketServer) -> None: + client = await aconnect(server.socket_path) + try: + pending = asyncio.create_task(client.shutdown(timeout=1)) + assert await asyncio.to_thread(received.wait, 1) + pending.cancel() + with pytest.raises(OperationOutcomeUnknown) as caught: + await pending + assert caught.value.reason == "cancelled" + assert not caught.value.retry_safe + finally: + await client.close() + + with PrivateSocketServer(response) as server: + asyncio.run(scenario(server)) + + +def test_async_pre_cancel_is_retry_safe(monkeypatch: pytest.MonkeyPatch) -> None: + async def scenario(server: PrivateSocketServer) -> None: + transport = AsyncUnixSocketTransport(server.socket_path) + cancel = asyncio.Event() + cancel.set() + with pytest.raises(CancelledBeforeSend): + await transport.send(b"{}\n", "cancelled", 1, cancel) + await transport.close() + + cancel_after_connect = asyncio.Event() + cancelling_transport = AsyncUnixSocketTransport(server.socket_path) + open_connection = asyncio.open_unix_connection + + async def cancelling_open(path: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + connection = await open_connection(path) + cancel_after_connect.set() + return connection + + monkeypatch.setattr(asyncio, "open_unix_connection", cancelling_open) + with pytest.raises(CancelledBeforeSend): + await cancelling_transport.send( + b"{}\n", "cancelled-after-connect", 1, cancel_after_connect + ) + await cancelling_transport.close() + + closing_transport = AsyncUnixSocketTransport(server.socket_path) + + async def closing_open(path: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + connection = await open_connection(path) + await closing_transport.close() + return connection + + monkeypatch.setattr(asyncio, "open_unix_connection", closing_open) + with pytest.raises(ClientClosedError): + await closing_transport.send(b"{}\n", "closed-after-connect", 1) + + with PrivateSocketServer(lambda request, _: json_frame(host_status(request["id"]))) as server: + asyncio.run(scenario(server)) + assert server.requests == [] diff --git a/packages/headless-python/tests/type_contract.py b/packages/headless-python/tests/type_contract.py new file mode 100644 index 0000000..dd9b04b --- /dev/null +++ b/packages/headless-python/tests/type_contract.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import assert_type + +from headless_sdk import AsyncClient, AuthenticationRequiredError, Client, Untrusted +from headless_sdk.generated import AuthenticationLogin, PageState + + +def sync_contract(client: Client, error: AuthenticationRequiredError) -> None: + page = client.session("work").visit(url="https://example.com") + assert_type(page, Untrusted[PageState]) + login = client.session("work").auth_login(challenge="id", account="work") + assert_type(login, Untrusted[AuthenticationLogin]) + assert_type(error.details.value["challenge"], str) + + +async def async_contract(client: AsyncClient) -> None: + page = await client.session("work").visit(url="https://example.com") + assert_type(page, Untrusted[PageState]) + login = await client.session("work").auth_login(interactive=True) + assert_type(login, Untrusted[AuthenticationLogin])