diff --git a/.gitignore b/.gitignore index 36ec7206b1833..530a81f2c1ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,12 @@ __pycache__ *.pyc dist/ py/selenium/webdriver/common/bidi/ +# Generated internal BiDi protocol modules; the hand-written runtime beside them stays tracked. +py/selenium/webdriver/common/_bidi/* +!py/selenium/webdriver/common/_bidi/__init__.py +!py/selenium/webdriver/common/_bidi/serialization.py +!py/selenium/webdriver/common/_bidi/transport.py +!py/selenium/webdriver/common/_bidi/domain.py py/selenium/webdriver/common/devtools/**/* !py/selenium/webdriver/common/devtools/util.py py/selenium/webdriver/common/linux/ diff --git a/py/BUILD.bazel b/py/BUILD.bazel index 6a83779380707..1cc676bb3a0b9 100644 --- a/py/BUILD.bazel +++ b/py/BUILD.bazel @@ -11,6 +11,7 @@ load("//common:defs.bzl", "copy_file") load("//py:defs.bzl", "generate_devtools", "generate_devtools_latest", "py_test_suite") load("//py/private:browsers.bzl", "BROWSERS") load("//py/private:generate_bidi.bzl", "generate_bidi") +load("//py/private:generate_bidi_protocol.bzl", "generate_bidi_protocol") load("//py/private:import.bzl", "py_import") load("//py/private:sphinx_docs.bzl", "sphinx_docs") @@ -298,6 +299,7 @@ py_library( "selenium/webdriver/common/actions/**", "selenium/webdriver/common/alert.py", "selenium/webdriver/common/api_request_context.py", + "selenium/webdriver/common/_bidi/**", "selenium/webdriver/common/bidi/**", "selenium/webdriver/common/devtools/**", "selenium/webdriver/common/fedcm/**", @@ -532,6 +534,7 @@ py_library( visibility = ["//visibility:public"], deps = [ ":bidi", + ":bidi_protocol", ":chrome", ":chromium", ":common", @@ -564,6 +567,7 @@ py_package( "py.selenium.webdriver.chrome", "py.selenium.webdriver.chromium", "py.selenium.webdriver.common", + "py.selenium.webdriver.common._bidi", "py.selenium.webdriver.common.bidi", "py.selenium.webdriver.common.devtools", "py.selenium.webdriver.common.devtools.latest", @@ -748,6 +752,46 @@ generate_bidi( spec_version = "1.0", ) +# Schema-driven internal BiDi protocol layer: generated at build time from the +# shared BiDi schema into `selenium/webdriver/common/_bidi/`. Only the hand-written +# runtime (serialization/transport/domain) is checked in; the domain modules are +# generated by the rule below and never committed. +generate_bidi_protocol( + name = "create-bidi-protocol-src", + generator = ":generate-bidi-protocol-tool", + package = "selenium/webdriver/common/_bidi", + schema = "//javascript/selenium-webdriver:create-bidi-src_schema", +) + +# Build-graph tool: the generator invoked by the rule above (schema + output dir +# passed as arguments). No baked args, so it is reusable in the exec configuration. +py_binary( + name = "generate-bidi-protocol-tool", + srcs = ["generate_bidi_protocol.py"], + main = "generate_bidi_protocol.py", + visibility = ["//visibility:private"], +) + +# `bazel run //py:generate-bidi-protocol` regenerates the checked-out tree for local +# development (writes under BUILD_WORKSPACE_DIRECTORY); the build itself uses the rule. +py_binary( + name = "generate-bidi-protocol", + srcs = ["generate_bidi_protocol.py"], + args = ["$(location //javascript/selenium-webdriver:create-bidi-src_schema)"], + data = ["//javascript/selenium-webdriver:create-bidi-src_schema"], + main = "generate_bidi_protocol.py", + visibility = ["//visibility:public"], +) + +py_library( + name = "bidi_protocol", + # Hand-written runtime from source + the generated domain modules from the rule. + srcs = [":create-bidi-protocol-src"] + glob(["selenium/webdriver/common/_bidi/*.py"]), + imports = ["."], + visibility = ["//visibility:public"], + deps = [":exceptions"], +) + py_test_suite( name = "unit", size = "small", @@ -776,7 +820,10 @@ py_library( deps = [], ) -BIDI_TESTS = glob(["test/selenium/webdriver/common/**/*bidi*_tests.py"]) +BIDI_TESTS = glob([ + "test/selenium/webdriver/common/bidi/**/*_tests.py", + "test/selenium/webdriver/common/_bidi/**/*_tests.py", +]) # Tests that have bidi and classic implementations. BIDI_IMPLEMENTATIONS = [] @@ -1032,7 +1079,8 @@ FEATURE_SUITE_DEFS = { target_compatible_with = BROWSERS[browser]["target_compatible_with"], test_suffix = "%s-bidi" % browser, deps = [ - ":common_alert", # bidi_browsing_context_tests.py calls EC.alert_is_present() + ":bidi_protocol", # bidi/protocol_tests.py imports the generated _bidi layer directly + ":common_alert", # bidi/browsing_context_tests.py calls EC.alert_is_present() ":init-tree", ":webserver", ] + BROWSER_TESTS[browser]["deps"] + TEST_DEPS, diff --git a/py/TESTING.md b/py/TESTING.md index 6316837be7fba..6a9fe19690f3b 100644 --- a/py/TESTING.md +++ b/py/TESTING.md @@ -53,7 +53,7 @@ bazel test //py/... --test_output=streamed # Live output for debugging bazel test //py:test-chrome --headless # Run a specific test in a test file -bazel test //py:test/selenium/webdriver/common/bidi_browsing_context_tests-chrome-bidi \ +bazel test //py:test/selenium/webdriver/common/bidi/browsing_context_tests-chrome-bidi \ --test_arg=-k \ --test_arg=test_get_tree_with_child \ diff --git a/py/generate_bidi_protocol.py b/py/generate_bidi_protocol.py new file mode 100644 index 0000000000000..0f5096d114f53 --- /dev/null +++ b/py/generate_bidi_protocol.py @@ -0,0 +1,1062 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Generate the Python WebDriver BiDi protocol layer from the shared schema. + +Consumes the binding-neutral schema produced by the JavaScript projector +(``//javascript/selenium-webdriver:create-bidi-src_schema`` -> schema.json). The +schema is already normalized (inline enums hoisted, unions canonicalized with a +dispatch ``selector``, group composition flattened, wire names + nullability +preserved), so this generator is a straight projection into Python — no CDDL +interpretation. Ported from the Ruby generator (PR #17731). + +Output is close to ``ruff format`` style; the build applies a format pass so the +checked-in files (and the verify test's comparison) are canonical. + +Usage: + python generate_bidi_protocol.py +""" + +from __future__ import annotations + +import argparse +import json +import keyword +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +BIDI_DOC_URL = "https://www.selenium.dev/documentation/warnings/bidi-implementation/" +LINE_LIMIT = 120 +DEFAULT_OUTPUT = "py/selenium/webdriver/common/_bidi" + +_HEADER = """# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This file is generated from the WebDriver BiDi specification. +# DO NOT EDIT. Regenerate with: +# bazel run //py:generate-bidi-protocol""" + +_RESERVED_FIELDS = {"as_json", "from_json", "extensions"} + + +# --------------------------------------------------------------------------- # +# Naming helpers +# --------------------------------------------------------------------------- # +def camel_to_snake(name: str) -> str: + name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name) + name = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", name) + return name.lower() + + +def snake_to_class(name: str) -> str: + return "".join(part.capitalize() for part in name.split("_")) + + +def type_class_name(type_name: str) -> str: + """Local class name for a schema type; synthetic ``X_Y`` becomes CamelCase ``XY``.""" + local = type_name.split(".", 1)[-1] + parts = [p for p in local.split("_") if p] + return "".join(p[:1].upper() + p[1:] for p in parts) or local + + +def value_alias(type_name: str) -> str: + """The TypeAlias name for a union's set of concrete variants.""" + return f"{type_class_name(type_name)}Value" + + +def safe_name(name: str) -> str: + """A snake_case identifier safe as a Python attribute / parameter. + + Escapes hard keywords and names that would shadow the runtime API; soft + keywords (``type``, ``match``) are valid identifiers and left alone. + """ + return f"{name}_" if keyword.iskeyword(name) or name in _RESERVED_FIELDS else name + + +def enum_member(value: Any) -> str: + token = camel_to_snake(str(value)) + token = re.sub(r"\A-(?=\d)", "neg", token) + token = re.sub(r"\A-", "neg_", token) + token = re.sub(r"[^a-z0-9]+", "_", token).strip("_").upper() + if not token or token[0].isdigit(): + token = f"_{token}" + return token + + +def lit(value: Any) -> str: + """A Python source literal, double-quoted (ruff-format style) for strings.""" + if isinstance(value, bool): + return "True" if value else "False" + if isinstance(value, str): + return json.dumps(value) + if value is None: + return "None" + return repr(value) + + +def tuple_lit(items: tuple) -> str: + """A tuple literal with double-quoted elements (single element keeps its comma).""" + inner = ", ".join(lit(i) for i in items) + return f"({inner},)" if len(items) == 1 else f"({inner})" + + +def frozenset_lit(values: list[str], indent: int) -> str: + """`frozenset({...})` on one line, or ruff's wrapped form when it would overflow.""" + one_line = f"frozenset({{{', '.join(values)}}})" + if indent + len(one_line) <= LINE_LIMIT: + return one_line + inner = ",\n".join(f"{' ' * (indent + 8)}{v}" for v in values) + return f"frozenset(\n{' ' * (indent + 4)}{{\n{inner},\n{' ' * (indent + 4)}}}\n{' ' * indent})" + + +def wrap_call(prefix: str, args: list[str], suffix: str, indent: int) -> str: + """`prefix(args)suffix` on one line, or one arg per line when it would exceed the limit.""" + one_line = f"{prefix}{', '.join(args)}{suffix}" + if not args or indent + len(one_line) <= LINE_LIMIT: + return one_line + pad = " " * (indent + 4) + body = ",\n".join(f"{pad}{a}" for a in args) + return f"{prefix}\n{body},\n{' ' * indent}{suffix.lstrip()}" + + +# --------------------------------------------------------------------------- # +# IR +# --------------------------------------------------------------------------- # +_NO_FIXED = object() + + +@dataclass +class ParamIR: + name: str + wire: str + required: bool + py_type: str + + +@dataclass +class CommandIR: + wire: str + method: str + params: list[ParamIR] + params_class: str | None + union_params: bool + result_class: str | None # runtime class passed to _execute (dispatch class) + result_type: str # return annotation + spec_href: str | None = None + + +@dataclass +class EventIR: + wire: str + name: str + payload_class: str | None + + +@dataclass +class EnumIR: + class_name: str + schema_name: str + members: list[tuple[str, Any]] + spec_href: str | None = None + + +@dataclass +class FieldIR: + name: str + wire: str + required: bool + nullable: bool + ref: str | None + is_list: bool + enum: str | None + primitive: str | None + scalar: str | list[str] | None + fixed: Any + py_type: str + + +@dataclass +class RecordIR: + class_name: str + schema_name: str + fields: list[FieldIR] + discriminator: FieldIR | None + extensible: bool + spec_href: str | None = None + + +@dataclass +class VariantIR: + mode: str + value: Any + ref: str + requires: tuple[str, ...] | None + + +@dataclass +class UnionIR: + class_name: str + schema_name: str + discriminator: str | None + discriminator_values: list[Any] | None + variants: list[VariantIR] + variant_types: list[str] # annotation names for the value alias + object_only: bool = False + spec_href: str | None = None + + +@dataclass +class ModuleIR: + domain: str + class_name: str + filename: str + enums: list[EnumIR] + records: list[RecordIR] + unions: list[UnionIR] + commands: list[CommandIR] + events: list[EventIR] + imports: dict[str, set[str]] # module filename -> annotation names (cross-domain) + spec_href: str | None = None + + +# --------------------------------------------------------------------------- # +# Schema projection (ported from Ruby bidi_generate.rb Schema) +# --------------------------------------------------------------------------- # +# The complete set of primitives the projector emits; it rejects `unknown` upstream +# (an unhandled CDDL construct fails the schema check), so we never see one. +_PRIMITIVE = {"string": "str", "integer": "int", "number": "float", "boolean": "bool", "null": "None"} +_LEAF_PRIMITIVE = {"string": "str", "integer": "int", "number": "float", "boolean": "bool"} + + +class Schema: + def __init__(self, raw: dict) -> None: + self.types: dict = raw["types"] + self.commands: list = raw["commands"] + self.events: list = raw["events"] + self._domain_links: dict = raw.get("domains", {}) + self._promote_command_params_records() + + def _promote_command_params_records(self) -> None: + for cmd in self.commands: + ref = (cmd.get("params") or {}).get("ref") + if ref and ref in self.types and self._envelope_synthetic(self.types[ref]): + for tag in ("synthetic", "owner", "label"): + self.types[ref].pop(tag, None) + + def domains(self) -> list[str]: + seen: list[str] = [] + for entry in self.commands + self.events: + if entry["domain"] not in seen: + seen.append(entry["domain"]) + return seen + + def commands_for(self, domain: str) -> list: + return [c for c in self.commands if c["domain"] == domain] + + def events_for(self, domain: str) -> list: + return [e for e in self.events if e["domain"] == domain] + + def type_kind(self, ref: str | None) -> str | None: + return self.types.get(ref, {}).get("kind") if ref else None + + def enums_for(self, domain: str) -> list[EnumIR]: + prefix = f"{domain}." + out = [] + for name, type_ in self.types.items(): + if type_.get("kind") != "enum" or not name.startswith(prefix): + continue + members = [(enum_member(v), v) for v in type_["values"]] + out.append( + EnumIR( + class_name=type_class_name(name), schema_name=name, members=members, spec_href=type_.get("specHref") + ) + ) + return out + + def types_for(self, domain: str) -> tuple[list[RecordIR], list[UnionIR]]: + prefix = f"{domain}." + records: list[RecordIR] = [] + unions: list[UnionIR] = [] + for name, type_ in self.types.items(): + if not name.startswith(prefix): + continue + kind = type_["kind"] + if kind == "record": + if not type_["fields"] or self._suppressed_record(type_): + continue + records.append(self._record_ir(name, type_)) + elif kind == "union": + unions.append(self._union_ir(name)) + elif kind == "alias" and "union" in type_["type"]: + unions.append(self._union_ir(name)) + return records, unions + + def _suppressed_record(self, type_: dict) -> bool: + return self._message_envelope(type_) or self._envelope_synthetic(type_) + + def _message_envelope(self, type_: dict) -> bool: + return any(f["wire"] == "method" and "const" in f["type"] for f in type_.get("fields", [])) + + def _envelope_synthetic(self, type_: dict) -> bool: + if not type_.get("synthetic"): + return False + owner = self.types.get(type_.get("owner")) + return bool(owner and owner["kind"] == "record" and self._message_envelope(owner)) + + def _record_ir(self, name: str, type_: dict) -> RecordIR: + disc_field = next((f for f in type_["fields"] if self._baked_discriminator(f)), None) + discriminator = self._field_ir(disc_field) if disc_field else None + fields = [self._field_ir(f) for f in type_["fields"] if not self._baked_discriminator(f)] + return RecordIR( + class_name=type_class_name(name), + schema_name=name, + fields=fields, + discriminator=discriminator, + # Gate the extensions store on ``preserveExtras`` (extensible AND re-sendable), + # not raw ``extensible``: only a type you receive and can hand back keeps unknown + # wire keys. A received-only extensible type gets no store — it accepts and ignores. + extensible=bool(type_.get("preserveExtras")), + spec_href=type_.get("specHref"), + ) + + def _baked_discriminator(self, field_: dict) -> bool: + return "const" in field_["type"] and not field_["type"].get("nullable") + + def _field_ir(self, field_: dict) -> FieldIR: + resolved = self._resolve(field_["type"]) + const = field_["type"]["const"] if self._baked_discriminator(field_) else _NO_FIXED + py, refs = self.py_type(field_["type"]) + self._pending_refs |= refs + return FieldIR( + name=safe_name(camel_to_snake(field_["name"])), + wire=field_["wire"], + required=bool(field_["required"]), + nullable=resolved["nullable"], + ref=resolved["ref"], + is_list=resolved["list"], + enum=self._enum_ref(field_["type"]), + primitive=self._leaf_primitive(field_["type"]), + scalar=self._scalar_py(resolved.get("scalar")), + fixed=const, + py_type=py, + ) + + # The projector's ``scalar`` primitive name(s) mapped to the runtime check names + # (``string`` -> ``str``), so a map key's bare scalar is validated with the same + # ``_PRIMITIVE_CHECKS`` table as a typed field. None when the field carries no scalar arm. + def _scalar_py(self, scalar: str | list | None) -> str | list[str] | None: + if scalar is None: + return None + if isinstance(scalar, list): + return [_LEAF_PRIMITIVE[s] for s in scalar if s in _LEAF_PRIMITIVE] or None + return _LEAF_PRIMITIVE.get(scalar) + + def _union_ir(self, name: str) -> UnionIR: + type_ = self.types[name] + if type_["kind"] == "union": + ir = self._union_from_selector(name, type_["selector"]) + else: + ir = self._union_from_alias(name) + for v in ir.variants: + py = self._py_ref(v.ref, self._pending_refs) + ir.variant_types.append(py) + return ir + + def _union_from_selector(self, name: str, selector: dict) -> UnionIR: + if selector.get("correlated"): + raise ValueError(f"correlated union {name} must not be emitted (resolved by request id)") + variants = self._discriminated_variants(selector) if selector.get("by") else self._ordered_variants(selector) + if not variants: + raise ValueError(f"union {name} selector yielded no dispatch variants") + values = self._discriminator_values(selector) if selector.get("by") else None + return UnionIR( + class_name=type_class_name(name), + schema_name=name, + discriminator=selector.get("by"), + discriminator_values=values, + variants=variants, + variant_types=[], + object_only=bool(self.types[name].get("objectOnly")), + spec_href=self.types[name].get("specHref"), + ) + + def _discriminated_variants(self, selector: dict) -> list[VariantIR]: + variants = [VariantIR("value", v["value"], v["ref"], None) for v in selector["variants"]] + if selector.get("default"): + variants.append(VariantIR("fallback", None, selector["default"], None)) + return variants + + def _ordered_variants(self, selector: dict) -> list[VariantIR]: + return [VariantIR("presence", None, arm["ref"], tuple(arm["requires"])) for arm in selector.get("ordered", [])] + + # The union-wide allowed discriminator set: each variant's tag plus the default + # variant's own enum values for the discriminator field (spec-faithful outbound check). + def _discriminator_values(self, selector: dict) -> list[Any] | None: + tagged = [v["value"] for v in selector["variants"]] + if not tagged or not all(isinstance(v, str) for v in tagged): + return None # boolean/number tags need no membership check + default = selector.get("default") + by = selector["by"] + extra: list[Any] = [] + default_type = self.types.get(default, {}) + if default and default_type.get("kind") == "record": + df = next((f for f in default_type["fields"] if f["wire"] == by), None) + ref = df and df["type"].get("ref") + if ref and self.types.get(ref, {}).get("kind") == "enum": + extra = self.types[ref]["values"] + return list(dict.fromkeys(tagged + extra)) + + def _union_from_alias(self, name: str) -> UnionIR: + variants = [] + discriminator = None + for arm in self.types[name]["type"]["union"]: + ref = arm.get("ref") + if not ref: + continue + const = next((f for f in self.types[ref]["fields"] if "const" in f["type"]), None) + if not const: + raise ValueError(f"alias-union {name} arm {ref} has no const discriminator") + discriminator = const["wire"] + variants.append(VariantIR("value", const["type"]["const"], ref, None)) + values = [v.value for v in variants] if all(isinstance(v.value, str) for v in variants) else None + # An alias-union carries bare-scalar arms (input.Origin's "viewport"/"pointer"), so the + # projector leaves it unflagged and a non-object payload still passes through. + object_only = bool(self.types[name].get("objectOnly")) + return UnionIR( + type_class_name(name), + name, + discriminator, + values, + variants, + [], + object_only=object_only, + spec_href=self.types[name].get("specHref"), + ) + + def params_for(self, params_ref: dict | None) -> list[ParamIR] | None: + if not params_ref: + return [] + type_ = self.types.get(params_ref["ref"]) + if not type_: + return None + if type_["kind"] == "record": + return [self._param_ir(f) for f in type_["fields"]] + if type_["kind"] == "union": + return self._union_params(type_, params_ref["ref"]) + return None + + def _param_ir(self, field_: dict, required: bool | None = None) -> ParamIR: + py, refs = self.py_type(field_["type"]) + self._pending_refs |= refs + return ParamIR( + name=safe_name(camel_to_snake(field_["name"])), + wire=field_["wire"], + required=bool(field_["required"]) if required is None else required, + py_type=py, + ) + + def _union_params(self, type_: dict, ref: str) -> list[ParamIR] | None: + variants = [self.types.get(v) for v in type_["variants"]] + if not all(v and v["kind"] == "record" for v in variants): + return None + self._guard_union_dispatch_keys(type_["selector"], ref) + return self._merged_params([v["fields"] for v in variants]) + + def _merged_params(self, variant_fields: list[list[dict]]) -> list[ParamIR]: + all_fields = [f for fields in variant_fields for f in fields] + out: list[ParamIR] = [] + for wire_name in dict.fromkeys(f["wire"] for f in all_fields): + field_ = next(f for f in all_fields if f["wire"] == wire_name) + required = all(any(f["wire"] == wire_name and f["required"] for f in fields) for fields in variant_fields) + out.append(self._param_ir(field_, required=required)) + return out + + def _guard_union_dispatch_keys(self, selector: dict, ref: str) -> None: + if selector.get("by"): + keys = [selector["by"]] + else: + keys = [k for arm in selector.get("ordered", []) for k in arm["requires"]] + camel = [k for k in keys if camel_to_snake(k) != k] + if camel: + raise ValueError( + f"union command param {ref} dispatches on non-snake wire key(s) {camel}; " + "Union.build matches kwargs to dispatch keys by name — add an explicit mapping first." + ) + + def structured_ref(self, name: str) -> str | None: + resolved = self._resolve_named(name, {}) + return None if resolved["list"] else resolved["ref"] + + # --- type resolution (serialization facts) --- + def _resolve(self, node: dict) -> dict: + nullable = bool(node.get("nullable")) + if "list" in node: + element = self._resolve(node["list"]) + return {"ref": element["ref"], "list": True, "nullable": nullable, "scalar": element.get("scalar")} + if "ref" in node: + named = self._resolve_named(node["ref"], {}) + return {"ref": named["ref"], "list": named["list"], "nullable": nullable, "scalar": named.get("scalar")} + if "union" in node: + return self._resolve_union(node, nullable) + return {"ref": None, "list": False, "nullable": nullable} + + # An inline union of one union-typed arm plus scalars (a map entry's RemoteValue / text) + # collapses onto that union ref. Because the union is object_only, a bare-scalar sibling + # would raise there, so forward the projector's ``scalar`` signal — the runtime passes a + # non-object leaf (the map's string keys) through instead, checked against this primitive. + def _resolve_union(self, node: dict, nullable: bool) -> dict: + refs = [arm for arm in node["union"] if "ref" in arm] + if len(refs) == 1 and self._union_ref(refs[0]["ref"]): + named = self._resolve_named(refs[0]["ref"], {}) + return {"ref": named["ref"], "list": named["list"], "nullable": nullable, "scalar": node.get("scalar")} + return {"ref": None, "list": False, "nullable": nullable} + + def _union_ref(self, name: str) -> bool: + type_ = self.types.get(name) + if not type_: + return False + if type_["kind"] == "alias" and "ref" in type_["type"]: + return self._union_ref(type_["type"]["ref"]) + return type_["kind"] == "union" + + _OPAQUE = {"ref": None, "list": False} + + def _resolve_named(self, name: str | None, seen: dict) -> dict: + if name is None or seen.get(name): + return dict(self._OPAQUE) + seen[name] = True + type_ = self.types.get(name) + if not type_: + return dict(self._OPAQUE) + kind = type_["kind"] + if kind == "record": + return dict(self._OPAQUE) if not type_["fields"] else {"ref": self._domain_ref(name), "list": False} + if kind == "union": + return {"ref": self._domain_ref(name), "list": False} + if kind == "enum": + return {"ref": None, "list": False} + if kind == "alias": + return self._resolve_named_alias(name, type_["type"], seen) + return dict(self._OPAQUE) + + def _resolve_named_alias(self, name: str, inner: dict, seen: dict) -> dict: + if "union" in inner: + return {"ref": self._domain_ref(name), "list": False} + if "ref" in inner: + return self._resolve_named(inner["ref"], seen) + if "list" in inner: + element = self._resolve(inner["list"]) + return {"ref": element["ref"], "list": True, "scalar": element.get("scalar")} + return {"ref": None, "list": False} + + def _domain_ref(self, name: str) -> str | None: + return name if "." in name else None + + def _enum_ref(self, node: dict) -> str | None: + ref = node.get("ref") or (node.get("list") or {}).get("ref") + if ref and self.types.get(ref, {}).get("kind") == "enum": + return ref + return None + + # The runtime-checkable scalar type of a field (or of a list's elements), + # following alias chains (e.g. js-uint -> integer). None for a record ref, + # union, const, enum, or opaque/unknown type — those are validated elsewhere. + def _leaf_primitive(self, node: dict, seen: set[str] | None = None) -> str | None: + seen = seen or set() + if "list" in node: + return self._leaf_primitive(node["list"], seen) + if "primitive" in node: + return _LEAF_PRIMITIVE.get(node["primitive"]) + if "ref" in node: + name = node["ref"] + type_ = self.types.get(name) + if name in seen or not type_ or type_["kind"] != "alias": + return None + seen.add(name) + return self._leaf_primitive(type_["type"], seen) + return None + + # --- Python type annotations --- + def py_type(self, node: dict) -> tuple[str, set[str]]: + refs: set[str] = set() + base = self._py_base(node, refs) + nullable = node.get("nullable") and base != "None" + return (f"{base} | None" if nullable else base), refs + + def _py_base(self, node: dict, refs: set[str]) -> str: + if "list" in node: + inner, _ = self._py_inner(node["list"], refs) + return f"list[{inner}]" + if "ref" in node: + return self._py_ref(node["ref"], refs) + if "union" in node: + return "Any" + if "const" in node: + value = node["const"] + if isinstance(value, bool): + return "bool" + if isinstance(value, str): + return "str" + return "float" if isinstance(value, float) else "int" + if "primitive" in node: + return _PRIMITIVE[node["primitive"]] # KeyError = a new schema primitive to handle (fail loud) + return "Any" + + def _py_inner(self, node: dict, refs: set[str]) -> tuple[str, set[str]]: + base = self._py_base(node, refs) + return (f"{base} | None" if node.get("nullable") and base != "None" else base), refs + + def _py_ref(self, name: str, refs: set[str], seen: set[str] | None = None) -> str: + seen = seen or set() + if name in seen: + return "Any" + seen.add(name) + type_ = self.types.get(name) + if not type_: + return "Any" + kind = type_["kind"] + if kind == "enum": + refs.add(name) + return type_class_name(name) + if kind == "record": + if not type_["fields"]: + return "Any" + refs.add(name) + return type_class_name(name) + if kind == "union": + refs.add(name) + return value_alias(name) + if kind == "alias": + inner = type_["type"] + if "union" in inner: + refs.add(name) + return value_alias(name) + if "ref" in inner: + return self._py_ref(inner["ref"], refs, seen) + if "list" in inner: + elem, _ = self._py_inner(inner["list"], refs) + return f"list[{elem}]" + return self._py_base(inner, refs) + return "Any" + + _pending_refs: set[str] = set() + + +# --------------------------------------------------------------------------- # +# IR builders +# --------------------------------------------------------------------------- # +_PARAMS_CLASS_KINDS = {"record", "union"} + + +def build_command(schema: Schema, cmd: dict) -> CommandIR: + params = schema.params_for(cmd.get("params")) + if cmd.get("params") and params is None: + raise ValueError(f"command {cmd['method']} has params that cannot be expressed as a typed object") + params_ref = (cmd.get("params") or {}).get("ref") + kind = schema.type_kind(params_ref) + params_class = type_class_name(params_ref) if params and kind in _PARAMS_CLASS_KINDS else None + result_ref = (cmd.get("result") or {}).get("ref") + result_name = schema.structured_ref(result_ref) if result_ref else None + _guard_same_domain(cmd["domain"], params_ref if params_class else None, result_name, cmd["method"]) + result_class = type_class_name(result_name) if result_name else None + if result_name and schema.type_kind(result_name) == "union": + result_type = value_alias(result_name) + elif result_class: + result_type = result_class + else: + result_type = "Any" + return CommandIR( + wire=cmd["method"], + method=safe_name(camel_to_snake(cmd["name"])), + params=params or [], + params_class=params_class, + union_params=(kind == "union"), + result_class=result_class, + result_type=result_type, + spec_href=cmd.get("specHref"), + ) + + +def build_event(schema: Schema, event: dict) -> EventIR: + params = event.get("params") + payload_ref = params.get("ref") if params else None + payload = schema.structured_ref(payload_ref) if payload_ref else None + _guard_same_domain(event["domain"], None, payload, event["method"]) + return EventIR( + wire=event["method"], + name=safe_name(camel_to_snake(event["name"])), + payload_class=type_class_name(payload) if payload else None, + ) + + +def _guard_same_domain(domain: str, *type_names: str | None) -> None: + for name in type_names: + if name and "." in name and name.split(".", 1)[0] != domain: + raise ValueError(f"{domain}: cross-domain type reference {name!r} not yet supported") + + +def build_module(schema: Schema, domain: str) -> ModuleIR: + schema._pending_refs = set() + records, unions = schema.types_for(domain) + commands = [build_command(schema, c) for c in schema.commands_for(domain)] + events = [build_event(schema, e) for e in schema.events_for(domain)] + imports = _cross_domain_imports(schema, domain) + return ModuleIR( + domain=domain, + class_name=snake_to_class(camel_to_snake(domain)), + filename=camel_to_snake(domain), + enums=schema.enums_for(domain), + records=records, + unions=unions, + commands=commands, + events=events, + imports=imports, + spec_href=schema._domain_links.get(domain, {}).get("specHref"), + ) + + +def _cross_domain_imports(schema: Schema, domain: str) -> dict[str, set[str]]: + imports: dict[str, set[str]] = {} + for ref in schema._pending_refs: + ref_domain = ref.split(".", 1)[0] + if ref_domain == domain: + continue + module = camel_to_snake(ref_domain) + is_union = schema.type_kind(ref) == "union" or _is_alias_union(schema, ref) + name = value_alias(ref) if is_union else type_class_name(ref) + imports.setdefault(module, set()).add(name) + return imports + + +def _is_alias_union(schema: Schema, ref: str) -> bool: + t = schema.types.get(ref, {}) + return t.get("kind") == "alias" and "union" in t.get("type", {}) + + +# --------------------------------------------------------------------------- # +# Emission +# --------------------------------------------------------------------------- # +def _annotation(f: FieldIR) -> str: + if f.fixed is not _NO_FIXED: + return f.py_type + if f.required: + return f.py_type + return f"{f.py_type} | UnsetType" + + +def _wire_args(f: FieldIR) -> list[str]: + args = [lit(f.wire)] + if f.required: + args.append("required=True") + if f.nullable: + args.append("nullable=True") + if f.ref: + args.append(f"ref={lit(f.ref)}") + if f.is_list: + args.append("is_list=True") + if f.enum: + args.append(f"enum={lit(f.enum)}") + if f.primitive: + args.append(f"primitive={lit(f.primitive)}") + if f.scalar: + value = f"[{', '.join(lit(s) for s in f.scalar)}]" if isinstance(f.scalar, list) else lit(f.scalar) + args.append(f"scalar={value}") + if f.fixed is not _NO_FIXED: + args.append(f"fixed={lit(f.fixed)}") + return args + + +def _emit_field(f: FieldIR, optional_default: bool) -> str: + meta_call = f"meta({', '.join(_wire_args(f))})" + field_args = [] + if f.fixed is not _NO_FIXED: + field_args += [f"default={lit(f.fixed)}", "init=False"] + elif optional_default: + field_args.append("default=UNSET") + field_args.append(f"metadata={meta_call}") + prefix = f" {f.name}: {_annotation(f)} = field(" + return wrap_call(prefix, field_args, ")", 4) + + +def _spec_docstring(name: str, spec_href: str | None) -> list[str]: + """Class docstring lines (with trailing blank) linking the element to its BiDi definition.""" + if not spec_href: + return [] + return [f' """{name}.', "", f" See {spec_href}", ' """', ""] + + +def _emit_enum(e: EnumIR) -> str: + lines = [f"@register({lit(e.schema_name)})", f"class {e.class_name}(str, Enum):"] + lines += _spec_docstring(e.schema_name, e.spec_href) + for member, value in e.members: + lines.append(f" {member} = {lit(value)}") + return "\n".join(lines) + + +def _emit_record(r: RecordIR) -> str: + lines = [f"@register({lit(r.schema_name)})", "@dataclass(frozen=True)", f"class {r.class_name}(Record):"] + doc = _spec_docstring(r.schema_name, r.spec_href) + lines += doc + if r.extensible: + lines.append(" _EXTENSIBLE = True") + required = [f for f in r.fields if f.required] + optional = [f for f in r.fields if not f.required] + for f in required: + lines.append(_emit_field(f, optional_default=False)) + if r.discriminator: + lines.append(_emit_field(r.discriminator, optional_default=False)) + for f in optional: + lines.append(_emit_field(f, optional_default=True)) + if r.extensible: + lines.append(' extensions: dict[str, Any] | UnsetType = field(default=UNSET, metadata=meta("extensions"))') + if not r.fields and not r.discriminator and not r.extensible and not doc: + lines.append(" pass") + return "\n".join(lines) + + +def _emit_union(u: UnionIR) -> str: + lines = [f"@register({lit(u.schema_name)})", f"class {u.class_name}(Union):"] + lines += _spec_docstring(u.schema_name, u.spec_href) + value_variants = [v for v in u.variants if v.mode == "value"] + fallback = next((v for v in u.variants if v.mode == "fallback"), None) + presence = [v for v in u.variants if v.mode == "presence"] + if u.discriminator is not None: + lines.append(f" _DISCRIMINATOR = {lit(u.discriminator)}") + if value_variants: + lines.append(" _VARIANTS = {") + for v in value_variants: + lines.append(f" {lit(v.value)}: {lit(v.ref)},") + lines.append(" }") + if presence: + entries = [f"({lit(v.ref)}, {tuple_lit(v.requires)})" for v in presence] + lines.append(wrap_call(" _PRESENCE = (", entries, ")", 4)) + if fallback: + lines.append(f" _FALLBACK = {lit(fallback.ref)}") + if u.discriminator_values: + # Set literal inside frozenset (never frozenset(("x")) — a single value with no + # trailing comma is not a tuple, so it would iterate the string into characters). + values = [lit(v) for v in u.discriminator_values] + lines.append(f" _DISCRIMINATOR_VALUES = {frozenset_lit(values, 4)}") + if u.object_only: + lines.append(" _OBJECT_ONLY = True") + alias = _emit_type_alias(value_alias(u.schema_name), u.variant_types) + return "\n".join(lines) + "\n\n\n" + alias + + +def _emit_type_alias(name: str, variants: list[str]) -> str: + unique = list(dict.fromkeys(variants)) + one_line = f'{name}: TypeAlias = "{" | ".join(unique)}"' + if len(one_line) <= LINE_LIMIT: + return one_line + # Wrap into parenthesized implicit string concatenation (E501-safe); adjacent + # string literals concatenate, and the " | " separators are kept intact. + tokens = [unique[0]] + [f" | {v}" for v in unique[1:]] + lines: list[str] = [] + cur = "" + for tok in tokens: + if cur and len(f' "{cur}{tok}"') > LINE_LIMIT: + lines.append(f' "{cur}"') + cur = tok + else: + cur += tok + lines.append(f' "{cur}"') + return f"{name}: TypeAlias = (\n" + "\n".join(lines) + "\n)" + + +def _emit_command(c: CommandIR) -> list[str]: + required = [p for p in c.params if p.required] + optional = [p for p in c.params if not p.required] + sig = ["self"] + sig += [f"{p.name}: {p.py_type}" for p in required] + sig += [f"{p.name}: {p.py_type} | UnsetType = UNSET" for p in optional] + lines = [wrap_call(f" def {c.method}(", sig, f") -> {c.result_type}:", 4)] + if c.spec_href: + lines.append(f' """Execute {c.wire} (internal, unsupported).') + lines.append("") + lines.append(f" See {c.spec_href}") + lines.append(' """') + else: + lines.append(f' """Execute {c.wire} (internal, unsupported)."""') + if c.params_class: + kwargs = [f"{p.name}={p.name}" for p in c.params] + method = "build" if c.union_params else "" + prefix = f" params = {c.params_class}.{method}(" if method else f" params = {c.params_class}(" + lines.append(wrap_call(prefix, kwargs, ")", 8)) + params_arg = "params=params" + else: + params_arg = "params=None" + result_arg = f"result={c.result_class}" if c.result_class else "result=None" + lines.append(f" return self._execute({lit(c.wire)}, {params_arg}, {result_arg})") + return lines + + +def _emit_domain_class(mod: ModuleIR) -> str: + doc = [' """Internal, unsupported.', "", f" See {BIDI_DOC_URL}"] + if mod.spec_href: + doc.append(f" See {mod.spec_href}") + doc.append(' """') + lines = [f"class {mod.class_name}(Domain):", *doc] + if mod.events: + # A blank line separates the class docstring from the first body statement; the + # command methods below carry their own leading blank, so only EVENTS needs one here. + lines.append("") + lines.append(" EVENTS = {") + for e in mod.events: + lines.append(f" {lit(e.name)}: {lit(e.wire)},") + lines.append(" }") + lines.append(" EVENT_TYPES = {") + for e in mod.events: + payload = lit(f"{mod.domain}.{e.payload_class}") if e.payload_class else "None" + lines.append(f" {lit(e.wire)}: {payload},") + lines.append(" }") + body = [] + for c in mod.commands: + body.append("") + body.extend(_emit_command(c)) + return "\n".join(lines + body) + + +def _import_order(name: str) -> tuple[int, str]: + """Ruff isort 'order-by-type': constants (UPPER), then classes, then functions.""" + if name.isupper(): + rank = 0 + elif name[0].isupper(): + rank = 1 + else: + rank = 2 + return (rank, name) + + +def _imports(mod: ModuleIR) -> list[str]: + has_records = bool(mod.records) + has_unions = bool(mod.unions) + uses_unset = ( + any(not f.required for r in mod.records for f in r.fields) + or any(r.extensible for r in mod.records) + or any(not p.required for c in mod.commands for p in c.params) + ) + # "Any" appears in any emitted annotation + text_blob = " ".join( + [f.py_type for r in mod.records for f in r.fields] + + [p.py_type for c in mod.commands for p in c.params] + + [c.result_type for c in mod.commands] + + ["dict[str, Any]" if any(r.extensible for r in mod.records) else ""] + ) + uses_any = "Any" in text_blob.split() or "Any]" in text_blob or "[Any" in text_blob + + typing_names = ["TYPE_CHECKING"] if mod.imports else [] + if uses_any: + typing_names.append("Any") + if has_unions: + typing_names.append("TypeAlias") + + runtime = [] + if has_records: + runtime += ["Record", "meta"] + if has_unions: + runtime.append("Union") + if uses_unset: + runtime += ["UNSET", "UnsetType"] + if mod.enums or has_records or has_unions: + runtime.append("register") + + lines = ["from __future__ import annotations", ""] + if has_records: + lines.append("from dataclasses import dataclass, field") + if mod.enums: + lines.append("from enum import Enum") + if typing_names: + lines.append(f"from typing import {', '.join(dict.fromkeys(typing_names))}") + lines.append("") + lines.append("from selenium.webdriver.common._bidi.domain import Domain") + if runtime: + names = ", ".join(sorted(set(runtime), key=_import_order)) + lines.append(f"from selenium.webdriver.common._bidi.serialization import {names}") + if mod.imports: + lines += ["", "if TYPE_CHECKING:"] + for module in sorted(mod.imports): + names = ", ".join(sorted(mod.imports[module])) + lines.append(f" from selenium.webdriver.common._bidi.{module} import {names}") + return lines + + +def render_module(mod: ModuleIR) -> str: + blocks: list[str] = [_HEADER, "\n".join(_imports(mod))] + for e in mod.enums: + blocks.append(_emit_enum(e)) + for r in mod.records: + blocks.append(_emit_record(r)) + for u in mod.unions: + blocks.append(_emit_union(u)) + blocks.append(_emit_domain_class(mod)) + return "\n\n\n".join(blocks) + "\n" + + +def render_all(schema_path: str) -> dict[str, str]: + """Render every domain module; returns {filename: contents}. + + The package ``__init__.py`` is hand-written (it carries only the package + docstring), so it is intentionally not emitted here. + """ + schema = Schema(json.loads(Path(schema_path).read_text())) + modules = [build_module(schema, domain) for domain in schema.domains()] + return {f"{mod.filename}.py": render_module(mod) for mod in modules} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("schema", help="Path to the binding-neutral BiDi schema JSON") + parser.add_argument( + "output_dir", + nargs="?", + help="Output directory (default: /py/selenium/webdriver/common/_bidi under `bazel run`)", + ) + args = parser.parse_args() + + out_dir = args.output_dir + if out_dir is None: + workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + if not workspace: + parser.error("output_dir is required unless run via `bazel run` (sets BUILD_WORKSPACE_DIRECTORY)") + out_dir = os.path.join(workspace, DEFAULT_OUTPUT) + + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + for name, contents in render_all(args.schema).items(): + (out / name).write_text(contents) + print(f"bidi-generate: wrote {name}") + + +if __name__ == "__main__": + main() diff --git a/py/private/generate_bidi_protocol.bzl b/py/private/generate_bidi_protocol.bzl new file mode 100644 index 0000000000000..996f1dba2bed3 --- /dev/null +++ b/py/private/generate_bidi_protocol.bzl @@ -0,0 +1,61 @@ +"""Bazel rule generating the internal ``_bidi`` protocol layer from the shared BiDi schema. + +Unlike ``generate_bidi`` (which parses CDDL for ``common/bidi``), this consumes the +already-projected, binding-neutral schema JSON and emits the generated domain modules. +The hand-written runtime (``serialization``/``transport``/``domain``) is not produced here. +""" + +# Generated domain modules, snake_case (one per BiDi domain in the schema). The +# package ``__init__.py`` is hand-written and checked in, so it is not generated here. +_MODULES = [ + "bluetooth", + "browser", + "browsing_context", + "emulation", + "input", + "log", + "network", + "permissions", + "script", + "session", + "speculation", + "storage", + "user_agent_client_hints", + "web_extension", +] + +def _generate_bidi_protocol_impl(ctx): + outputs = [ctx.actions.declare_file(ctx.attr.package + "/" + name + ".py") for name in _MODULES] + + ctx.actions.run( + inputs = [ctx.file.schema], + outputs = outputs, + executable = ctx.executable.generator, + # The generator writes one file per domain into the output dir. + arguments = [ctx.file.schema.path, outputs[-1].dirname], + use_default_shell_env = True, + ) + + return [DefaultInfo(files = depset(outputs))] + +generate_bidi_protocol = rule( + implementation = _generate_bidi_protocol_impl, + attrs = { + "schema": attr.label( + allow_single_file = [".json"], + mandatory = True, + doc = "Projected binding-neutral BiDi schema JSON", + ), + "generator": attr.label( + executable = True, + cfg = "exec", + mandatory = True, + doc = "The generate_bidi_protocol.py generator binary", + ), + "package": attr.string( + mandatory = True, + doc = "Output package path, e.g. 'selenium/webdriver/common/_bidi'", + ), + }, + doc = "Generates the internal _bidi protocol modules from the shared BiDi schema", +) diff --git a/py/selenium/webdriver/common/_bidi/__init__.py b/py/selenium/webdriver/common/_bidi/__init__.py new file mode 100644 index 0000000000000..c0799e154d32f --- /dev/null +++ b/py/selenium/webdriver/common/_bidi/__init__.py @@ -0,0 +1,30 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Internal WebDriver BiDi protocol layer, projected from the BiDi specification. + +This package is an internal implementation detail, not a supported public API. +It carries no stability guarantee and may change without warning between releases. +Program against the high-level driver APIs instead. See +https://www.selenium.dev/documentation/warnings/bidi-implementation/ +""" + +# The domain modules beside this file (network.py, browsing_context.py, ...) are +# generated at build time from the shared BiDi schema by //py:create-bidi-protocol-src +# and are not checked in. Only serialization.py, transport.py, domain.py and this +# __init__.py are hand-written. Regenerate the domain modules locally with: +# bazel run //py:generate-bidi-protocol diff --git a/py/selenium/webdriver/common/_bidi/domain.py b/py/selenium/webdriver/common/_bidi/domain.py new file mode 100644 index 0000000000000..bff105d4928a9 --- /dev/null +++ b/py/selenium/webdriver/common/_bidi/domain.py @@ -0,0 +1,54 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Base class for the generated BiDi domain modules. + +This is internal, unsupported implementation. See +https://www.selenium.dev/documentation/warnings/bidi-implementation/ +""" + +from __future__ import annotations + +from typing import Any + +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.common._bidi.transport import Transport + + +class Domain: + """Base for every generated domain module. + + ``source`` is a ``WebDriver`` — whose one shared BiDi transport is reused (the + driver starts BiDi if it hasn't already) — or a :class:`Transport` for the + standalone path. + """ + + def __init__(self, source: Any) -> None: + if isinstance(source, Transport): + self._transport = source + return + transport = getattr(source, "_bidi_transport", None) + if transport is None: + start = getattr(source, "_start_bidi", None) + if start is None: + raise WebDriverException("a WebDriver or Transport is required") + start() + transport = source._bidi_transport + self._transport = transport + + def _execute(self, cmd: str, params: Any = None, result: Any = None) -> Any: + return self._transport.execute(cmd, params=params, result=result) diff --git a/py/selenium/webdriver/common/_bidi/serialization.py b/py/selenium/webdriver/common/_bidi/serialization.py new file mode 100644 index 0000000000000..06fb8b5b6bc86 --- /dev/null +++ b/py/selenium/webdriver/common/_bidi/serialization.py @@ -0,0 +1,410 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Serialization runtime for the generated WebDriver BiDi protocol layer. + +Hand-written support for the generated ``selenium.webdriver.common._bidi.*`` modules +(not itself generated). Provides the immutable value-type base (:class:`Record`), +the discriminated-union dispatch base (:class:`Union`), and the :data:`UNSET` +sentinel that distinguishes an omitted optional from an explicit wire ``null``. + +Each generated field carries its wire facts as dataclass field metadata (see +:func:`meta`), so a record's Python fields are its single source of truth — there +is no parallel field table. + +This is internal, unsupported implementation. See +https://www.selenium.dev/documentation/warnings/bidi-implementation/ +""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from selenium.common.exceptions import WebDriverException + + +class BiDiSerializationError(WebDriverException): + """A payload could not be (de)serialized against this Selenium's BiDi schema.""" + + +class UnsetType: + """Type of the :data:`UNSET` sentinel (an omitted optional, not ``None``).""" + + _instance: UnsetType | None = None + + def __new__(cls) -> UnsetType: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "UNSET" + + def __bool__(self) -> bool: + return False + + +UNSET: UnsetType = UnsetType() +"""Marks an omitted optional. Dropped from the wire; distinct from ``None`` (explicit null).""" + + +@dataclass(frozen=True) +class _Wire: + """Wire facts for one record field, attached as its dataclass field metadata.""" + + wire: str + required: bool = False + nullable: bool = False + ref: str | None = None + is_list: bool = False + enum: str | None = None + primitive: str | None = None + scalar: str | list[str] | None = None + fixed: Any = UNSET + + +_META_KEY = "bidi" + + +def meta( + name: str, + *, + required: bool = False, + nullable: bool = False, + ref: str | None = None, + is_list: bool = False, + enum: str | None = None, + primitive: str | None = None, + scalar: str | list[str] | None = None, + fixed: Any = UNSET, +) -> dict: + """Dataclass field metadata carrying one field's BiDi wire facts. + + Used as ``x: T = field(metadata=meta(...))`` in the generated modules — a plain + ``dataclasses.field`` so it reads as an ordinary dataclass field. A baked + discriminator additionally sets ``default=, init=False`` on the field. + + ``scalar`` marks a map field (a ``[key, value]`` list whose value type is an + object-only union): the union's string keys pass through when they match this + primitive, rather than being rejected as non-objects. + """ + descriptor = _Wire( + wire=name, + required=required, + nullable=nullable, + ref=ref, + is_list=is_list, + enum=enum, + primitive=primitive, + scalar=scalar, + fixed=fixed, + ) + return {_META_KEY: descriptor} + + +# schema type name -> generated class, populated as each domain module imports. +# Refs are resolved lazily (at (de)serialization time) through this registry so +# generated modules never import each other at runtime, avoiding import cycles. +_REGISTRY: dict[str, type] = {} + + +def register(schema_name: str) -> Callable[[type], type]: + """Class decorator registering a generated class under its schema type name.""" + + def decorate(cls: type) -> type: + _REGISTRY[schema_name] = cls + return cls + + return decorate + + +def resolve(schema_name: str) -> type: + try: + return _REGISTRY[schema_name] + except KeyError: + raise BiDiSerializationError(f"unknown BiDi type {schema_name!r} (module not imported?)") from None + + +def _fields(obj: Any) -> tuple[dataclasses.Field[Any], ...]: + """``dataclasses.fields`` for a Record subclass; the ``Record`` base is not itself a dataclass.""" + return dataclasses.fields(obj) + + +def _wire_of(f: dataclasses.Field) -> _Wire: + return f.metadata[_META_KEY] + + +def _as_json(value: Any) -> Any: + if isinstance(value, Record): + return value.as_json() + if isinstance(value, Enum): + return value.value + if isinstance(value, list): + return [_as_json(item) for item in value] + if isinstance(value, dict): + return {key: _as_json(item) for key, item in value.items()} + return value + + +class Record: + """Immutable value-type base for generated params/results/event payloads. + + Subclasses are ``@dataclass(frozen=True)`` whose fields are declared with + :func:`meta`. Outbound (:meth:`as_json`) omits ``UNSET`` and emits ``null`` only + for nullable fields; inbound (:meth:`from_json`) is strict on values and + required-presence, lenient on unknown keys (the spec permits extra properties). + """ + + _EXTENSIBLE: bool = False + + def __post_init__(self) -> None: + for f in _fields(self): + if f.name == "extensions": + continue + w = _wire_of(f) + if w.fixed is not UNSET: + continue + value = getattr(self, f.name) + if value is UNSET: + continue + if value is None: + if not w.nullable: + raise BiDiSerializationError(f"{type(self).__name__}.{f.name} cannot be None") + continue + if w.enum is not None: + self._validate_enum(f.name, w, value) + + def _validate_enum(self, name: str, w: _Wire, value: Any) -> None: + enum_cls = resolve(w.enum) # type: ignore[arg-type] + for item in value if w.is_list else [value]: + if isinstance(item, enum_cls): + continue + try: + enum_cls(item) + except ValueError: + raise BiDiSerializationError( + f"{type(self).__name__}.{name}: {item!r} is not a valid {enum_cls.__name__}" + ) from None + + def as_json(self) -> dict: + payload: dict = {} + for f in _fields(self): + if f.name == "extensions": + continue + w = _wire_of(f) + value = getattr(self, f.name) + if value is UNSET: + continue + if value is None and not w.nullable: + continue + payload[w.wire] = _as_json(value) + if self._EXTENSIBLE: + payload.update(getattr(self, "extensions", None) or {}) + return payload + + @classmethod + def from_json(cls, payload: dict) -> Any: + kwargs: dict = {} + known: set[str] = set() + for f in _fields(cls): + w = _wire_of(f) + known.add(w.wire) + if w.fixed is not UNSET or f.name == "extensions": + continue + kwargs[f.name] = _read_field(cls, f.name, w, payload) + if cls._EXTENSIBLE: + kwargs["extensions"] = {k: v for k, v in payload.items() if k not in known} + return cls(**kwargs) + + +def _read_field(cls: type, name: str, w: _Wire, payload: dict) -> Any: + if w.wire not in payload: + if w.required: + raise BiDiSerializationError(f"{cls.__name__}.{name} missing required {w.wire!r}") + return UNSET + raw = payload[w.wire] + if raw is None: + if w.nullable: + return None + raise BiDiSerializationError(f"{cls.__name__}.{name} received null but is not nullable") + if w.is_list != isinstance(raw, list) and (w.is_list or w.enum or w.ref or w.primitive): + expected = "a list" if w.is_list else "a single value" + raise BiDiSerializationError(f"{cls.__name__}.{name} expected {expected}, got {raw!r}") + if w.is_list: + if w.scalar is not None: + return [_read_map_entry(cls, name, w, item) for item in raw] + return [_read_scalar(cls, name, w, item) for item in raw] + return _read_scalar(cls, name, w, raw) + + +def _read_map_entry(cls: type, name: str, w: _Wire, element: Any) -> list: + """Read one ``[key, value]`` map entry into typed key/value. + + A map arrives as ``[[key, value], ...]``. The key is ``ref / text``: an object + key deserializes; a bare-scalar key passes through once it matches the arm's + ``scalar`` primitive. The value is the object-only ref and always deserializes, + so a bare scalar there is rejected. A non-pair element is malformed. + """ + if not (isinstance(element, list) and len(element) == 2): + raise BiDiSerializationError(f"{cls.__name__}.{name} expected a [key, value] pair, got {element!r}") + klass = resolve(w.ref) # type: ignore[arg-type] + key, value = element + key = klass.from_json(key) if isinstance(key, dict) else _check_scalar(cls, name, w, key) # type: ignore[attr-defined] + return [key, klass.from_json(value)] # type: ignore[attr-defined] + + +def _check_scalar(cls: type, name: str, w: _Wire, value: Any) -> Any: + """A bare map key must match one of the union's scalar-arm primitives. + + A wrong-typed scalar (a number where a string is expected) is a wire error, not + something to pass through. An unrecognized primitive is left unchecked, matching + the lenient default elsewhere. + """ + scalars = [s for s in (w.scalar if isinstance(w.scalar, list) else [w.scalar]) if s is not None] + checks = [_PRIMITIVE_CHECKS[s] for s in scalars if s in _PRIMITIVE_CHECKS] + if not checks or any(check(value) for check in checks): + return value + got = type(value).__name__ + raise BiDiSerializationError(f"{cls.__name__}.{name}: map key expected {' or '.join(scalars)}, got {got} {value!r}") + + +# JSON value -> the Python types a schema primitive accepts. ``integer`` accepts only an +# int (a non-integer float like 1.5 is a real mismatch, and even 5.0 is rejected under +# strict-first — relax reactively if a browser is ever seen sending it). ``number`` accepts +# an int or a float. ``bool`` is excluded from the numeric checks: it is an ``int`` subclass +# but is not a number. +_PRIMITIVE_CHECKS = { + "str": lambda v: isinstance(v, str), + "bool": lambda v: isinstance(v, bool), + "int": lambda v: isinstance(v, int) and not isinstance(v, bool), + "float": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool), +} + + +def _read_scalar(cls: type, name: str, w: _Wire, raw: Any) -> Any: + if w.enum is not None: + enum_cls = resolve(w.enum) + try: + return enum_cls(raw) + except ValueError: + raise BiDiSerializationError(f"{cls.__name__}.{name}: {raw!r} is not a valid {enum_cls.__name__}") from None + if w.ref is not None: + klass = resolve(w.ref) + # A record must arrive as an object; a union may legitimately be a bare scalar + # arm (e.g. input.Origin's "viewport"), so let its from_json handle non-objects. + if not issubclass(klass, Union) and not isinstance(raw, dict): + got = type(raw).__name__ + raise BiDiSerializationError(f"{cls.__name__}.{name}: expected an object, got {got} {raw!r}") + return klass.from_json(raw) # type: ignore[attr-defined] + if w.primitive is not None and raw is not None: + check = _PRIMITIVE_CHECKS.get(w.primitive) + if check and not check(raw): + got = type(raw).__name__ + raise BiDiSerializationError(f"{cls.__name__}.{name}: expected {w.primitive}, got {got} {raw!r}") + return raw + + +class Union: + """Dispatch base for a discriminated union; subclassed, never instantiated. + + Carries the schema's authoritative ``selector`` as class attributes: a + discriminator wire key + tag->variant table, and/or presence-ordered rules, and + an optional declared fallback. Inbound (:meth:`from_json`) resolves a payload to a + variant; outbound (:meth:`build`) selects the variant a command's kwargs describe. + """ + + _DISCRIMINATOR: str | None = None + _VARIANTS: dict[Any, str] = {} + _PRESENCE: tuple[tuple[str, tuple[str, ...]], ...] = () + _FALLBACK: str | None = None + _DISCRIMINATOR_VALUES: frozenset[Any] | None = None + _OBJECT_ONLY: bool = False + + @classmethod + def from_json(cls, payload: Any) -> Any: + if not isinstance(payload, dict): + # Every arm is an object (the schema's ``objectOnly`` signal), so a + # non-object payload can match no variant and is a wire error. + if cls._OBJECT_ONLY: + got = type(payload).__name__ + raise BiDiSerializationError(f"{cls.__name__} expected an object on the wire, got {got} {payload!r}") + # A bare scalar arm (e.g. input.Origin's "viewport") has no object to + # dispatch on, so it is returned unchanged. + return payload + variant = cls._select(payload) + if variant is None: + raise BiDiSerializationError( + f"{cls.__name__} received a variant not in this Selenium's BiDi schema: {payload!r}" + ) + return resolve(variant).from_json(payload) # type: ignore[attr-defined] + + @classmethod + def _select(cls, payload: dict) -> str | None: + if cls._DISCRIMINATOR is not None and cls._DISCRIMINATOR in payload: + tag = payload[cls._DISCRIMINATOR] + if tag in cls._VARIANTS: + return cls._VARIANTS[tag] + for variant, keys in cls._PRESENCE: + if all(key in payload for key in keys): + return variant + return cls._FALLBACK + + @classmethod + def build(cls, **kwargs: Any) -> Any: + cls._validate_discriminator(kwargs) + variant = cls._outbound(kwargs) + if variant is None: + raise BiDiSerializationError(f"no {cls.__name__} variant matches {kwargs!r}") + klass = resolve(variant) + provided = {k: v for k, v in kwargs.items() if v is not UNSET} + fields = {f.name for f in _fields(klass)} + invalid = set(provided) - fields + if invalid: + raise BiDiSerializationError(f"invalid combination for {cls.__name__}: {', '.join(sorted(invalid))}") + # A baked discriminator (init=False) is forced to its const, so drop it from + # the constructor kwargs even though it is a valid field to have named. + settable = {f.name for f in _fields(klass) if _wire_of(f).fixed is UNSET} + return klass(**{k: v for k, v in provided.items() if k in settable}) + + # Outbound-strict (V1): a supplied discriminator must be in the union-wide allowed + # set, so an invalid value fails locally rather than routing to the fallback variant. + @classmethod + def _validate_discriminator(cls, kwargs: dict) -> None: + if cls._DISCRIMINATOR is None or cls._DISCRIMINATOR_VALUES is None: + return + value = kwargs.get(cls._DISCRIMINATOR, UNSET) + if isinstance(value, Enum): + value = value.value + if value is not UNSET and value not in cls._DISCRIMINATOR_VALUES: + raise BiDiSerializationError(f"{cls.__name__}.{cls._DISCRIMINATOR}: {value!r} is not a valid discriminator") + + @classmethod + def _outbound(cls, kwargs: dict) -> str | None: + if cls._DISCRIMINATOR is not None: + tag = kwargs.get(cls._DISCRIMINATOR, UNSET) + if isinstance(tag, Enum): + tag = tag.value + if tag is not UNSET and tag in cls._VARIANTS: + return cls._VARIANTS[tag] + for variant, keys in cls._PRESENCE: + if all(kwargs.get(key, UNSET) is not UNSET for key in keys): + return variant + return cls._FALLBACK diff --git a/py/selenium/webdriver/common/_bidi/transport.py b/py/selenium/webdriver/common/_bidi/transport.py new file mode 100644 index 0000000000000..6d55e9f17d87f --- /dev/null +++ b/py/selenium/webdriver/common/_bidi/transport.py @@ -0,0 +1,58 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""The command seam between the generated BiDi protocol layer and the websocket. + +Hand-written (not generated). :class:`Transport` serializes a command's params, +sends them over the session websocket, parses the reply into its declared result +type, and exposes the underlying connection for event subscription. + +This is internal, unsupported implementation. See +https://www.selenium.dev/documentation/warnings/bidi-implementation/ +""" + +from __future__ import annotations + +from typing import Any + +from selenium.common.exceptions import WebDriverException + + +class Transport: + """Serializes params, sends over a websocket connection, parses the reply. + + Deliberately narrow: it holds only an immutable connection reference (the id + counter and callbacks live on the shared ``WebSocketConnection``). One is + built per session and owned by the ``WebDriver`` (as ``_bidi_transport``), so + every domain over that session shares it. + """ + + def __init__(self, connection: Any) -> None: + self._connection = connection + + @property + def connection(self) -> Any: + """The underlying websocket, for event subscription (which lives on it, not here).""" + return self._connection + + def execute(self, cmd: str, params: Any = None, result: Any = None) -> Any: + reply = self._connection.send_cmd(cmd, params.as_json() if params is not None else {}) + if reply.get("error"): + message = reply.get("message") + raise WebDriverException(f"{reply['error']}: {message}" if message else reply["error"]) + value = reply["result"] + return result.from_json(value) if result is not None else value diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index f3e95859276cc..e473a6c3dd433 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -183,6 +183,7 @@ def create_matches(options: list[BaseOptions]) -> dict: if TYPE_CHECKING: + from selenium.webdriver.common._bidi.transport import Transport from selenium.webdriver.common.api_request_context import APIRequestContext from selenium.webdriver.common.fedcm.dialog import Dialog from selenium.webdriver.common.print_page_options import PrintOptions @@ -301,6 +302,7 @@ def __init__( self._fedcm = FedCM(self) self._websocket_connection: WebSocketConnection | None = None + self._bidi_transport: Transport | None = None self._script: Script | None = None self._network: Network | None = None self._browser: Browser | None = None @@ -642,6 +644,7 @@ def quit(self) -> None: if self._websocket_connection is not None: self._websocket_connection.close() self._websocket_connection = None + self._bidi_transport = None self.execute(Command.QUIT) finally: if self._request is not None: @@ -1219,6 +1222,10 @@ def _start_bidi(self) -> None: self.command_executor.client_config.websocket_timeout, self.command_executor.client_config.websocket_interval, ) + # Imported lazily: only BiDi sessions pay for loading the generated _bidi package. + from selenium.webdriver.common._bidi.transport import Transport + + self._bidi_transport = Transport(self._websocket_connection) @property def network(self) -> Network: diff --git a/py/selenium/webdriver/remote/websocket_connection.py b/py/selenium/webdriver/remote/websocket_connection.py index 5d3f75f761542..42b56f84ee37f 100644 --- a/py/selenium/webdriver/remote/websocket_connection.py +++ b/py/selenium/webdriver/remote/websocket_connection.py @@ -115,11 +115,15 @@ def close(self): self._started = False self._ws = None - def execute(self, command): + def _round_trip(self, payload): + """Assign an id, envelope and send the frame, and return the correlated raw reply. + + Shared by ``execute`` (coroutine path) and ``send_cmd`` (direct path). Raises on + timeout; the caller decides what to do with a reply that carries ``error``. + """ with self._id_lock: self._id += 1 current_id = self._id - payload = self._serialize_command(command) payload["id"] = current_id if self.session_id: payload["sessionId"] = self.session_id @@ -131,7 +135,18 @@ def execute(self, command): self._wait_until(lambda: current_id in self._messages) if current_id not in self._messages: raise WebDriverException(f"Timed out waiting for response to BiDi command {current_id}") - response = self._messages.pop(current_id) + return self._messages.pop(current_id) + + def send_cmd(self, method, params): + """Send one command and return its raw reply (``result`` or ``error`` left intact). + + The dumb-send counterpart to ``execute``: no coroutine, no error raising. The + caller parses the reply into its declared type and raises on ``error`` itself. + """ + return self._round_trip({"method": method, "params": params}) + + def execute(self, command): + response = self._round_trip(self._serialize_command(command)) if "error" in response: error = response["error"] diff --git a/py/test/selenium/webdriver/common/_bidi/browser_tests.py b/py/test/selenium/webdriver/common/_bidi/browser_tests.py new file mode 100644 index 0000000000000..37da99d173b2c --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/browser_tests.py @@ -0,0 +1,274 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/browser_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +import os + +import pytest + +from selenium.common.exceptions import TimeoutException, WebDriverException +from selenium.webdriver.common._bidi.browser import ( + Browser, + ClientWindowInfo, + ClientWindowInfoState, + DownloadBehaviorAllowed, + DownloadBehaviorDenied, +) +from selenium.webdriver.common._bidi.browsing_context import BrowsingContext, CreateType, ReadinessState +from selenium.webdriver.common._bidi.session import ( + DirectProxyConfiguration, + ManualProxyConfiguration, + UserPromptHandler, + UserPromptHandlerType, +) +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait + + +def test_create_user_context(driver): + browser = Browser(driver) + result = browser.create_user_context() + assert result.user_context + browser.remove_user_context(user_context=result.user_context) + + +def test_get_user_contexts(driver): + browser = Browser(driver) + first = browser.create_user_context().user_context + second = browser.create_user_context().user_context + + ids = [info.user_context for info in browser.get_user_contexts().user_contexts] + assert first in ids + assert second in ids + + browser.remove_user_context(user_context=first) + browser.remove_user_context(user_context=second) + + +def test_remove_user_context(driver): + browser = Browser(driver) + first = browser.create_user_context().user_context + second = browser.create_user_context().user_context + + browser.remove_user_context(user_context=second) + + ids = [info.user_context for info in browser.get_user_contexts().user_contexts] + assert first in ids + assert second not in ids + + browser.remove_user_context(user_context=first) + + +def test_get_client_windows(driver): + result = Browser(driver).get_client_windows() + + assert result.client_windows + window = result.client_windows[0] + assert isinstance(window, ClientWindowInfo) + assert window.client_window + assert isinstance(window.state, str) + assert window.width > 0 + assert window.height > 0 + assert isinstance(window.active, bool) + assert isinstance(window.x, int) + assert isinstance(window.y, int) + + +def test_removing_default_user_context_raises(driver): + with pytest.raises(WebDriverException): + Browser(driver).remove_user_context(user_context="default") + + +def test_client_window_state_constants(): + assert ClientWindowInfoState.FULLSCREEN == "fullscreen" + assert ClientWindowInfoState.MAXIMIZED == "maximized" + assert ClientWindowInfoState.MINIMIZED == "minimized" + assert ClientWindowInfoState.NORMAL == "normal" + + +def test_create_user_context_with_accept_insecure_certs(driver): + browser = Browser(driver) + user_context = browser.create_user_context(accept_insecure_certs=True).user_context + + bc = BrowsingContext(driver).create(type=CreateType.WINDOW, user_context=user_context).context + driver.switch_to.window(bc) + + driver.get("https://self-signed.badssl.com/") + h1 = driver.find_element(By.TAG_NAME, "h1") + assert h1.text.strip() == "self-signed.\nbadssl.com" + + browser.remove_user_context(user_context=user_context) + + +def test_create_user_context_with_direct_proxy(driver): + browser = Browser(driver) + user_context = browser.create_user_context(proxy=DirectProxyConfiguration()).user_context + + bc = BrowsingContext(driver).create(type=CreateType.WINDOW, user_context=user_context).context + driver.switch_to.window(bc) + + driver.get("http://example.com/") + assert "example domain" in driver.find_element(By.TAG_NAME, "body").text.lower() + + browser.remove_user_context(user_context=user_context) + + +def test_create_user_context_with_unhandled_prompt_behavior(driver): + browser = Browser(driver) + handler = UserPromptHandler( + alert=UserPromptHandlerType.DISMISS, + default=UserPromptHandlerType.DISMISS, + prompt=UserPromptHandlerType.DISMISS, + ) + user_context = browser.create_user_context(unhandled_prompt_behavior=handler).user_context + + ids = [info.user_context for info in browser.get_user_contexts().user_contexts] + assert user_context in ids + + browser.remove_user_context(user_context=user_context) + + +@pytest.mark.xfail_chrome(reason="Chrome auto upgrades HTTP to HTTPS in untrusted networks like CI environments") +def test_create_user_context_with_manual_proxy_all_params(driver, proxy_server): + create_proxy_server = proxy_server(response_content=b"proxied response") + no_proxy_server = proxy_server(response_content=b"direct connection - not proxied") + proxy_port = create_proxy_server["port"] + no_proxy_port = no_proxy_server["port"] + + proxy = ManualProxyConfiguration( + http_proxy=f"localhost:{proxy_port}", + ssl_proxy=f"localhost:{proxy_port}", + socks_proxy=f"localhost:{proxy_port}", + socks_version=5, + no_proxy=[f"localhost:{no_proxy_port}"], + ) + browser = Browser(driver) + user_context = browser.create_user_context(proxy=proxy).user_context + + bc = BrowsingContext(driver).create(type=CreateType.WINDOW, user_context=user_context).context + driver.switch_to.window(bc) + + try: + driver.get(f"http://localhost:{no_proxy_port}/") + assert "direct connection - not proxied" in driver.find_element(By.TAG_NAME, "body").text.lower() + + driver.get("http://example.com/") + assert "proxied response" in driver.find_element(By.TAG_NAME, "body").text.lower() + finally: + browser.remove_user_context(user_context=user_context) + + +@pytest.mark.xfail_chrome(reason="Chrome auto upgrades HTTP to HTTPS in untrusted networks like CI environments") +def test_create_user_context_with_proxy_and_accept_insecure_certs(driver, proxy_server): + create_proxy_server = proxy_server(response_content=b"proxied response") + port = create_proxy_server["port"] + + proxy = ManualProxyConfiguration( + http_proxy=f"localhost:{port}", + ssl_proxy=f"localhost:{port}", + socks_proxy=f"localhost:{port}", + socks_version=5, + no_proxy=["self-signed.badssl.com"], + ) + browser = Browser(driver) + user_context = browser.create_user_context(accept_insecure_certs=True, proxy=proxy).user_context + + bc = BrowsingContext(driver).create(type=CreateType.WINDOW, user_context=user_context).context + driver.switch_to.window(bc) + + try: + driver.get("https://self-signed.badssl.com/") + assert "badssl.com" in driver.find_element(By.TAG_NAME, "h1").text.lower() + + driver.get("http://example.com/") + assert "proxied response" in driver.find_element(By.TAG_NAME, "body").text.lower() + finally: + browser.remove_user_context(user_context=user_context) + + +# The facade's ``set_download_behavior(allowed=..., destination_folder=...)`` builds a +# DownloadBehavior union; the generated command takes the typed variant directly. + + +@pytest.mark.xfail_firefox +def test_set_download_behavior_allowed(driver, pages, tmp_path): + browser = Browser(driver) + try: + browser.set_download_behavior(download_behavior=DownloadBehaviorAllowed(destination_folder=str(tmp_path))) + + context_id = driver.current_window_handle + BrowsingContext(driver).navigate( + context=context_id, url=pages.url("downloads/download.html"), wait=ReadinessState.COMPLETE + ) + driver.find_element(By.ID, "file-1").click() + + WebDriverWait(driver, 5).until(lambda d: "file_1.txt" in os.listdir(tmp_path)) + assert "file_1.txt" in os.listdir(tmp_path) + finally: + browser.set_download_behavior(download_behavior=None) + + +@pytest.mark.xfail_firefox +def test_set_download_behavior_denied(driver, pages, tmp_path): + browser = Browser(driver) + try: + browser.set_download_behavior(download_behavior=DownloadBehaviorDenied()) + + context_id = driver.current_window_handle + BrowsingContext(driver).navigate( + context=context_id, url=pages.url("downloads/download.html"), wait=ReadinessState.COMPLETE + ) + driver.find_element(By.ID, "file-1").click() + + with pytest.raises(TimeoutException): + WebDriverWait(driver, 3, poll_frequency=0.2).until(lambda _: len(os.listdir(tmp_path)) > 0) + finally: + browser.set_download_behavior(download_behavior=None) + + +@pytest.mark.xfail_firefox +def test_set_download_behavior_user_context(driver, pages, tmp_path): + browser = Browser(driver) + user_context = browser.create_user_context().user_context + try: + bc = BrowsingContext(driver).create(type=CreateType.WINDOW, user_context=user_context).context + driver.switch_to.window(bc) + try: + browser.set_download_behavior( + download_behavior=DownloadBehaviorAllowed(destination_folder=str(tmp_path)), + user_contexts=[user_context], + ) + BrowsingContext(driver).navigate( + context=bc, url=pages.url("downloads/download.html"), wait=ReadinessState.COMPLETE + ) + driver.find_element(By.ID, "file-1").click() + + WebDriverWait(driver, 5).until(lambda d: "file_1.txt" in os.listdir(tmp_path)) + initial_file_count = len(os.listdir(tmp_path)) + + browser.set_download_behavior(download_behavior=DownloadBehaviorDenied(), user_contexts=[user_context]) + driver.find_element(By.ID, "file-2").click() + + with pytest.raises(TimeoutException): + WebDriverWait(driver, 3, poll_frequency=0.2).until( + lambda _: len(os.listdir(tmp_path)) > initial_file_count + ) + finally: + browser.set_download_behavior(download_behavior=None, user_contexts=[user_context]) + finally: + browser.remove_user_context(user_context=user_context) diff --git a/py/test/selenium/webdriver/common/_bidi/browsing_context_tests.py b/py/test/selenium/webdriver/common/_bidi/browsing_context_tests.py new file mode 100644 index 0000000000000..e7e88dadf68ee --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/browsing_context_tests.py @@ -0,0 +1,555 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/browsing_context_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +import base64 + +import pytest + +from selenium.webdriver.common._bidi.browser import Browser +from selenium.webdriver.common._bidi.browsing_context import ( + BoxClipRectangle, + BrowsingContext, + CaptureScreenshotParametersOrigin, + CreateType, + CssLocator, + InnerTextLocator, + ReadinessState, + Viewport, + XPathLocator, +) +from selenium.webdriver.common._bidi.script import SharedReference +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + + +def create_alert_page(driver, pages): + url = pages.url("alerts.html") + driver.get(url) + return url + + +def create_prompt_page(driver, pages): + url = pages.url("javascriptPage.html") + driver.get(url) + return url + + +def test_create_window(driver): + bc = BrowsingContext(driver) + context_id = bc.create(type=CreateType.WINDOW).context + assert context_id is not None + + bc.close(context=context_id) + + +def test_create_window_with_reference_context(driver): + bc = BrowsingContext(driver) + reference_context = driver.current_window_handle + context_id = bc.create(type=CreateType.WINDOW, reference_context=reference_context).context + assert context_id is not None + + bc.close(context=context_id) + + +def test_create_tab(driver): + bc = BrowsingContext(driver) + context_id = bc.create(type=CreateType.TAB).context + assert context_id is not None + + bc.close(context=context_id) + + +def test_create_tab_with_reference_context(driver): + bc = BrowsingContext(driver) + reference_context = driver.current_window_handle + context_id = bc.create(type=CreateType.TAB, reference_context=reference_context).context + assert context_id is not None + + bc.close(context=context_id) + + +def test_create_context_with_all_parameters(driver): + bc = BrowsingContext(driver) + reference_context = driver.current_window_handle + user_context = Browser(driver).create_user_context().user_context + + context_id = bc.create( + type=CreateType.WINDOW, + reference_context=reference_context, + user_context=user_context, + background=True, + ).context + assert context_id is not None + assert context_id != reference_context + + bc.close(context=context_id) + Browser(driver).remove_user_context(user_context=user_context) + + +def test_navigate_to_url(driver, pages): + bc = BrowsingContext(driver) + context_id = bc.create(type=CreateType.TAB).context + + url = pages.url("bidi/logEntryAdded.html") + result = bc.navigate(context=context_id, url=url) + + assert "/bidi/logEntryAdded.html" in result.url + + bc.close(context=context_id) + + +def test_navigate_to_url_with_readiness_state(driver, pages): + bc = BrowsingContext(driver) + context_id = bc.create(type=CreateType.TAB).context + + url = pages.url("bidi/logEntryAdded.html") + result = bc.navigate(context=context_id, url=url, wait=ReadinessState.COMPLETE) + + assert "/bidi/logEntryAdded.html" in result.url + + bc.close(context=context_id) + + +def test_get_tree_with_child(driver, pages): + bc = BrowsingContext(driver) + reference_context = driver.current_window_handle + + url = pages.url("iframes.html") + bc.navigate(context=reference_context, url=url, wait=ReadinessState.COMPLETE) + + contexts = bc.get_tree(root=reference_context).contexts + + assert len(contexts) == 1 + info = contexts[0] + assert len(info.children) == 1 + assert info.context == reference_context + assert "formPage.html" in info.children[0].url + + +def test_get_tree_with_depth(driver, pages): + bc = BrowsingContext(driver) + reference_context = driver.current_window_handle + + url = pages.url("iframes.html") + bc.navigate(context=reference_context, url=url, wait=ReadinessState.COMPLETE) + + contexts = bc.get_tree(root=reference_context, max_depth=0).contexts + + assert len(contexts) == 1 + info = contexts[0] + assert info.children is None + assert info.context == reference_context + + +def test_get_all_top_level_contexts(driver): + bc = BrowsingContext(driver) + window_handle = bc.create(type=CreateType.WINDOW).context + + contexts = bc.get_tree().contexts + + assert len(contexts) == 2 + + bc.close(context=window_handle) + + +def test_close_window(driver): + bc = BrowsingContext(driver) + window1 = bc.create(type=CreateType.WINDOW).context + window2 = bc.create(type=CreateType.WINDOW).context + + bc.close(context=window2) + + with pytest.raises(Exception): + bc.get_tree(root=window2) + + bc.close(context=window1) + + +def test_close_tab(driver): + bc = BrowsingContext(driver) + tab1 = bc.create(type=CreateType.TAB).context + tab2 = bc.create(type=CreateType.TAB).context + + bc.close(context=tab2) + + with pytest.raises(Exception): + bc.get_tree(root=tab2) + + bc.close(context=tab1) + + +def test_activate_browsing_context(driver): + bc = BrowsingContext(driver) + window1 = driver.current_window_handle + window2 = bc.create(type=CreateType.WINDOW).context + + assert not driver.execute_script("return document.hasFocus();") + + bc.activate(context=window1) + + assert driver.execute_script("return document.hasFocus();") + + bc.close(context=window2) + + +def test_reload_browsing_context(driver, pages): + bc = BrowsingContext(driver) + context_id = bc.create(type=CreateType.TAB).context + + url = pages.url("bidi/logEntryAdded.html") + bc.navigate(context=context_id, url=url, wait=ReadinessState.COMPLETE) + + reload_info = bc.reload(context=context_id) + + assert "/bidi/logEntryAdded.html" in reload_info.url + + bc.close(context=context_id) + + +def test_reload_with_readiness_state(driver, pages): + bc = BrowsingContext(driver) + context_id = bc.create(type=CreateType.TAB).context + + url = pages.url("bidi/logEntryAdded.html") + bc.navigate(context=context_id, url=url, wait=ReadinessState.COMPLETE) + + reload_info = bc.reload(context=context_id, wait=ReadinessState.COMPLETE) + + assert reload_info.navigation is not None + assert "/bidi/logEntryAdded.html" in reload_info.url + + bc.close(context=context_id) + + +def test_handle_user_prompt(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + create_alert_page(driver, pages) + + driver.find_element(By.ID, "alert").click() + WebDriverWait(driver, 5).until(EC.alert_is_present()) + + bc.handle_user_prompt(context=context_id) + + assert "Alerts" in driver.title + + +def test_accept_user_prompt(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + create_alert_page(driver, pages) + + driver.find_element(By.ID, "alert").click() + WebDriverWait(driver, 5).until(EC.alert_is_present()) + + bc.handle_user_prompt(context=context_id, accept=True) + + assert "Alerts" in driver.title + + +def test_dismiss_user_prompt(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + create_alert_page(driver, pages) + + driver.find_element(By.ID, "alert").click() + WebDriverWait(driver, 5).until(EC.alert_is_present()) + + bc.handle_user_prompt(context=context_id, accept=False) + + assert "Alerts" in driver.title + + +def test_pass_user_text_to_prompt(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + create_prompt_page(driver, pages) + + driver.execute_script("prompt('Enter something')") + WebDriverWait(driver, 5).until(EC.alert_is_present()) + + user_text = "Selenium automates browsers" + + bc.handle_user_prompt(context=context_id, user_text=user_text) + + +def test_capture_screenshot(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + driver.get(pages.url("simpleTest.html")) + + screenshot = bc.capture_screenshot(context=context_id).data + + try: + base64.b64decode(screenshot) + is_valid = True + except Exception: + is_valid = False + + assert is_valid + assert len(screenshot) > 0 + + +def test_capture_screenshot_with_parameters(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + driver.get(pages.url("coordinates_tests/simple_page.html")) + element = driver.find_element(By.ID, "box") + + rect = element.rect + + clip = BoxClipRectangle(x=rect["x"], y=rect["y"], width=5, height=5) + + screenshot = bc.capture_screenshot( + context=context_id, + origin=CaptureScreenshotParametersOrigin.DOCUMENT, + clip=clip, + ).data + + assert len(screenshot) > 0 + + +def test_set_viewport(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + driver.get(pages.url("formPage.html")) + + try: + bc.set_viewport(context=context_id, viewport=Viewport(width=251, height=301)) + + viewport_size = driver.execute_script("return [window.innerWidth, window.innerHeight];") + + assert viewport_size[0] == 251 + assert viewport_size[1] == 301 + finally: + bc.set_viewport(context=context_id, viewport=None, device_pixel_ratio=None) + + +def test_set_viewport_with_device_pixel_ratio(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + driver.get(pages.url("formPage.html")) + + try: + bc.set_viewport(context=context_id, viewport=Viewport(width=252, height=302), device_pixel_ratio=5) + + viewport_size = driver.execute_script("return [window.innerWidth, window.innerHeight];") + + assert viewport_size[0] == 252 + assert viewport_size[1] == 302 + + device_pixel_ratio = driver.execute_script("return window.devicePixelRatio") + + assert device_pixel_ratio == 5 + finally: + bc.set_viewport(context=context_id, viewport=None, device_pixel_ratio=None) + + +def test_set_viewport_with_no_args_doesnt_change_values(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + driver.get(pages.url("formPage.html")) + + try: + bc.set_viewport(context=context_id, viewport=Viewport(width=253, height=303), device_pixel_ratio=6) + + bc.set_viewport(context=context_id) + + viewport_size = driver.execute_script("return [window.innerWidth, window.innerHeight];") + + assert viewport_size[0] == 253 + assert viewport_size[1] == 303 + + device_pixel_ratio = driver.execute_script("return window.devicePixelRatio") + + assert device_pixel_ratio == 6 + finally: + bc.set_viewport(context=context_id, viewport=None, device_pixel_ratio=None) + + +@pytest.mark.xfail_chrome +def test_set_viewport_back_to_default(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + driver.get(pages.url("formPage.html")) + + default_viewport_size = driver.execute_script("return [window.innerWidth, window.innerHeight];") + default_device_pixel_ratio = driver.execute_script("return window.devicePixelRatio") + + try: + bc.set_viewport(context=context_id, viewport=Viewport(width=254, height=304), device_pixel_ratio=10) + + bc.set_viewport(context=context_id, viewport=None, device_pixel_ratio=None) + + viewport_size = driver.execute_script("return [window.innerWidth, window.innerHeight];") + device_pixel_ratio = driver.execute_script("return window.devicePixelRatio") + + assert abs(viewport_size[0] - default_viewport_size[0]) <= 5 + assert abs(viewport_size[1] - default_viewport_size[1]) <= 5 + assert device_pixel_ratio == default_device_pixel_ratio + finally: + bc.set_viewport(context=context_id, viewport=None, device_pixel_ratio=None) + + +def test_print_page(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + driver.get(pages.url("formPage.html")) + + print_result = bc.print(context=context_id).data + + assert len(print_result) > 0 + # Valid PDF starts with JVBERi (base64 of %PDF) + assert "JVBERi" in print_result + + +def test_navigate_back_in_browser_history(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + bc.navigate(context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE) + + driver.find_element(By.ID, "imageButton").submit() + WebDriverWait(driver, 5).until(EC.title_is("We Arrive Here")) + + bc.traverse_history(context=context_id, delta=-1) + WebDriverWait(driver, 5).until(EC.title_is("We Leave From Here")) + + +def test_navigate_forward_in_browser_history(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + bc.navigate(context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE) + + driver.find_element(By.ID, "imageButton").submit() + WebDriverWait(driver, 5).until(EC.title_is("We Arrive Here")) + + bc.traverse_history(context=context_id, delta=-1) + WebDriverWait(driver, 5).until(EC.title_is("We Leave From Here")) + + bc.traverse_history(context=context_id, delta=1) + WebDriverWait(driver, 5).until(EC.title_is("We Arrive Here")) + + +def test_locate_nodes(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + driver.get(pages.url("xhtmlTest.html")) + + nodes = bc.locate_nodes(context=context_id, locator=CssLocator(value="div")).nodes + + assert len(nodes) > 0 + + +def test_locate_nodes_with_css_locator(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + driver.get(pages.url("xhtmlTest.html")) + + nodes = bc.locate_nodes( + context=context_id, + locator=CssLocator(value="div.extraDiv, div.content"), + max_node_count=1, + ).nodes + + assert len(nodes) >= 1 + + node = nodes[0] + assert node.type == "node" + assert node.value.local_name == "div" + assert node.value.attributes["class"] == "content" + + +def test_locate_nodes_with_xpath_locator(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + driver.get(pages.url("xhtmlTest.html")) + + nodes = bc.locate_nodes( + context=context_id, + locator=XPathLocator(value="/html/body/div[2]"), + max_node_count=1, + ).nodes + + assert len(nodes) >= 1 + + node = nodes[0] + assert node.type == "node" + assert node.value.local_name == "div" + assert node.value.attributes["class"] == "content" + + +@pytest.mark.xfail_firefox +def test_locate_nodes_with_inner_text(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + driver.get(pages.url("xhtmlTest.html")) + + nodes = bc.locate_nodes( + context=context_id, + locator=InnerTextLocator(value="Spaced out"), + max_node_count=1, + ).nodes + + assert len(nodes) >= 1 + assert nodes[0].type == "node" + + +def test_locate_nodes_with_max_node_count(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + driver.get(pages.url("xhtmlTest.html")) + + nodes = bc.locate_nodes(context=context_id, locator=CssLocator(value="div"), max_node_count=4).nodes + + assert len(nodes) == 4 + + +def test_locate_nodes_given_start_nodes(driver, pages): + bc = BrowsingContext(driver) + context_id = driver.current_window_handle + + driver.get(pages.url("formPage.html")) + + form_nodes = bc.locate_nodes(context=context_id, locator=CssLocator(value="form[name='login']")).nodes + + assert len(form_nodes) == 1 + + form_shared_id = form_nodes[0].shared_id + + nodes = bc.locate_nodes( + context=context_id, + locator=CssLocator(value="input"), + start_nodes=[SharedReference(shared_id=form_shared_id)], + max_node_count=50, + ).nodes + # The login form has 3 input elements (email, age, and submit button) + assert len(nodes) == 3 diff --git a/py/test/selenium/webdriver/common/_bidi/emulation_tests.py b/py/test/selenium/webdriver/common/_bidi/emulation_tests.py new file mode 100644 index 0000000000000..c18e327eeb00f --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/emulation_tests.py @@ -0,0 +1,527 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/emulation_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +import pytest + +from selenium.webdriver.common._bidi.browser import Browser +from selenium.webdriver.common._bidi.browsing_context import BrowsingContext, CreateType, ReadinessState +from selenium.webdriver.common._bidi.emulation import ( + Emulation, + GeolocationCoordinates, + GeolocationPositionError, + NetworkConditionsOffline, + ScreenArea, + ScreenOrientation, + ScreenOrientationNatural, + ScreenOrientationType, +) +from selenium.webdriver.common._bidi.permissions import PermissionDescriptor, Permissions, PermissionState +from selenium.webdriver.common._bidi.script import ContextTarget, Script + + +def _eval(driver, expression, context_id): + return Script(driver).evaluate(expression, ContextTarget(context=context_id), False).result.value + + +def get_browser_timezone_string(driver): + return _eval(driver, "Intl.DateTimeFormat().resolvedOptions().timeZone", driver.current_window_handle) + + +def get_browser_timezone_offset(driver): + return _eval(driver, "new Date().getTimezoneOffset()", driver.current_window_handle) + + +def get_browser_geolocation(driver, user_context=None): + origin = driver.execute_script("return window.location.origin;") + extra = {"user_context": user_context} if user_context else {} + Permissions(driver).set_permission( + descriptor=PermissionDescriptor(name="geolocation"), state=PermissionState.GRANTED, origin=origin, **extra + ) + + return driver.execute_async_script(""" + const callback = arguments[arguments.length - 1]; + navigator.geolocation.getCurrentPosition( + position => { + const coords = position.coords; + callback({ + latitude: coords.latitude, + longitude: coords.longitude, + accuracy: coords.accuracy, + altitude: coords.altitude, + altitudeAccuracy: coords.altitudeAccuracy, + heading: coords.heading, + speed: coords.speed, + timestamp: position.timestamp + }); + }, + error => { + callback({ error: error.message }); + } + ); + """) + + +def get_browser_locale(driver): + return _eval(driver, "Intl.DateTimeFormat().resolvedOptions().locale", driver.current_window_handle) + + +def get_screen_orientation(driver, context_id): + return { + "type": _eval(driver, "screen.orientation.type", context_id), + "angle": _eval(driver, "screen.orientation.angle", context_id), + } + + +def get_browser_user_agent(driver): + return _eval(driver, "navigator.userAgent", driver.current_window_handle) + + +def is_online(driver, context_id): + return _eval(driver, "navigator.onLine", context_id) + + +def get_screen_dimensions(driver, context_id): + return { + "width": _eval(driver, "screen.width", context_id), + "height": _eval(driver, "screen.height", context_id), + } + + +def test_set_geolocation_override_with_coordinates_in_context(driver, pages): + context_id = driver.current_window_handle + pages.load("blank.html") + coords = GeolocationCoordinates(45.5, -122.4194, accuracy=10.0) + + Emulation(driver).set_geolocation_override(coordinates=coords, contexts=[context_id]) + + result = get_browser_geolocation(driver) + assert "error" not in result, f"Geolocation error: {result.get('error')}" + assert abs(result["latitude"] - coords.latitude) < 0.0001 + assert abs(result["longitude"] - coords.longitude) < 0.0001 + assert abs(result["accuracy"] - coords.accuracy) < 1.0 + + +def test_set_geolocation_override_with_coordinates_in_user_context(driver, pages): + user_context = Browser(driver).create_user_context().user_context + context_id = BrowsingContext(driver).create(type=CreateType.TAB, user_context=user_context).context + + driver.switch_to.window(context_id) + pages.load("blank.html") + coords = GeolocationCoordinates(45.5, -122.4194, accuracy=10.0) + + Emulation(driver).set_geolocation_override(coordinates=coords, user_contexts=[user_context]) + + result = get_browser_geolocation(driver, user_context=user_context) + assert "error" not in result, f"Geolocation error: {result.get('error')}" + assert abs(result["latitude"] - coords.latitude) < 0.0001 + assert abs(result["longitude"] - coords.longitude) < 0.0001 + assert abs(result["accuracy"] - coords.accuracy) < 1.0 + + BrowsingContext(driver).close(context=context_id) + Browser(driver).remove_user_context(user_context=user_context) + + +def test_set_geolocation_override_all_coords(driver, pages): + context_id = driver.current_window_handle + pages.load("blank.html") + coords = GeolocationCoordinates( + 45.5, -122.4194, accuracy=10.0, altitude=100.2, altitude_accuracy=5.0, heading=183.2, speed=10.0 + ) + + Emulation(driver).set_geolocation_override(coordinates=coords, contexts=[context_id]) + + result = get_browser_geolocation(driver) + assert "error" not in result + assert abs(result["latitude"] - coords.latitude) < 0.0001 + assert abs(result["longitude"] - coords.longitude) < 0.0001 + assert abs(result["accuracy"] - coords.accuracy) < 1.0 + assert abs(result["altitude"] - coords.altitude) < 0.0001 + assert abs(result["altitudeAccuracy"] - coords.altitude_accuracy) < 0.1 + assert abs(result["heading"] - coords.heading) < 0.1 + assert abs(result["speed"] - coords.speed) < 0.1 + + BrowsingContext(driver).close(context=context_id) + + +def test_set_geolocation_override_with_multiple_contexts(driver, pages): + bc = BrowsingContext(driver) + context1_id = bc.create(type=CreateType.TAB).context + context2_id = bc.create(type=CreateType.TAB).context + + coords = GeolocationCoordinates(45.5, -122.4194, accuracy=10.0) + Emulation(driver).set_geolocation_override(coordinates=coords, contexts=[context1_id, context2_id]) + + for context_id in (context1_id, context2_id): + driver.switch_to.window(context_id) + pages.load("blank.html") + result = get_browser_geolocation(driver) + assert "error" not in result + assert abs(result["latitude"] - coords.latitude) < 0.0001 + assert abs(result["longitude"] - coords.longitude) < 0.0001 + assert abs(result["accuracy"] - coords.accuracy) < 1.0 + + bc.close(context=context1_id) + bc.close(context=context2_id) + + +def test_set_geolocation_override_with_multiple_user_contexts(driver, pages): + browser = Browser(driver) + user_context1 = browser.create_user_context().user_context + user_context2 = browser.create_user_context().user_context + + bc = BrowsingContext(driver) + context1_id = bc.create(type=CreateType.TAB, user_context=user_context1).context + context2_id = bc.create(type=CreateType.TAB, user_context=user_context2).context + + coords = GeolocationCoordinates(45.5, -122.4194, accuracy=10.0) + Emulation(driver).set_geolocation_override(coordinates=coords, user_contexts=[user_context1, user_context2]) + + for context_id, user_context in ((context1_id, user_context1), (context2_id, user_context2)): + driver.switch_to.window(context_id) + pages.load("blank.html") + result = get_browser_geolocation(driver, user_context=user_context) + assert "error" not in result + assert abs(result["latitude"] - coords.latitude) < 0.0001 + assert abs(result["longitude"] - coords.longitude) < 0.0001 + assert abs(result["accuracy"] - coords.accuracy) < 1.0 + + bc.close(context=context1_id) + bc.close(context=context2_id) + browser.remove_user_context(user_context=user_context1) + browser.remove_user_context(user_context=user_context2) + + +@pytest.mark.xfail_firefox +def test_set_geolocation_override_with_error(driver, pages): + context_id = driver.current_window_handle + pages.load("blank.html") + + Emulation(driver).set_geolocation_override(error=GeolocationPositionError(), contexts=[context_id]) + + result = get_browser_geolocation(driver) + assert "error" in result + + +def test_set_timezone_override_with_context(driver, pages): + context_id = driver.current_window_handle + pages.load("blank.html") + initial_timezone_string = get_browser_timezone_string(driver) + + Emulation(driver).set_timezone_override(timezone="Asia/Tokyo", contexts=[context_id]) + + assert get_browser_timezone_offset(driver) == -540 + assert get_browser_timezone_string(driver) == "Asia/Tokyo" + + Emulation(driver).set_timezone_override(timezone=None, contexts=[context_id]) + assert get_browser_timezone_string(driver) == initial_timezone_string + + +def test_set_timezone_override_with_user_context(driver, pages): + user_context = Browser(driver).create_user_context().user_context + context_id = BrowsingContext(driver).create(type=CreateType.TAB, user_context=user_context).context + + driver.switch_to.window(context_id) + pages.load("blank.html") + + Emulation(driver).set_timezone_override(timezone="America/New_York", user_contexts=[user_context]) + assert get_browser_timezone_string(driver) == "America/New_York" + + Emulation(driver).set_timezone_override(timezone=None, user_contexts=[user_context]) + + BrowsingContext(driver).close(context=context_id) + Browser(driver).remove_user_context(user_context=user_context) + + +@pytest.mark.xfail_firefox(reason="Firefox returns UTC as timezone string in case of offset.") +def test_set_timezone_override_using_offset(driver, pages): + context_id = driver.current_window_handle + pages.load("blank.html") + + Emulation(driver).set_timezone_override(timezone="+05:30", contexts=[context_id]) + + assert get_browser_timezone_offset(driver) == -330 + assert get_browser_timezone_string(driver) == "+05:30" + + Emulation(driver).set_timezone_override(timezone=None, contexts=[context_id]) + + +@pytest.mark.parametrize( + ("locale", "expected_locale"), + [ + ("de-DE-u-co-phonebk", "de-DE"), + ("fr-ca", "fr-CA"), + ("FR-CA", "fr-CA"), + ("fR-cA", "fr-CA"), + ("en-t-zh", "en"), + ], +) +def test_set_locale_override_with_contexts(driver, pages, locale, expected_locale): + context_id = driver.current_window_handle + + Emulation(driver).set_locale_override(locale=locale, contexts=[context_id]) + BrowsingContext(driver).navigate(context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE) + + assert get_browser_locale(driver) == expected_locale + + +@pytest.mark.parametrize("value", ["en", "en-US", "sr-Latn", "zh-Hans-CN"]) +def test_set_locale_override_with_user_contexts(driver, pages, value): + user_context = Browser(driver).create_user_context().user_context + try: + context_id = BrowsingContext(driver).create(type=CreateType.TAB, user_context=user_context).context + try: + driver.switch_to.window(context_id) + Emulation(driver).set_locale_override(locale=value, user_contexts=[user_context]) + BrowsingContext(driver).navigate( + context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE + ) + assert get_browser_locale(driver) == value + finally: + BrowsingContext(driver).close(context=context_id) + finally: + Browser(driver).remove_user_context(user_context=user_context) + + +@pytest.mark.xfail_firefox(reason="Not yet supported") +def test_set_scripting_enabled_with_contexts(driver, pages): + context_id = driver.current_window_handle + emulation = Emulation(driver) + bc = BrowsingContext(driver) + + emulation.set_scripting_enabled(enabled=False, contexts=[context_id]) + bc.navigate(context=context_id, url="data:text/html,", wait=ReadinessState.COMPLETE) + assert _eval(driver, "'foo' in window", context_id) is False + + emulation.set_scripting_enabled(enabled=None, contexts=[context_id]) + bc.navigate(context=context_id, url="data:text/html,", wait=ReadinessState.COMPLETE) + assert _eval(driver, "'foo' in window", context_id) is True + + +@pytest.mark.xfail_firefox(reason="Not yet supported") +def test_set_scripting_enabled_with_user_contexts(driver, pages): + user_context = Browser(driver).create_user_context().user_context + try: + context_id = BrowsingContext(driver).create(type=CreateType.TAB, user_context=user_context).context + try: + driver.switch_to.window(context_id) + Emulation(driver).set_scripting_enabled(enabled=False, user_contexts=[user_context]) + + url = pages.url("javascriptPage.html") + BrowsingContext(driver).navigate(context=context_id, url=url, wait=ReadinessState.COMPLETE) + + click_field = driver.find_element("id", "clickField") + initial_value = click_field.get_attribute("value") + click_field.click() + assert _eval(driver, "document.getElementById('clickField').value", context_id) == initial_value + + Emulation(driver).set_scripting_enabled(enabled=None, user_contexts=[user_context]) + BrowsingContext(driver).navigate(context=context_id, url=url, wait=ReadinessState.COMPLETE) + driver.find_element("id", "clickField").click() + assert _eval(driver, "document.getElementById('clickField').value", context_id) == "Clicked" + finally: + BrowsingContext(driver).close(context=context_id) + finally: + Browser(driver).remove_user_context(user_context=user_context) + + +def test_set_screen_orientation_override_with_contexts(driver, pages): + context_id = driver.current_window_handle + initial_orientation = get_screen_orientation(driver, context_id) + + orientation = ScreenOrientation( + natural=ScreenOrientationNatural.LANDSCAPE, type=ScreenOrientationType.LANDSCAPE_PRIMARY + ) + Emulation(driver).set_screen_orientation_override(screen_orientation=orientation, contexts=[context_id]) + BrowsingContext(driver).navigate(context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE) + + current = get_screen_orientation(driver, context_id) + assert current["type"] == "landscape-primary" + assert current["angle"] == 0 + + orientation = ScreenOrientation( + natural=ScreenOrientationNatural.PORTRAIT, type=ScreenOrientationType.PORTRAIT_SECONDARY + ) + Emulation(driver).set_screen_orientation_override(screen_orientation=orientation, contexts=[context_id]) + current = get_screen_orientation(driver, context_id) + assert current["type"] == "portrait-secondary" + assert current["angle"] == 180 + + Emulation(driver).set_screen_orientation_override(screen_orientation=None, contexts=[context_id]) + assert get_screen_orientation(driver, context_id) == initial_orientation + + +@pytest.mark.parametrize( + ("natural", "orientation_type", "expected_angle"), + [ + ("Portrait", "portrait-primary", 0), + ("portrait", "portrait-secondary", 180), + ("portrait", "landscape-primary", 90), + ("portrait", "landscape-secondary", 270), + ("Landscape", "Portrait-Primary", 90), + ("landscape", "portrait-secondary", 270), + ("landscape", "landscape-primary", 0), + ("landscape", "landscape-secondary", 180), + ], +) +def test_set_screen_orientation_override_with_user_contexts(driver, pages, natural, orientation_type, expected_angle): + user_context = Browser(driver).create_user_context().user_context + try: + context_id = BrowsingContext(driver).create(type=CreateType.TAB, user_context=user_context).context + try: + driver.switch_to.window(context_id) + # ScreenOrientation validates its enum fields on serialize, so normalize the mixed-case strings. + orientation = ScreenOrientation( + natural=ScreenOrientationNatural(natural.lower()), type=ScreenOrientationType(orientation_type.lower()) + ) + Emulation(driver).set_screen_orientation_override( + screen_orientation=orientation, user_contexts=[user_context] + ) + BrowsingContext(driver).navigate( + context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE + ) + + current = get_screen_orientation(driver, context_id) + assert current["type"] == orientation_type.lower() + assert current["angle"] == expected_angle + + Emulation(driver).set_screen_orientation_override(screen_orientation=None, user_contexts=[user_context]) + finally: + BrowsingContext(driver).close(context=context_id) + finally: + Browser(driver).remove_user_context(user_context=user_context) + + +def test_set_user_agent_override_with_contexts(driver, pages): + context_id = driver.current_window_handle + BrowsingContext(driver).navigate(context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE) + initial_user_agent = get_browser_user_agent(driver) + + custom_user_agent = "Mozilla/5.0 (Custom Test Agent)" + Emulation(driver).set_user_agent_override(user_agent=custom_user_agent, contexts=[context_id]) + assert get_browser_user_agent(driver) == custom_user_agent + + Emulation(driver).set_user_agent_override(user_agent=None, contexts=[context_id]) + assert get_browser_user_agent(driver) == initial_user_agent + + +def test_set_user_agent_override_with_user_contexts(driver, pages): + user_context = Browser(driver).create_user_context().user_context + try: + context_id = BrowsingContext(driver).create(type=CreateType.TAB, user_context=user_context).context + try: + driver.switch_to.window(context_id) + BrowsingContext(driver).navigate( + context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE + ) + initial_user_agent = get_browser_user_agent(driver) + + custom_user_agent = "Mozilla/5.0 (Custom User Context Agent)" + Emulation(driver).set_user_agent_override(user_agent=custom_user_agent, user_contexts=[user_context]) + assert get_browser_user_agent(driver) == custom_user_agent + + Emulation(driver).set_user_agent_override(user_agent=None, user_contexts=[user_context]) + assert get_browser_user_agent(driver) == initial_user_agent + finally: + BrowsingContext(driver).close(context=context_id) + finally: + Browser(driver).remove_user_context(user_context=user_context) + + +@pytest.mark.xfail_firefox +def test_set_network_conditions_offline_with_context(driver, pages): + context_id = driver.current_window_handle + BrowsingContext(driver).navigate(context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE) + + assert is_online(driver, context_id) is True + try: + Emulation(driver).set_network_conditions(network_conditions=NetworkConditionsOffline(), contexts=[context_id]) + assert is_online(driver, context_id) is False + finally: + Emulation(driver).set_network_conditions(network_conditions=None, contexts=[context_id]) + assert is_online(driver, context_id) is True + + +@pytest.mark.xfail_firefox +def test_set_network_conditions_offline_with_user_context(driver, pages): + user_context = Browser(driver).create_user_context().user_context + try: + context_id = BrowsingContext(driver).create(type=CreateType.TAB, user_context=user_context).context + try: + driver.switch_to.window(context_id) + BrowsingContext(driver).navigate( + context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE + ) + assert is_online(driver, context_id) is True + + Emulation(driver).set_network_conditions( + network_conditions=NetworkConditionsOffline(), user_contexts=[user_context] + ) + assert is_online(driver, context_id) is False + finally: + Emulation(driver).set_network_conditions(network_conditions=None, user_contexts=[user_context]) + BrowsingContext(driver).close(context=context_id) + finally: + Browser(driver).remove_user_context(user_context=user_context) + + +@pytest.mark.xfail_chrome +@pytest.mark.xfail_edge +def test_set_screen_settings_override_with_contexts(driver, pages): + context_id = driver.current_window_handle + BrowsingContext(driver).navigate(context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE) + initial_dimensions = get_screen_dimensions(driver, context_id) + + try: + Emulation(driver).set_screen_settings_override( + screen_area=ScreenArea(width=1024, height=768), contexts=[context_id] + ) + current = get_screen_dimensions(driver, context_id) + assert current["width"] == 1024 + assert current["height"] == 768 + finally: + Emulation(driver).set_screen_settings_override(screen_area=None, contexts=[context_id]) + assert get_screen_dimensions(driver, context_id) == initial_dimensions + + +@pytest.mark.xfail_chrome +@pytest.mark.xfail_edge +def test_set_screen_settings_override_with_user_contexts(driver, pages): + user_context = Browser(driver).create_user_context().user_context + try: + context_id = BrowsingContext(driver).create(type=CreateType.TAB, user_context=user_context).context + try: + driver.switch_to.window(context_id) + BrowsingContext(driver).navigate( + context=context_id, url=pages.url("formPage.html"), wait=ReadinessState.COMPLETE + ) + initial_dimensions = get_screen_dimensions(driver, context_id) + + Emulation(driver).set_screen_settings_override( + screen_area=ScreenArea(width=800, height=600), user_contexts=[user_context] + ) + current = get_screen_dimensions(driver, context_id) + assert current["width"] == 800 + assert current["height"] == 600 + + Emulation(driver).set_screen_settings_override(screen_area=None, user_contexts=[user_context]) + assert get_screen_dimensions(driver, context_id) == initial_dimensions + finally: + BrowsingContext(driver).close(context=context_id) + finally: + Browser(driver).remove_user_context(user_context=user_context) diff --git a/py/test/selenium/webdriver/common/_bidi/errors_tests.py b/py/test/selenium/webdriver/common/_bidi/errors_tests.py new file mode 100644 index 0000000000000..70c4f98065c20 --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/errors_tests.py @@ -0,0 +1,121 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/errors_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +import pytest + +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.common._bidi.browser import Browser +from selenium.webdriver.common._bidi.browsing_context import BrowsingContext +from selenium.webdriver.common._bidi.emulation import Emulation, GeolocationCoordinates +from selenium.webdriver.common._bidi.input import Input +from selenium.webdriver.common._bidi.storage import Storage +from selenium.webdriver.common.by import By + + +def test_invalid_browsing_context_id(driver): + with pytest.raises(WebDriverException): + BrowsingContext(driver).close(context="invalid-context-id") + + +def test_invalid_navigation_url(driver): + with pytest.raises(WebDriverException): + BrowsingContext(driver).navigate(context="invalid-context-id", url="about:blank") + + +def test_invalid_geolocation_coordinates(driver): + with pytest.raises((WebDriverException, ValueError, TypeError)): + coords = GeolocationCoordinates(latitude=999, longitude=180, accuracy=10) + Emulation(driver).set_geolocation_override(coordinates=coords) + + +def test_invalid_timezone(driver): + with pytest.raises((WebDriverException, ValueError)): + Emulation(driver).set_timezone_override(timezone="Invalid/Timezone") + + +def test_invalid_set_cookie(driver, pages): + pages.load("blank.html") + with pytest.raises((WebDriverException, TypeError, AttributeError)): + Storage(driver).set_cookie(None) + + +def test_remove_nonexistent_context(driver): + with pytest.raises(WebDriverException): + Browser(driver).remove_user_context(user_context="non-existent-context-id") + + +def test_invalid_perform_actions_missing_context(driver, pages): + pages.load("blank.html") + with pytest.raises(TypeError): + Input(driver).perform_actions(actions=[]) + + +def test_error_recovery_after_invalid_navigation(driver): + with pytest.raises(WebDriverException): + BrowsingContext(driver).navigate(context="invalid-context", url="about:blank") + + driver.get("about:blank") + assert driver.find_element(By.TAG_NAME, "body") is not None + + +def test_multiple_error_conditions(driver, pages): + pages.load("blank.html") + + with pytest.raises(WebDriverException): + Browser(driver).remove_user_context(user_context="invalid") + + assert driver.find_element(By.TAG_NAME, "body") is not None + + with pytest.raises((WebDriverException, ValueError)): + Emulation(driver).set_timezone_override(timezone="Invalid") + + driver.get("about:blank") + + +class TestBidiErrorHandling: + @pytest.fixture(autouse=True) + def setup(self, driver, pages): + pages.load("blank.html") + + def test_error_on_invalid_context_operations(self, driver): + with pytest.raises(WebDriverException): + BrowsingContext(driver).close(context="nonexistent") + + def test_error_recovery_sequence(self, driver): + with pytest.raises(WebDriverException): + Browser(driver).remove_user_context(user_context="bad-id") + + assert driver.find_element(By.TAG_NAME, "body") is not None + + def test_consecutive_errors(self, driver): + errors_caught = 0 + + try: + Browser(driver).remove_user_context(user_context="id1") + except WebDriverException: + errors_caught += 1 + + try: + Browser(driver).remove_user_context(user_context="id2") + except WebDriverException: + errors_caught += 1 + + assert errors_caught == 2 + + driver.get("about:blank") diff --git a/py/test/selenium/webdriver/common/_bidi/input_tests.py b/py/test/selenium/webdriver/common/_bidi/input_tests.py new file mode 100644 index 0000000000000..900b7d338cc0d --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/input_tests.py @@ -0,0 +1,542 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/input_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +import os +import tempfile + +import pytest + +from selenium.webdriver.common._bidi.input import ( + ElementOrigin, + Input, + KeyDownAction, + KeySourceActions, + KeyUpAction, + NoneSourceActions, + PauseAction, + PointerDownAction, + PointerMoveAction, + PointerParameters, + PointerSourceActions, + PointerType, + PointerUpAction, + WheelScrollAction, + WheelSourceActions, +) +from selenium.webdriver.common._bidi.script import SharedReference +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait + + +def _center(element): + location = element.location + size = element.size + return location["x"] + size["width"] // 2, location["y"] + size["height"] // 2 + + +def test_basic_key_input(driver, pages): + pages.load("single_text_input.html") + input_element = driver.find_element(By.ID, "textInput") + + key_actions = KeySourceActions( + id="keyboard", + actions=[ + KeyDownAction(value="h"), + KeyUpAction(value="h"), + KeyDownAction(value="e"), + KeyUpAction(value="e"), + KeyDownAction(value="l"), + KeyUpAction(value="l"), + KeyDownAction(value="l"), + KeyUpAction(value="l"), + KeyDownAction(value="o"), + KeyUpAction(value="o"), + ], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[key_actions]) + + WebDriverWait(driver, 5).until(lambda d: input_element.get_attribute("value") == "hello") + + +def test_key_input_with_pause(driver, pages): + pages.load("single_text_input.html") + input_element = driver.find_element(By.ID, "textInput") + + key_actions = KeySourceActions( + id="keyboard", + actions=[ + KeyDownAction(value="a"), + KeyUpAction(value="a"), + PauseAction(duration=100), + KeyDownAction(value="b"), + KeyUpAction(value="b"), + ], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[key_actions]) + + WebDriverWait(driver, 5).until(lambda d: input_element.get_attribute("value") == "ab") + + +def test_pointer_click(driver, pages): + pages.load("javascriptPage.html") + button = driver.find_element(By.ID, "clickField") + x, y = _center(button) + + pointer_actions = PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[PointerMoveAction(x=x, y=y), PointerDownAction(button=0), PointerUpAction(button=0)], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[pointer_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + + +def test_pointer_move_with_element_origin(driver, pages): + pages.load("javascriptPage.html") + button = driver.find_element(By.ID, "clickField") + element_origin = ElementOrigin(element=SharedReference(shared_id=button.id)) + + pointer_actions = PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[ + PointerMoveAction(x=0, y=0, origin=element_origin), + PointerDownAction(button=0), + PointerUpAction(button=0), + ], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[pointer_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + + +def test_pointer_with_common_properties(driver, pages): + pages.load("javascriptPage.html") + button = driver.find_element(By.ID, "clickField") + x, y = _center(button) + + # Generated actions flatten PointerCommonProperties inline, so the common fields spread onto each action. + props = dict( + width=2, height=2, pressure=0.5, tangential_pressure=0.0, twist=45, altitude_angle=0.5, azimuth_angle=1.0 + ) + pointer_actions = PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[ + PointerMoveAction(x=x, y=y, **props), + PointerDownAction(button=0, **props), + PointerUpAction(button=0), + ], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[pointer_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + + +def test_wheel_scroll(driver, pages): + pages.load("scroll3.html") + wheel_actions = WheelSourceActions( + id="wheel", actions=[WheelScrollAction(x=100, y=100, delta_x=0, delta_y=100, origin="viewport")] + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[wheel_actions]) + + assert driver.execute_script("return window.pageYOffset;") == 100 + + +def test_combined_input_actions(driver, pages): + pages.load("single_text_input.html") + input_element = driver.find_element(By.ID, "textInput") + x, y = _center(input_element) + + pointer_actions = PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[ + PauseAction(duration=0), + PointerMoveAction(x=x, y=y), + PointerDownAction(button=0), + PointerUpAction(button=0), + ], + ) + key_actions = KeySourceActions( + id="keyboard", + actions=[ + PauseAction(duration=0), + KeyDownAction(value="t"), + KeyUpAction(value="t"), + KeyDownAction(value="e"), + KeyUpAction(value="e"), + KeyDownAction(value="s"), + KeyUpAction(value="s"), + KeyDownAction(value="t"), + KeyUpAction(value="t"), + ], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[pointer_actions, key_actions]) + + WebDriverWait(driver, 5).until(lambda d: input_element.get_attribute("value") == "test") + + +def test_set_files(driver, pages): + pages.load("formPage.html") + upload_element = driver.find_element(By.ID, "upload") + assert upload_element.get_attribute("value") == "" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as temp_file: + temp_file.write("test content") + temp_file_path = temp_file.name + + try: + Input(driver).set_files( + context=driver.current_window_handle, + element=SharedReference(shared_id=upload_element.id), + files=[temp_file_path], + ) + assert os.path.basename(temp_file_path) in upload_element.get_attribute("value") + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +def test_set_multiple_files(driver): + driver.get("data:text/html,") + upload_element = driver.find_element(By.ID, "upload") + + temp_files = [] + for i in range(2): + temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) + temp_file.write(f"test content {i}") + temp_files.append(temp_file.name) + temp_file.close() + + try: + Input(driver).set_files( + context=driver.current_window_handle, element=SharedReference(shared_id=upload_element.id), files=temp_files + ) + assert upload_element.get_attribute("value") != "" + finally: + for temp_file_path in temp_files: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +def test_release_actions(driver, pages): + pages.load("single_text_input.html") + input_element = driver.find_element(By.ID, "textInput") + + Input(driver).perform_actions( + context=driver.current_window_handle, + actions=[KeySourceActions(id="keyboard", actions=[KeyDownAction(value="a")])], + ) + Input(driver).release_actions(context=driver.current_window_handle) + + Input(driver).perform_actions( + context=driver.current_window_handle, + actions=[KeySourceActions(id="keyboard", actions=[KeyDownAction(value="b"), KeyUpAction(value="b")])], + ) + WebDriverWait(driver, 5).until(lambda d: "b" in input_element.get_attribute("value")) + + +def test_perform_actions_with_none_source(driver, pages): + pages.load("single_text_input.html") + none_actions = NoneSourceActions(id="none_id", actions=[PauseAction(duration=100), PauseAction(duration=50)]) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[none_actions]) + + assert driver.find_element(By.ID, "textInput").get_attribute("value") == "" + + +def test_perform_actions_rapid_key_sequence(driver, pages): + pages.load("single_text_input.html") + input_element = driver.find_element(By.ID, "textInput") + + key_actions = KeySourceActions( + id="keyboard", + actions=[ + KeyDownAction(value="a"), + KeyUpAction(value="a"), + KeyDownAction(value="b"), + KeyUpAction(value="b"), + KeyDownAction(value="c"), + KeyUpAction(value="c"), + KeyDownAction(value="d"), + KeyUpAction(value="d"), + ], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[key_actions]) + + WebDriverWait(driver, 5).until(lambda d: input_element.get_attribute("value") == "abcd") + + +def test_perform_actions_multiple_pointer_buttons(driver, pages): + pages.load("javascriptPage.html") + button = driver.find_element(By.ID, "clickField") + x, y = _center(button) + + pointer_actions = PointerSourceActions( + id="mouse_left", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[PointerMoveAction(x=x, y=y), PointerDownAction(button=0), PointerUpAction(button=0)], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[pointer_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + + +def test_perform_actions_pointer_touch_type(driver, pages): + pages.load("javascriptPage.html") + button = driver.find_element(By.ID, "clickField") + x, y = _center(button) + + touch_actions = PointerSourceActions( + id="touch", + parameters=PointerParameters(pointer_type=PointerType.TOUCH), + actions=[PointerMoveAction(x=x, y=y), PointerDownAction(button=0), PointerUpAction(button=0)], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[touch_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + + +@pytest.mark.xfail_firefox +def test_perform_actions_pointer_pen_type(driver, pages): + pages.load("javascriptPage.html") + button = driver.find_element(By.ID, "clickField") + x, y = _center(button) + + pen_actions = PointerSourceActions( + id="pen", + parameters=PointerParameters(pointer_type=PointerType.PEN), + actions=[PointerMoveAction(x=x, y=y), PointerDownAction(button=0), PointerUpAction(button=0)], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[pen_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + + +def test_perform_actions_pointer_move_with_duration(driver, pages): + pages.load("javascriptPage.html") + button = driver.find_element(By.ID, "clickField") + x, y = _center(button) + + pointer_actions = PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[ + PointerMoveAction(x=x - 100, y=y - 100), + PointerMoveAction(x=x, y=y, duration=500), + PointerDownAction(button=0), + PointerUpAction(button=0), + ], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[pointer_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + + +def test_wheel_scroll_negative_delta(driver, pages): + pages.load("scroll3.html") + Input(driver).perform_actions( + context=driver.current_window_handle, + actions=[ + WheelSourceActions( + id="d", actions=[WheelScrollAction(x=100, y=100, delta_x=0, delta_y=100, origin="viewport")] + ) + ], + ) + scroll_y_down = driver.execute_script("return window.pageYOffset;") + assert scroll_y_down > 0 + + Input(driver).perform_actions( + context=driver.current_window_handle, + actions=[ + WheelSourceActions( + id="u", actions=[WheelScrollAction(x=100, y=100, delta_x=0, delta_y=-50, origin="viewport")] + ) + ], + ) + assert driver.execute_script("return window.pageYOffset;") < scroll_y_down + + +def test_wheel_scroll_with_duration(driver, pages): + pages.load("scroll3.html") + wheel_actions = WheelSourceActions( + id="wheel", + actions=[WheelScrollAction(x=100, y=100, delta_x=0, delta_y=100, duration=500, origin="viewport")], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[wheel_actions]) + + assert driver.execute_script("return window.pageYOffset;") == 100 + + +def test_wheel_scroll_horizontal(driver, pages): + pages.load("scroll3.html") + wheel_actions = WheelSourceActions( + id="wheel", actions=[WheelScrollAction(x=100, y=100, delta_x=50, delta_y=0, origin="viewport")] + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[wheel_actions]) + + assert driver.execute_script("return window.pageXOffset;") >= 0 + + +def test_key_input_special_characters(driver, pages): + pages.load("single_text_input.html") + input_element = driver.find_element(By.ID, "textInput") + + key_actions = KeySourceActions( + id="keyboard", + actions=[ + KeyDownAction(value="!"), + KeyUpAction(value="!"), + KeyDownAction(value="@"), + KeyUpAction(value="@"), + KeyDownAction(value="#"), + KeyUpAction(value="#"), + ], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[key_actions]) + + WebDriverWait(driver, 5).until(lambda d: "!" in input_element.get_attribute("value")) + + +def test_set_files_empty_file_list(driver, pages): + pages.load("formPage.html") + upload_element = driver.find_element(By.ID, "upload") + + Input(driver).set_files( + context=driver.current_window_handle, element=SharedReference(shared_id=upload_element.id), files=[] + ) + assert upload_element.get_attribute("value") == "" + + +def test_set_files_with_absolute_path(driver): + driver.get("data:text/html,") + upload_element = driver.find_element(By.ID, "upload") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as temp_file: + temp_file.write("test file content") + temp_file_path = temp_file.name + + try: + Input(driver).set_files( + context=driver.current_window_handle, + element=SharedReference(shared_id=upload_element.id), + files=[temp_file_path], + ) + assert os.path.basename(temp_file_path) in upload_element.get_attribute("value") + finally: + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +def test_release_actions_clears_pointer_state(driver, pages): + pages.load("javascriptPage.html") + button = driver.find_element(By.ID, "clickField") + x, y = _center(button) + + Input(driver).perform_actions( + context=driver.current_window_handle, + actions=[ + PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[PointerMoveAction(x=x, y=y), PointerDownAction(button=0)], + ) + ], + ) + Input(driver).release_actions(context=driver.current_window_handle) + + Input(driver).perform_actions( + context=driver.current_window_handle, + actions=[ + PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[PointerMoveAction(x=x, y=y), PointerDownAction(button=0), PointerUpAction(button=0)], + ) + ], + ) + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + + +def test_pointer_common_properties_pressure_values(driver, pages): + pages.load("javascriptPage.html") + button = driver.find_element(By.ID, "clickField") + x, y = _center(button) + + props = dict( + width=2, height=2, pressure=0.75, tangential_pressure=0.25, twist=90, altitude_angle=0.7, azimuth_angle=1.5 + ) + pointer_actions = PointerSourceActions( + id="mouse", + parameters=PointerParameters(pointer_type=PointerType.MOUSE), + actions=[ + PointerMoveAction(x=x, y=y, **props), + PointerDownAction(button=0, **props), + PointerUpAction(button=0), + ], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[pointer_actions]) + + WebDriverWait(driver, 5).until(lambda d: button.get_attribute("value") == "Clicked") + + +def test_combined_keyboard_and_wheel_actions(driver, pages): + pages.load("scroll3.html") + key_actions = KeySourceActions(id="keyboard", actions=[PauseAction(duration=0)]) + wheel_actions = WheelSourceActions( + id="wheel", + actions=[PauseAction(duration=0), WheelScrollAction(x=100, y=100, delta_x=0, delta_y=100, origin="viewport")], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[key_actions, wheel_actions]) + + assert driver.execute_script("return window.pageYOffset;") == 100 + + +def test_key_input_with_value_attribute(driver, pages): + pages.load("single_text_input.html") + input_element = driver.find_element(By.ID, "textInput") + + key_actions = KeySourceActions( + id="keyboard", + actions=[ + KeyDownAction(value="x"), + KeyUpAction(value="x"), + KeyDownAction(value="y"), + KeyUpAction(value="y"), + KeyDownAction(value="z"), + KeyUpAction(value="z"), + ], + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[key_actions]) + + WebDriverWait(driver, 5).until(lambda d: input_element.get_attribute("value") == "xyz") + + +def test_wheel_scroll_with_element_origin(driver, pages): + pages.load("scroll3.html") + body_element = driver.find_element(By.TAG_NAME, "body") + element_origin = ElementOrigin(element=SharedReference(shared_id=body_element.id)) + + wheel_actions = WheelSourceActions( + id="wheel", actions=[WheelScrollAction(x=100, y=100, delta_x=0, delta_y=100, origin=element_origin)] + ) + Input(driver).perform_actions(context=driver.current_window_handle, actions=[wheel_actions]) + + assert driver.execute_script("return window.pageYOffset;") >= 0 diff --git a/py/test/selenium/webdriver/common/_bidi/network_tests.py b/py/test/selenium/webdriver/common/_bidi/network_tests.py new file mode 100644 index 0000000000000..59fd80d9b42a9 --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/network_tests.py @@ -0,0 +1,62 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/network_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +import pytest + +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.common._bidi.network import Header, InterceptPhase, Network, StringValue + + +def test_add_intercept(driver, pages): + network = Network(driver) + result = network.add_intercept(phases=[InterceptPhase.BEFORE_REQUEST_SENT]) + assert result.intercept + + network.remove_intercept(intercept=result.intercept) + + +def test_remove_intercept(driver): + network = Network(driver) + intercept = network.add_intercept(phases=[InterceptPhase.BEFORE_REQUEST_SENT]).intercept + + network.remove_intercept(intercept=intercept) + + # No facade `intercepts` list to inspect; a second remove of the same id must raise. + with pytest.raises(WebDriverException): + network.remove_intercept(intercept=intercept) + + +def test_extra_header_is_sent_with_requests(driver, pages): + header = Header(name="x-selenium-extra", value=StringValue(value="extra-header-value")) + Network(driver).set_extra_headers(headers=[header]) + try: + driver.get(pages.url("echo_headers")) + assert "x-selenium-extra" in driver.page_source + assert "extra-header-value" in driver.page_source + finally: + Network(driver).set_extra_headers(headers=[]) + + +def test_removed_extra_header_is_not_sent(driver, pages): + network = Network(driver) + network.set_extra_headers(headers=[Header(name="x-selenium-extra", value=StringValue(value="extra-header-value"))]) + network.set_extra_headers(headers=[]) + + driver.get(pages.url("echo_headers")) + assert "x-selenium-extra" not in driver.page_source diff --git a/py/test/selenium/webdriver/common/_bidi/permissions_tests.py b/py/test/selenium/webdriver/common/_bidi/permissions_tests.py new file mode 100644 index 0000000000000..f9b991fef8277 --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/permissions_tests.py @@ -0,0 +1,114 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/permissions_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +from selenium.webdriver.common._bidi.browser import Browser +from selenium.webdriver.common._bidi.browsing_context import BrowsingContext, CreateType +from selenium.webdriver.common._bidi.permissions import PermissionDescriptor, Permissions, PermissionState + +GEOLOCATION = PermissionDescriptor(name="geolocation") + + +def get_origin(driver): + return driver.execute_script("return window.location.origin;") + + +def get_geolocation_permission(driver): + script = """ + const callback = arguments[arguments.length - 1]; + navigator.permissions.query({ name: 'geolocation' }).then(permission => { + callback(permission.state); + }).catch(error => { + callback(null); + }); + """ + return driver.execute_async_script(script) + + +def test_can_set_permission_to_granted(driver, pages): + pages.load("blank.html") + origin = get_origin(driver) + + Permissions(driver).set_permission(descriptor=GEOLOCATION, state=PermissionState.GRANTED, origin=origin) + + assert get_geolocation_permission(driver) == PermissionState.GRANTED + + +def test_can_set_permission_to_denied(driver, pages): + pages.load("blank.html") + origin = get_origin(driver) + + Permissions(driver).set_permission(descriptor=GEOLOCATION, state=PermissionState.DENIED, origin=origin) + + assert get_geolocation_permission(driver) == PermissionState.DENIED + + +def test_can_set_permission_to_prompt(driver, pages): + pages.load("blank.html") + origin = get_origin(driver) + + permissions = Permissions(driver) + permissions.set_permission(descriptor=GEOLOCATION, state=PermissionState.DENIED, origin=origin) + permissions.set_permission(descriptor=GEOLOCATION, state=PermissionState.PROMPT, origin=origin) + + assert get_geolocation_permission(driver) == PermissionState.PROMPT + + +def test_can_set_permission_for_user_context(driver, pages): + user_context = Browser(driver).create_user_context().user_context + context_id = BrowsingContext(driver).create(type=CreateType.TAB, user_context=user_context).context + + pages.load("blank.html") + original_window = driver.current_window_handle + driver.switch_to.window(context_id) + pages.load("blank.html") + origin = get_origin(driver) + + driver.switch_to.window(original_window) + original_permission = get_geolocation_permission(driver) + + driver.switch_to.window(context_id) + Permissions(driver).set_permission( + descriptor=GEOLOCATION, state=PermissionState.GRANTED, origin=origin, user_context=user_context + ) + + driver.switch_to.window(original_window) + assert get_geolocation_permission(driver) == original_permission + + driver.switch_to.window(context_id) + assert get_geolocation_permission(driver) == PermissionState.GRANTED + + BrowsingContext(driver).close(context=context_id) + Browser(driver).remove_user_context(user_context=user_context) + + +def test_can_set_permission_with_embedded_origin(driver, pages): + pages.load("blank.html") + origin = get_origin(driver) + + Permissions(driver).set_permission( + descriptor=GEOLOCATION, state=PermissionState.GRANTED, origin=origin, embedded_origin=origin + ) + + assert get_geolocation_permission(driver) == PermissionState.GRANTED + + +def test_permission_states_constants(): + assert PermissionState.GRANTED == "granted" + assert PermissionState.DENIED == "denied" + assert PermissionState.PROMPT == "prompt" diff --git a/py/test/selenium/webdriver/common/_bidi/script_tests.py b/py/test/selenium/webdriver/common/_bidi/script_tests.py new file mode 100644 index 0000000000000..14d9f606931ed --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/script_tests.py @@ -0,0 +1,623 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/script_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +import pytest + +from selenium.webdriver.common._bidi.browser import Browser +from selenium.webdriver.common._bidi.browsing_context import BrowsingContext, CreateType +from selenium.webdriver.common._bidi.script import ( + ArrayLocalValue, + BigIntValue, + BooleanValue, + ChannelProperties, + ChannelValue, + ContextTarget, + DateLocalValue, + EvaluateResultException, + EvaluateResultSuccess, + NullValue, + NumberValue, + ObjectLocalValue, + RealmType, + RemoteObjectReference, + ResultOwnership, + Script, + SerializationOptions, + SerializationOptionsIncludeShadowTree, + StringValue, +) +from selenium.webdriver.common._bidi.serialization import UNSET +from selenium.webdriver.common.by import By + + +def _evaluate(driver, expression, context, *, await_promise=False, **kwargs): + target = context if isinstance(context, ContextTarget) else ContextTarget(context=context) + return Script(driver).evaluate(expression, target, await_promise, **kwargs) + + +def _tree_has_shadow_root(props): + """Walk a typed ``NodeProperties`` tree looking for any shadow root.""" + if props is UNSET or props is None: + return False + if props.shadow_root is not UNSET and props.shadow_root is not None: + return True + children = props.children + if children is UNSET or children is None: + return False + return any(_tree_has_shadow_root(child.value) for child in children) + + +def test_add_preload_script(driver, pages): + function_declaration = "() => { window.preloadExecuted = true; }" + + script_id = Script(driver).add_preload_script(function_declaration).script + assert isinstance(script_id, str) + + pages.load("blank.html") + + result = _evaluate(driver, "window.preloadExecuted", driver.current_window_handle) + assert result.result.value is True + + +def test_add_preload_script_with_arguments(driver, pages): + function_declaration = "(channelFunc) => { channelFunc('test_value'); window.preloadValue = 'received'; }" + + arguments = [ChannelValue(value=ChannelProperties(channel="test-channel", ownership=ResultOwnership.ROOT))] + + script_id = Script(driver).add_preload_script(function_declaration, arguments=arguments).script + assert script_id is not None + + pages.load("blank.html") + + result = _evaluate(driver, "window.preloadValue", driver.current_window_handle) + assert result.result.value == "received" + + +def test_add_preload_script_with_contexts(driver, pages): + function_declaration = "() => { window.contextSpecific = true; }" + contexts = [driver.current_window_handle] + + script_id = Script(driver).add_preload_script(function_declaration, contexts=contexts).script + assert script_id is not None + + pages.load("blank.html") + + result = _evaluate(driver, "window.contextSpecific", driver.current_window_handle) + assert result.result.value is True + + +def test_add_preload_script_with_user_contexts(driver, pages): + function_declaration = "() => { window.contextSpecific = true; }" + original_handle = driver.current_window_handle + user_context = Browser(driver).create_user_context().user_context + + context1 = BrowsingContext(driver).create(type=CreateType.WINDOW, user_context=user_context).context + driver.switch_to.window(context1) + + try: + script_id = Script(driver).add_preload_script(function_declaration, user_contexts=[user_context]).script + assert script_id is not None + + pages.load("blank.html") + + result = _evaluate(driver, "window.contextSpecific", driver.current_window_handle) + assert result.result.value is True + finally: + driver.switch_to.window(original_handle) + BrowsingContext(driver).close(context=context1) + Browser(driver).remove_user_context(user_context=user_context) + + +def test_add_preload_script_with_sandbox(driver, pages): + function_declaration = "() => { window.sandboxScript = true; }" + + script_id = Script(driver).add_preload_script(function_declaration, sandbox="test-sandbox").script + assert script_id is not None + + pages.load("blank.html") + + result = _evaluate(driver, "window.sandboxScript", driver.current_window_handle) + assert result.result.type == "undefined" + + target = ContextTarget(context=driver.current_window_handle, sandbox="test-sandbox") + result = _evaluate(driver, "window.sandboxScript", target) + assert result.result.value is True + + +def test_remove_preload_script(driver, pages): + function_declaration = "() => { window.removableScript = true; }" + + script_id = Script(driver).add_preload_script(function_declaration).script + Script(driver).remove_preload_script(script=script_id) + + pages.load("blank.html") + + result = _evaluate(driver, "typeof window.removableScript", driver.current_window_handle) + assert result.result.value == "undefined" + + +def test_preload_script_runs_before_dom_content_loaded(driver, pages): + function_declaration = """ + () => { + document.addEventListener('DOMContentLoaded', () => { + const div = document.createElement('div'); + div.id = 'injected-element'; + div.textContent = 'injected'; + document.body.appendChild(div); + }); + } + """ + script_id = Script(driver).add_preload_script(function_declaration).script + + try: + pages.load("blank.html") + assert driver.find_element(By.ID, "injected-element").text == "injected" + finally: + Script(driver).remove_preload_script(script=script_id) + + +def test_evaluate_expression(driver, pages): + pages.load("blank.html") + + result = _evaluate(driver, "1 + 2", driver.current_window_handle) + + assert isinstance(result, EvaluateResultSuccess) + assert result.realm is not None + assert result.result.type == "number" + assert result.result.value == 3 + + +def test_evaluate_with_await_promise(driver, pages): + pages.load("blank.html") + + result = _evaluate(driver, "Promise.resolve(42)", driver.current_window_handle, await_promise=True) + + assert result.result.type == "number" + assert result.result.value == 42 + + +def test_evaluate_with_exception(driver, pages): + pages.load("blank.html") + + result = _evaluate(driver, "throw new Error('Test error')", driver.current_window_handle) + + assert isinstance(result, EvaluateResultException) + assert "Test error" in result.exception_details.text + + +def test_evaluate_with_result_ownership(driver, pages): + pages.load("blank.html") + + result = _evaluate( + driver, + "({ test: 'value' })", + driver.current_window_handle, + result_ownership=ResultOwnership.ROOT, + ) + assert result.result.handle is not UNSET + + result = _evaluate( + driver, + "({ test: 'value' })", + driver.current_window_handle, + result_ownership=ResultOwnership.NONE, + ) + assert result.result.handle is UNSET + assert result.result is not None + + +def test_evaluate_with_serialization_options(driver, pages): + pages.load("shadowRootPage.html") + + serialization_options = SerializationOptions( + max_dom_depth=2, + max_object_depth=2, + include_shadow_tree=SerializationOptionsIncludeShadowTree.ALL, + ) + + result = _evaluate( + driver, + "document.body", + driver.current_window_handle, + serialization_options=serialization_options, + ) + + assert result.result.value.children + assert _tree_has_shadow_root(result.result.value) + + +def test_evaluate_with_user_activation(driver, pages): + pages.load("blank.html") + + result = _evaluate( + driver, + "navigator.userActivation ? navigator.userActivation.isActive : false", + driver.current_window_handle, + user_activation=True, + ) + + assert result.result.value is True + + +def test_call_function(driver, pages): + pages.load("blank.html") + + result = Script(driver).call_function( + "(a, b) => a + b", + False, + ContextTarget(context=driver.current_window_handle), + arguments=[NumberValue(value=5), NumberValue(value=3)], + ) + + assert result.result.type == "number" + assert result.result.value == 8 + + +def test_call_function_with_this(driver, pages): + pages.load("blank.html") + + result = Script(driver).call_function( + "function() { return this.value; }", + False, + ContextTarget(context=driver.current_window_handle), + this=ObjectLocalValue(value=[["value", NumberValue(value=20)]]), + ) + + assert result.result.type == "number" + assert result.result.value == 20 + + +def test_call_function_with_user_activation(driver, pages): + pages.load("blank.html") + + result = Script(driver).call_function( + "() => navigator.userActivation ? navigator.userActivation.isActive : false", + False, + ContextTarget(context=driver.current_window_handle), + user_activation=True, + ) + + assert result.result.value is True + + +def test_call_function_with_serialization_options(driver, pages): + pages.load("shadowRootPage.html") + + serialization_options = SerializationOptions( + max_dom_depth=2, + max_object_depth=2, + include_shadow_tree=SerializationOptionsIncludeShadowTree.ALL, + ) + + result = Script(driver).call_function( + "() => document.body", + False, + ContextTarget(context=driver.current_window_handle), + serialization_options=serialization_options, + ) + + assert result.result.value.children + assert _tree_has_shadow_root(result.result.value) + + +def test_call_function_with_exception(driver, pages): + pages.load("blank.html") + + result = Script(driver).call_function( + "() => { throw new Error('Function error'); }", + False, + ContextTarget(context=driver.current_window_handle), + ) + + assert isinstance(result, EvaluateResultException) + assert "Function error" in result.exception_details.text + + +def test_call_function_with_await_promise(driver, pages): + pages.load("blank.html") + + result = Script(driver).call_function( + "() => Promise.resolve('async result')", + True, + ContextTarget(context=driver.current_window_handle), + ) + + assert result.result.type == "string" + assert result.result.value == "async result" + + +def test_call_function_with_result_ownership(driver, pages): + pages.load("blank.html") + + result = Script(driver).call_function( + "function() { return { greet: 'Hi', number: 42 }; }", + False, + ContextTarget(context=driver.current_window_handle), + result_ownership=ResultOwnership.ROOT, + ) + + assert result.result.type == "object" + assert result.result.handle is not UNSET + handle = result.result.handle + + result2 = Script(driver).call_function( + "function() { return this.number + 1; }", + False, + ContextTarget(context=driver.current_window_handle), + this=RemoteObjectReference(handle=handle), + ) + + assert result2.result.type == "number" + assert result2.result.value == 43 + + +def test_get_realms(driver, pages): + pages.load("blank.html") + + realms = Script(driver).get_realms().realms + + assert len(realms) > 0 + assert all(hasattr(realm, "realm") for realm in realms) + assert all(hasattr(realm, "origin") for realm in realms) + assert all(hasattr(realm, "type") for realm in realms) + + +def test_get_realms_filtered_by_context(driver, pages): + pages.load("blank.html") + + realms = Script(driver).get_realms(context=driver.current_window_handle).realms + + assert len(realms) > 0 + for realm in realms: + if getattr(realm, "context", None) is not None: + assert realm.context == driver.current_window_handle + + +def test_get_realms_filtered_by_type(driver, pages): + pages.load("blank.html") + + realms = Script(driver).get_realms(type=RealmType.WINDOW).realms + + assert len(realms) > 0 + for realm in realms: + assert realm.type == RealmType.WINDOW + + +def test_disown_handles(driver, pages): + pages.load("blank.html") + + result = _evaluate( + driver, + "({foo: 'bar'})", + driver.current_window_handle, + result_ownership=ResultOwnership.ROOT, + ) + handle = result.result.handle + assert handle is not UNSET + + result_before = Script(driver).call_function( + "function(obj) { return obj.foo; }", + False, + ContextTarget(context=driver.current_window_handle), + arguments=[RemoteObjectReference(handle=handle)], + ) + assert result_before.result.value == "bar" + + Script(driver).disown(handles=[handle], target=ContextTarget(context=driver.current_window_handle)) + + with pytest.raises(Exception): + Script(driver).call_function( + "function(obj) { return obj.foo; }", + False, + ContextTarget(context=driver.current_window_handle), + arguments=[RemoteObjectReference(handle=handle)], + ) + + +# The facade's ``driver.script.execute(fn, *py_args)`` converts Python values to LocalValues; +# below that conversion is done inline so call_function receives typed argument records directly. + + +def _call(driver, function_declaration, *arguments): + return Script(driver).call_function( + function_declaration, + False, + ContextTarget(context=driver.current_window_handle), + arguments=list(arguments), + ) + + +def test_call_function_with_null_argument(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(arg) => { if (arg !== null) throw Error('expected null'); return arg; }", + NullValue(), + ) + + assert result.result.type == "null" + + +def test_call_function_with_number_argument(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(arg) => { if (arg !== 1.4) throw Error('expected 1.4'); return arg; }", + NumberValue(value=1.4), + ) + + assert result.result.type == "number" + assert result.result.value == 1.4 + + +def test_call_function_with_nan_argument(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(arg) => { if (!Number.isNaN(arg)) throw Error('expected NaN'); return arg; }", + NumberValue(value="NaN"), + ) + + assert result.result.type == "number" + assert result.result.value == "NaN" + + +def test_call_function_with_infinity_argument(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(arg) => { if (arg !== Infinity) throw Error('expected Infinity'); return arg; }", + NumberValue(value="Infinity"), + ) + + assert result.result.type == "number" + assert result.result.value == "Infinity" + + +def test_call_function_with_minus_infinity_argument(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(arg) => { if (arg !== -Infinity) throw Error('expected -Infinity'); return arg; }", + NumberValue(value="-Infinity"), + ) + + assert result.result.type == "number" + assert result.result.value == "-Infinity" + + +def test_call_function_with_bigint_argument(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(arg) => { if (arg !== 9007199254740992n) throw Error('expected bigint'); return arg; }", + BigIntValue(value="9007199254740992"), + ) + + assert result.result.type == "bigint" + assert result.result.value == "9007199254740992" + + +def test_call_function_with_boolean_argument(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(arg) => { if (arg !== true) throw Error('expected true'); return arg; }", + BooleanValue(value=True), + ) + + assert result.result.type == "boolean" + assert result.result.value is True + + +def test_call_function_with_string_argument(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(arg) => { if (arg !== 'hello world') throw Error('expected hello world'); return arg; }", + StringValue(value="hello world"), + ) + + assert result.result.type == "string" + assert result.result.value == "hello world" + + +def test_call_function_with_date_argument(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(arg) => { if (!(arg instanceof Date)) throw Error('expected Date');" + " if (arg.getFullYear() !== 2023) throw Error('expected 2023'); return arg; }", + DateLocalValue(value="2023-12-25T10:30:45"), + ) + + assert result.result.type == "date" + assert "2023-12-25T10:30:45" in result.result.value + + +def test_call_function_with_array_argument(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(arg) => { if (!(arg instanceof Array)) throw Error('expected Array');" + " if (arg.length !== 3) throw Error('expected length 3'); return arg; }", + ArrayLocalValue(value=[NumberValue(value=1), NumberValue(value=2), NumberValue(value=3)]), + ) + + assert result.result.type == "array" + assert len(result.result.value) == 3 + + +def test_call_function_with_multiple_arguments(driver, pages): + pages.load("blank.html") + + result = _call( + driver, + "(a, b, c) => { if (a !== 1) throw Error('a'); if (b !== 'test') throw Error('b');" + " if (c !== true) throw Error('c'); return a + b.length + (c ? 1 : 0); }", + NumberValue(value=1), + StringValue(value="test"), + BooleanValue(value=True), + ) + + assert result.result.type == "number" + assert result.result.value == 6 + + +def test_call_function_with_nested_object_argument(driver, pages): + pages.load("blank.html") + + nested = ObjectLocalValue( + value=[ + [ + "user", + ObjectLocalValue( + value=[ + ["name", StringValue(value="John")], + ["age", NumberValue(value=30)], + ["hobbies", ArrayLocalValue(value=[StringValue(value="reading"), StringValue(value="coding")])], + ] + ), + ], + ["settings", ObjectLocalValue(value=[["theme", StringValue(value="dark")]])], + ] + ) + + result = _call( + driver, + "(data) => ({ userName: data.user.name, userAge: data.user.age," + " hobbyCount: data.user.hobbies.length, theme: data.settings.theme })", + nested, + ) + + assert result.result.type == "object" + value_dict = {k: v.value for k, v in result.result.value} + assert value_dict["userName"] == "John" + assert value_dict["userAge"] == 30 + assert value_dict["hobbyCount"] == 2 diff --git a/py/test/selenium/webdriver/common/_bidi/session_tests.py b/py/test/selenium/webdriver/common/_bidi/session_tests.py new file mode 100644 index 0000000000000..85d50e7800545 --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/session_tests.py @@ -0,0 +1,43 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/session_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +import pytest + +from selenium.webdriver.common._bidi.session import Session +from selenium.webdriver.common.window import WindowTypes + + +@pytest.mark.xfail_safari +def test_session_status(driver): + result = Session(driver).status() + assert isinstance(result.ready, bool) + assert isinstance(result.message, str) + + +@pytest.mark.xfail_safari +def test_session_status_not_closed_with_one_window(driver): + assert isinstance(Session(driver).status().ready, bool) + + driver.switch_to.new_window(WindowTypes.WINDOW) + driver.switch_to.new_window(WindowTypes.TAB) + driver.close() + + result = Session(driver).status() + assert isinstance(result.ready, bool) + assert isinstance(result.message, str) diff --git a/py/test/selenium/webdriver/common/_bidi/storage_tests.py b/py/test/selenium/webdriver/common/_bidi/storage_tests.py new file mode 100644 index 0000000000000..22f9ceb926a83 --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/storage_tests.py @@ -0,0 +1,525 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/storage_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +import random +import time + +import pytest + +from selenium.webdriver.common._bidi.browser import Browser +from selenium.webdriver.common._bidi.browsing_context import BrowsingContext, CreateType +from selenium.webdriver.common._bidi.network import SameSite, StringValue +from selenium.webdriver.common._bidi.storage import ( + BrowsingContextPartitionDescriptor, + CookieFilter, + PartialCookie, + Storage, + StorageKeyPartitionDescriptor, +) +from selenium.webdriver.common.window import WindowTypes + + +def generate_unique_key(): + return f"key_{random.randint(0, 100000)}" + + +def assert_cookie_is_not_present_with_name(driver, key): + assert driver.get_cookie(key) is None + document_cookie = get_document_cookie_or_none(driver) + if document_cookie is not None: + assert key + "=" not in document_cookie + + +def assert_cookie_is_present_with_name(driver, key): + assert driver.get_cookie(key) is not None + document_cookie = get_document_cookie_or_none(driver) + if document_cookie is not None: + assert key + "=" in document_cookie + + +def assert_cookie_has_value(driver, key, value): + assert driver.get_cookie(key)["value"] == value + document_cookie = get_document_cookie_or_none(driver) + if document_cookie is not None: + assert f"{key}={value}" in document_cookie + + +def assert_no_cookies_are_present(driver): + assert len(driver.get_cookies()) == 0 + document_cookie = get_document_cookie_or_none(driver) + if document_cookie is not None: + assert document_cookie == "" + + +def assert_some_cookies_are_present(driver): + assert len(driver.get_cookies()) > 0 + document_cookie = get_document_cookie_or_none(driver) + if document_cookie is not None: + assert document_cookie != "" + + +def get_document_cookie_or_none(driver): + try: + return driver.execute_script("return document.cookie") + except Exception: + return None + + +class TestBidiStorage: + @pytest.fixture(autouse=True) + def setup(self, driver, pages): + driver.get(pages.url("simpleTest.html")) + driver.delete_all_cookies() + + def test_get_cookie_by_name(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = generate_unique_key() + value = "set" + assert_cookie_is_not_present_with_name(driver, key) + + driver.add_cookie({"name": key, "value": value}) + + cookie_filter = CookieFilter(name=key, value=StringValue(value="set")) + result = Storage(driver).get_cookies(filter=cookie_filter) + + assert len(result.cookies) > 0 + assert result.cookies[0].value.value == value + + @pytest.mark.xfail_chrome + @pytest.mark.xfail_edge + def test_get_cookie_in_default_user_context(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + window_handle = driver.current_window_handle + key = generate_unique_key() + value = "set" + assert_cookie_is_not_present_with_name(driver, key) + + driver.add_cookie({"name": key, "value": value}) + + cookie_filter = CookieFilter(name=key, value=StringValue(value="set")) + storage = Storage(driver) + + driver.switch_to.new_window(WindowTypes.WINDOW) + + descriptor = BrowsingContextPartitionDescriptor(context=driver.current_window_handle) + result_after_switching_context = storage.get_cookies(filter=cookie_filter, partition=descriptor) + + assert len(result_after_switching_context.cookies) > 0 + assert result_after_switching_context.cookies[0].value.value == value + + driver.switch_to.window(window_handle) + + descriptor = BrowsingContextPartitionDescriptor(context=driver.current_window_handle) + result = storage.get_cookies(filter=cookie_filter, partition=descriptor) + + assert len(result.cookies) > 0 + assert result.cookies[0].value.value == value + partition_key = result.partition_key + assert partition_key.source_origin is not None + assert partition_key.user_context is not None + assert partition_key.user_context == "default" + + def test_get_cookie_in_a_user_context(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + user_context = Browser(driver).create_user_context().user_context + window_handle = driver.current_window_handle + + key = generate_unique_key() + value = "set" + + descriptor = StorageKeyPartitionDescriptor(user_context=user_context) + storage = Storage(driver) + storage.set_cookie(cookie=PartialCookie(key, StringValue(value=value), webserver.host), partition=descriptor) + + cookie_filter = CookieFilter(name=key, value=StringValue(value="set")) + + new_window = BrowsingContext(driver).create(type=CreateType.TAB, user_context=user_context).context + driver.switch_to.window(new_window) + + result = storage.get_cookies(filter=cookie_filter, partition=descriptor) + assert len(result.cookies) > 0 + assert result.cookies[0].value.value == value + assert result.partition_key.user_context == user_context + + driver.switch_to.window(window_handle) + + by_context = BrowsingContextPartitionDescriptor(context=window_handle) + result1 = storage.get_cookies(filter=cookie_filter, partition=by_context) + assert len(result1.cookies) == 0 + + BrowsingContext(driver).close(context=new_window) + Browser(driver).remove_user_context(user_context=user_context) + + def test_add_cookie(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = generate_unique_key() + value = "foo" + + assert_cookie_is_not_present_with_name(driver, key) + Storage(driver).set_cookie(cookie=PartialCookie(key, StringValue(value=value), webserver.host)) + + assert_cookie_has_value(driver, key, value) + driver.get(pages.url("simpleTest.html")) + assert_cookie_has_value(driver, key, value) + + @pytest.mark.xfail_chrome + @pytest.mark.xfail_edge + def test_add_and_get_cookie(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + value = StringValue(value="cod") + domain = webserver.host + expiry = int(time.time() + 3600) + path = "/simpleTest.html" + + cookie = PartialCookie( + "fish", value, domain, path=path, http_only=True, secure=False, same_site=SameSite.LAX, expiry=expiry + ) + Storage(driver).set_cookie(cookie=cookie) + + driver.get(pages.url("simpleTest.html")) + + cookie_filter = CookieFilter( + name="fish", + value=value, + domain=domain, + path=path, + http_only=True, + secure=False, + same_site=SameSite.LAX, + expiry=expiry, + ) + descriptor = BrowsingContextPartitionDescriptor(context=driver.current_window_handle) + result = Storage(driver).get_cookies(filter=cookie_filter, partition=descriptor) + + assert len(result.cookies) > 0 + result_cookie = result.cookies[0] + assert result_cookie.name == "fish" + assert result_cookie.value.value == value.value + assert result_cookie.domain == domain + assert result_cookie.path == path + assert result_cookie.http_only is True + assert result_cookie.secure is False + assert result_cookie.same_site == SameSite.LAX + assert result_cookie.expiry == expiry + assert result.partition_key.source_origin is not None + assert result.partition_key.user_context == "default" + + def test_get_all_cookies(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key1 = generate_unique_key() + key2 = generate_unique_key() + assert_cookie_is_not_present_with_name(driver, key1) + assert_cookie_is_not_present_with_name(driver, key2) + + storage = Storage(driver) + count_before = len(storage.get_cookies(filter=CookieFilter()).cookies) + + driver.add_cookie({"name": key1, "value": "value"}) + driver.add_cookie({"name": key2, "value": "value"}) + driver.get(pages.url("simpleTest.html")) + + result = storage.get_cookies(filter=CookieFilter()) + assert len(result.cookies) == count_before + 2 + cookie_names = [cookie.name for cookie in result.cookies] + assert key1 in cookie_names + assert key2 in cookie_names + + def test_delete_all_cookies(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + driver.add_cookie({"name": "foo", "value": "set"}) + assert_some_cookies_are_present(driver) + + Storage(driver).delete_cookies(filter=CookieFilter()) + + assert_no_cookies_are_present(driver) + driver.get(pages.url("simpleTest.html")) + assert_no_cookies_are_present(driver) + + def test_delete_cookie_with_name(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key1 = generate_unique_key() + key2 = generate_unique_key() + driver.add_cookie({"name": key1, "value": "set"}) + driver.add_cookie({"name": key2, "value": "set"}) + assert_cookie_is_present_with_name(driver, key1) + assert_cookie_is_present_with_name(driver, key2) + + Storage(driver).delete_cookies(filter=CookieFilter(name=key1)) + + assert_cookie_is_not_present_with_name(driver, key1) + assert_cookie_is_present_with_name(driver, key2) + driver.get(pages.url("simpleTest.html")) + assert_cookie_is_not_present_with_name(driver, key1) + assert_cookie_is_present_with_name(driver, key2) + + def test_add_cookies_with_different_paths(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + storage = Storage(driver) + storage.set_cookie( + cookie=PartialCookie("fish", StringValue(value="cod"), webserver.host, path="/simpleTest.html") + ) + storage.set_cookie(cookie=PartialCookie("planet", StringValue(value="earth"), webserver.host, path="/")) + + driver.get(pages.url("simpleTest.html")) + assert_cookie_is_present_with_name(driver, "fish") + assert_cookie_is_present_with_name(driver, "planet") + + driver.get(pages.url("formPage.html")) + assert_cookie_is_not_present_with_name(driver, "fish") + + def test_delete_cookies_by_name_filter(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key1 = generate_unique_key() + key2 = generate_unique_key() + key3 = generate_unique_key() + driver.add_cookie({"name": key1, "value": "value1"}) + driver.add_cookie({"name": key2, "value": "value2"}) + driver.add_cookie({"name": key3, "value": "value3"}) + + Storage(driver).delete_cookies(filter=CookieFilter(name=key1)) + + assert_cookie_is_not_present_with_name(driver, key1) + assert_cookie_is_present_with_name(driver, key2) + assert_cookie_is_present_with_name(driver, key3) + + def test_delete_cookies_multiple_filters(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "multi_filter_delete_test" + value = StringValue(value="test_value") + + storage = Storage(driver) + storage.set_cookie(cookie=PartialCookie(key, value, webserver.host, http_only=True)) + storage.set_cookie(cookie=PartialCookie(key, value, webserver.host, http_only=False)) + + storage.delete_cookies(filter=CookieFilter(name=key, http_only=True)) + + result = storage.get_cookies(filter=CookieFilter(name=key)) + assert len(result.cookies) == 1 + assert result.cookies[0].http_only is False + + def test_delete_cookies_empty_filter(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + for i in range(3): + driver.add_cookie({"name": f"cookie_{i}", "value": f"value_{i}"}) + assert_some_cookies_are_present(driver) + + Storage(driver).delete_cookies(filter=CookieFilter()) + assert_no_cookies_are_present(driver) + + def test_set_cookie_with_http_only_attribute(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "http_only_cookie" + Storage(driver).set_cookie( + cookie=PartialCookie(key, StringValue(value="protected"), webserver.host, http_only=True) + ) + + result = Storage(driver).get_cookies(filter=CookieFilter(name=key, http_only=True)) + assert len(result.cookies) > 0 + assert result.cookies[0].http_only is True + + def test_set_cookie_with_secure_attribute(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "secure_cookie" + Storage(driver).set_cookie( + cookie=PartialCookie(key, StringValue(value="encrypted"), webserver.host, secure=True) + ) + + result = Storage(driver).get_cookies(filter=CookieFilter(name=key, secure=True)) + assert len(result.cookies) > 0 + assert result.cookies[0].secure is True + + def test_set_cookie_with_same_site_strict(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "samesite_strict" + Storage(driver).set_cookie( + cookie=PartialCookie(key, StringValue(value="strict"), webserver.host, same_site=SameSite.STRICT) + ) + + result = Storage(driver).get_cookies(filter=CookieFilter(name=key, same_site=SameSite.STRICT)) + assert len(result.cookies) > 0 + assert result.cookies[0].same_site == SameSite.STRICT + + def test_set_cookie_with_same_site_lax(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "samesite_lax" + Storage(driver).set_cookie( + cookie=PartialCookie(key, StringValue(value="lax"), webserver.host, same_site=SameSite.LAX) + ) + + result = Storage(driver).get_cookies(filter=CookieFilter(name=key, same_site=SameSite.LAX)) + assert len(result.cookies) > 0 + assert result.cookies[0].same_site == SameSite.LAX + + def test_set_cookie_with_same_site_none(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "samesite_none" + Storage(driver).set_cookie( + cookie=PartialCookie(key, StringValue(value="none"), webserver.host, same_site=SameSite.NONE, secure=True) + ) + + result = Storage(driver).get_cookies(filter=CookieFilter(name=key, same_site=SameSite.NONE)) + assert len(result.cookies) > 0 + assert result.cookies[0].same_site == SameSite.NONE + + def test_set_cookie_with_path_and_domain(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "path_domain_cookie" + path = "/simpleTest.html" + Storage(driver).set_cookie(cookie=PartialCookie(key, StringValue(value="scoped"), webserver.host, path=path)) + + result = Storage(driver).get_cookies(filter=CookieFilter(name=key, path=path)) + assert len(result.cookies) > 0 + assert result.cookies[0].path == path + assert result.cookies[0].domain == webserver.host + + def test_set_cookie_with_future_expiry(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "future_expiry_cookie" + future_expiry = int(time.time() + 3600) + Storage(driver).set_cookie( + cookie=PartialCookie(key, StringValue(value="future"), webserver.host, expiry=future_expiry) + ) + + result = Storage(driver).get_cookies(filter=CookieFilter(name=key)) + assert len(result.cookies) > 0 + assert result.cookies[0].expiry == future_expiry + + def test_set_cookie_with_string_value(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "string_value_cookie" + Storage(driver).set_cookie(cookie=PartialCookie(key, StringValue(value="hello"), webserver.host)) + + result = Storage(driver).get_cookies(filter=CookieFilter(name=key)) + assert len(result.cookies) > 0 + assert result.cookies[0].value.value == "hello" + + def test_get_cookies_filter_by_domain(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = generate_unique_key() + Storage(driver).set_cookie(cookie=PartialCookie(key, StringValue(value="domain_test"), webserver.host)) + + result = Storage(driver).get_cookies(filter=CookieFilter(domain=webserver.host)) + assert key in [c.name for c in result.cookies] + + def test_get_cookies_filter_by_path(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key1 = generate_unique_key() + key2 = generate_unique_key() + value = StringValue(value="path_test") + + storage = Storage(driver) + storage.set_cookie(cookie=PartialCookie(key1, value, webserver.host, path="/simpleTest.html")) + storage.set_cookie(cookie=PartialCookie(key2, value, webserver.host, path="/")) + + result = storage.get_cookies(filter=CookieFilter(path="/simpleTest.html")) + assert len(result.cookies) > 0 + assert all(c.path == "/simpleTest.html" for c in result.cookies) + + def test_multiple_cookies_same_name_different_paths(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "multi_path_cookie" + value = StringValue(value="test") + + storage = Storage(driver) + storage.set_cookie(cookie=PartialCookie(key, value, webserver.host, path="/")) + storage.set_cookie(cookie=PartialCookie(key, value, webserver.host, path="/simpleTest.html")) + + result = storage.get_cookies(filter=CookieFilter(name=key)) + assert len(result.cookies) >= 2 + + def test_delete_cookie_by_path(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key1 = generate_unique_key() + key2 = generate_unique_key() + value = StringValue(value="delete_test") + + storage = Storage(driver) + storage.set_cookie(cookie=PartialCookie(key1, value, webserver.host, path="/simpleTest.html")) + storage.set_cookie(cookie=PartialCookie(key2, value, webserver.host, path="/")) + + storage.delete_cookies(filter=CookieFilter(path="/simpleTest.html")) + + result = storage.get_cookies(filter=CookieFilter()) + cookie_names = [c.name for c in result.cookies] + assert key1 not in cookie_names or all(c.path != "/simpleTest.html" for c in result.cookies if c.name == key1) + + def test_cookie_expiry_timestamp(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "expiry_test" + expiry_time = int(time.time() + 7200) + Storage(driver).set_cookie( + cookie=PartialCookie(key, StringValue(value="expires"), webserver.host, expiry=expiry_time) + ) + + result = Storage(driver).get_cookies(filter=CookieFilter(name=key)) + assert len(result.cookies) > 0 + assert result.cookies[0].expiry == expiry_time + + def test_cookie_combined_attributes(self, driver, pages, webserver): + assert_no_cookies_are_present(driver) + + key = "combined_attrs" + value = StringValue(value="all_features") + path = "/simpleTest.html" + expiry = int(time.time() + 3600) + + cookie = PartialCookie( + key, value, webserver.host, path=path, http_only=True, secure=True, same_site=SameSite.LAX, expiry=expiry + ) + Storage(driver).set_cookie(cookie=cookie) + + cookie_filter = CookieFilter( + name=key, path=path, http_only=True, secure=True, same_site=SameSite.LAX, expiry=expiry + ) + result = Storage(driver).get_cookies(filter=cookie_filter) + + assert len(result.cookies) > 0 + cookie_result = result.cookies[0] + assert cookie_result.name == key + assert cookie_result.value.value == value.value + assert cookie_result.path == path + assert cookie_result.http_only is True + assert cookie_result.secure is True + assert cookie_result.same_site == SameSite.LAX + assert cookie_result.expiry == expiry diff --git a/py/test/selenium/webdriver/common/_bidi/webextension_tests.py b/py/test/selenium/webdriver/common/_bidi/webextension_tests.py new file mode 100644 index 0000000000000..75bd1381205e2 --- /dev/null +++ b/py/test/selenium/webdriver/common/_bidi/webextension_tests.py @@ -0,0 +1,171 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Mirror of ``../bidi/webextension_tests.py`` using the generated ``_bidi`` commands, not the ``driver`` facade.""" + +import base64 +import os +import shutil +import tempfile + +import pytest + +from selenium import webdriver +from selenium.webdriver.common._bidi.browsing_context import BrowsingContext +from selenium.webdriver.common._bidi.web_extension import ( + ExtensionArchivePath, + ExtensionBase64Encoded, + ExtensionPath, + WebExtension, +) +from selenium.webdriver.common.by import By +from selenium.webdriver.support.wait import WebDriverWait + +from conftest import _resolve_bazel_path, get_extensions_location + +EXTENSIONS = get_extensions_location() +EXTENSION_ID = "webextensions-selenium-example-v3@example.com" +EXTENSION_PATH = "webextensions-selenium-example-signed" +EXTENSION_ARCHIVE_PATH = "webextensions-selenium-example.xpi" + + +def install_extension(driver, extension_data): + result = WebExtension(driver).install(extension_data=extension_data) + assert result.extension == EXTENSION_ID + return result.extension + + +def verify_extension_injection(driver, pages): + pages.load("blank.html") + injected = WebDriverWait(driver, timeout=2).until( + lambda dr: dr.find_element(By.ID, "webextensions-selenium-example") + ) + assert injected.text == "Content injected by webextensions-selenium-example" + + +def uninstall_extension_and_verify_extension_uninstalled(driver, extension_id): + WebExtension(driver).uninstall(extension=extension_id) + + BrowsingContext(driver).reload(context=driver.current_window_handle) + assert len(driver.find_elements(By.ID, "webextensions-selenium-example")) == 0 + + +@pytest.mark.xfail_chrome +@pytest.mark.xfail_edge +class TestFirefoxWebExtension: + """Firefox-specific WebExtension tests.""" + + def test_install_extension_path(self, driver, pages): + path = os.path.join(EXTENSIONS, EXTENSION_PATH) + extension_id = install_extension(driver, ExtensionPath(path=path)) + verify_extension_injection(driver, pages) + uninstall_extension_and_verify_extension_uninstalled(driver, extension_id) + + def test_install_archive_extension_path(self, driver, pages): + path = os.path.join(EXTENSIONS, EXTENSION_ARCHIVE_PATH) + extension_id = install_extension(driver, ExtensionArchivePath(path=path)) + verify_extension_injection(driver, pages) + uninstall_extension_and_verify_extension_uninstalled(driver, extension_id) + + def test_install_base64_extension_path(self, driver, pages): + path = os.path.join(EXTENSIONS, EXTENSION_ARCHIVE_PATH) + with open(path, "rb") as file: + base64_encoded = base64.b64encode(file.read()).decode("utf-8") + extension_id = install_extension(driver, ExtensionBase64Encoded(value=base64_encoded)) + # TODO: the extension is installed but the script is not injected, check and fix + # verify_extension_injection(driver, pages) + uninstall_extension_and_verify_extension_uninstalled(driver, extension_id) + + def test_install_unsigned_extension(self, driver, pages): + path = os.path.join(EXTENSIONS, "webextensions-selenium-example") + extension_id = install_extension(driver, ExtensionPath(path=path)) + verify_extension_injection(driver, pages) + uninstall_extension_and_verify_extension_uninstalled(driver, extension_id) + + def test_install_with_extension_id_uninstall(self, driver, pages): + path = os.path.join(EXTENSIONS, EXTENSION_PATH) + extension_id = install_extension(driver, ExtensionPath(path=path)) + uninstall_extension_and_verify_extension_uninstalled(driver, extension_id) + + +@pytest.mark.xfail_firefox +class TestChromiumWebExtension: + """Chrome/Edge-specific WebExtension tests with custom driver.""" + + @pytest.fixture + def pages_chromium(self, webserver, chromium_driver): + class Pages: + def load(self, name): + chromium_driver.get(webserver.where_is(name, localhost=False)) + + return Pages() + + @pytest.fixture + def chromium_driver(self, chromium_options, request): + """Create a Chrome/Edge driver with webextension support enabled.""" + driver_option = request.config.option.drivers[0].lower() + + if driver_option == "chrome": + browser_class = webdriver.Chrome + browser_service = webdriver.ChromeService + elif driver_option == "edge": + browser_class = webdriver.Edge + browser_service = webdriver.EdgeService + + temp_dir = tempfile.mkdtemp(prefix="chromium-profile-") + + chromium_options.enable_bidi = True + chromium_options.enable_webextensions = True + chromium_options.add_argument(f"--user-data-dir={temp_dir}") + chromium_options.add_argument("--no-sandbox") + chromium_options.add_argument("--disable-dev-shm-usage") + + binary = _resolve_bazel_path(request.config.option.binary) + if binary: + chromium_options.binary_location = binary + + executable = _resolve_bazel_path(request.config.option.executable) + if executable: + service = browser_service(executable_path=executable) + else: + service = browser_service() + + chromium_driver = browser_class(options=chromium_options, service=service) + + yield chromium_driver + chromium_driver.quit() + + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + + def test_install_extension_path(self, chromium_driver, pages_chromium): + # Chromium assigns its own extension id, so we do not assert the Firefox EXTENSION_ID here. + path = os.path.join(EXTENSIONS, EXTENSION_PATH) + extension_id = WebExtension(chromium_driver).install(extension_data=ExtensionPath(path=path)).extension + verify_extension_injection(chromium_driver, pages_chromium) + uninstall_extension_and_verify_extension_uninstalled(chromium_driver, extension_id) + + def test_install_unsigned_extension(self, chromium_driver, pages_chromium): + path = os.path.join(EXTENSIONS, "webextensions-selenium-example") + extension_id = WebExtension(chromium_driver).install(extension_data=ExtensionPath(path=path)).extension + verify_extension_injection(chromium_driver, pages_chromium) + uninstall_extension_and_verify_extension_uninstalled(chromium_driver, extension_id) + + def test_install_with_extension_id_uninstall(self, chromium_driver): + path = os.path.join(EXTENSIONS, EXTENSION_PATH) + extension_id = WebExtension(chromium_driver).install(extension_data=ExtensionPath(path=path)).extension + uninstall_extension_and_verify_extension_uninstalled(chromium_driver, extension_id) diff --git a/py/test/selenium/webdriver/common/bidi_browser_tests.py b/py/test/selenium/webdriver/common/bidi/browser_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_browser_tests.py rename to py/test/selenium/webdriver/common/bidi/browser_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_browsing_context_tests.py b/py/test/selenium/webdriver/common/bidi/browsing_context_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_browsing_context_tests.py rename to py/test/selenium/webdriver/common/bidi/browsing_context_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_emulation_tests.py b/py/test/selenium/webdriver/common/bidi/emulation_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_emulation_tests.py rename to py/test/selenium/webdriver/common/bidi/emulation_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_errors_tests.py b/py/test/selenium/webdriver/common/bidi/errors_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_errors_tests.py rename to py/test/selenium/webdriver/common/bidi/errors_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_input_tests.py b/py/test/selenium/webdriver/common/bidi/input_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_input_tests.py rename to py/test/selenium/webdriver/common/bidi/input_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_integration_tests.py b/py/test/selenium/webdriver/common/bidi/integration_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_integration_tests.py rename to py/test/selenium/webdriver/common/bidi/integration_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_log_tests.py b/py/test/selenium/webdriver/common/bidi/log_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_log_tests.py rename to py/test/selenium/webdriver/common/bidi/log_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_network_tests.py b/py/test/selenium/webdriver/common/bidi/network_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_network_tests.py rename to py/test/selenium/webdriver/common/bidi/network_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_permissions_tests.py b/py/test/selenium/webdriver/common/bidi/permissions_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_permissions_tests.py rename to py/test/selenium/webdriver/common/bidi/permissions_tests.py diff --git a/py/test/selenium/webdriver/common/bidi/protocol_tests.py b/py/test/selenium/webdriver/common/bidi/protocol_tests.py new file mode 100644 index 0000000000000..f0bcd265442d6 --- /dev/null +++ b/py/test/selenium/webdriver/common/bidi/protocol_tests.py @@ -0,0 +1,70 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""End-to-end checks for the generated `selenium.webdriver.common._bidi` layer. + +These drive the generated protocol code directly against a real browser — the +one thing the serialization unit tests structurally cannot do, since a +fixture only ever confirms our model against itself. What a browser adds is +proof that the schema-derived field names, nesting, discriminators, and types +match what the browser actually sends, and that the strict inbound deserializer +accepts real payloads. Coverage is by wire *shape*, not by domain (the machinery +is uniform): a plain command result and a deeply nested union/record result. + +Event delivery is not covered here: the layer speaks commands (request/response) +only. Routing pushed events into their generated types is the facade piece ADR +17701 keeps out of scope, so there is nothing in `_bidi` to exercise yet. +""" + +from selenium.webdriver.common._bidi.browsing_context import ( + BrowsingContext, + CreateResult, + CreateType, + GetTreeResult, + Info, +) + + +def test_create_result_round_trips(driver): + """A plain command result deserializes into its generated value object.""" + context = BrowsingContext(driver) + + result = context.create(type=CreateType.TAB) + + assert isinstance(result, CreateResult) + assert isinstance(result.context, str) + assert result.context + + context.close(result.context) + + +def test_get_tree_deserializes_nested_records(driver, pages): + """A nested list[Info] result deserializes end-to-end from the browser.""" + driver.get(pages.url("simpleTest.html")) + + tree = BrowsingContext(driver).get_tree() + + assert isinstance(tree, GetTreeResult) + assert tree.contexts, "expected at least one browsing context" + + top = tree.contexts[0] + assert isinstance(top, Info) + assert isinstance(top.context, str) + assert top.context + assert isinstance(top.url, str) + # children is required-nullable: a real browser sends a list or null, never omits it. + assert top.children is None or all(isinstance(child, Info) for child in top.children) diff --git a/py/test/selenium/webdriver/common/bidi_quit_tests.py b/py/test/selenium/webdriver/common/bidi/quit_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_quit_tests.py rename to py/test/selenium/webdriver/common/bidi/quit_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_script_tests.py b/py/test/selenium/webdriver/common/bidi/script_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_script_tests.py rename to py/test/selenium/webdriver/common/bidi/script_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_session_tests.py b/py/test/selenium/webdriver/common/bidi/session_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_session_tests.py rename to py/test/selenium/webdriver/common/bidi/session_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_storage_tests.py b/py/test/selenium/webdriver/common/bidi/storage_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_storage_tests.py rename to py/test/selenium/webdriver/common/bidi/storage_tests.py diff --git a/py/test/selenium/webdriver/common/bidi_webextension_tests.py b/py/test/selenium/webdriver/common/bidi/webextension_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/bidi_webextension_tests.py rename to py/test/selenium/webdriver/common/bidi/webextension_tests.py diff --git a/py/test/unit/selenium/webdriver/common/bidi_protocol_command_tests.py b/py/test/unit/selenium/webdriver/common/bidi_protocol_command_tests.py new file mode 100644 index 0000000000000..38763742ea0f4 --- /dev/null +++ b/py/test/unit/selenium/webdriver/common/bidi_protocol_command_tests.py @@ -0,0 +1,185 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Unit tests for the generated BiDi command surface. + +Drives the actual generated domain classes through a stand-in transport — no +browser — to prove the schema-derived methods build and serialize their params, +validate enum/discriminator arguments before any wire call, dispatch to the +right command, and parse the reply into the generated result type. The runtime +itself is covered by bidi_serialization_tests; here the subject is the generated +code that sits on top of it. +""" + +import pytest + +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.common._bidi.browsing_context import ( + BrowsingContext, + CreateResult, + CreateType, + GetTreeResult, + Info, +) +from selenium.webdriver.common._bidi.network import Cookie, InterceptPhase, Network +from selenium.webdriver.common._bidi.serialization import BiDiSerializationError +from selenium.webdriver.common._bidi.storage import PartialCookie +from selenium.webdriver.common._bidi.transport import Transport + + +class DrivingConnection: + """Stands in for WebSocketConnection's ``send_cmd``, minus the socket. + + Records the outbound frame (or leaves it None if no command was ever sent) + and returns a canned reply envelope. + """ + + def __init__(self, reply=None): + self.reply = reply + self.sent = None + + def send_cmd(self, method, params): + self.sent = {"method": method, "params": params} + return {"result": self.reply} + + +def _domain(cls, reply=None): + connection = DrivingConnection(reply=reply) + return cls(Transport(connection)), connection + + +# --- dispatch + result parsing --- + + +def test_a_generated_command_sends_its_frame_and_parses_its_result(): + context, connection = _domain(BrowsingContext, reply={"context": "ctx-1"}) + + result = context.create(type=CreateType.TAB) + + assert connection.sent == {"method": "browsingContext.create", "params": {"type": "tab"}} + assert result == CreateResult(context="ctx-1") + + +def test_a_nested_union_result_is_deserialized_into_generated_types(): + reply = { + "contexts": [ + { + "children": None, + "clientWindow": "w1", + "context": "c1", + "originalOpener": None, + "url": "about:blank", + "userContext": "default", + } + ] + } + context, _ = _domain(BrowsingContext, reply=reply) + + tree = context.get_tree() + + assert isinstance(tree, GetTreeResult) + assert isinstance(tree.contexts[0], Info) + assert tree.contexts[0].context == "c1" + + +# --- argument validation happens before any wire call --- + + +def test_a_scalar_enum_argument_outside_the_set_raises_before_any_wire_call(): + context, connection = _domain(BrowsingContext) + + with pytest.raises(BiDiSerializationError, match=r"not a valid CreateType"): + context.create(type="sideways") + + assert connection.sent is None + + +def test_each_element_of_a_list_enum_argument_is_validated(): + network, connection = _domain(Network) + + with pytest.raises(BiDiSerializationError, match=r"not a valid InterceptPhase"): + network.add_intercept(phases=[InterceptPhase.AUTH_REQUIRED, "nope"]) + + assert connection.sent is None + + +def test_a_union_discriminator_outside_the_set_raises_before_any_wire_call(): + network, connection = _domain(Network) + + with pytest.raises(BiDiSerializationError, match=r"not a valid discriminator"): + network.continue_with_auth(request="r", action="bogus") + + assert connection.sent is None + + +# --- valid arguments serialize and dispatch --- + + +def test_a_valid_list_enum_argument_is_serialized_to_wire_tokens(): + network, connection = _domain(Network, reply={"intercept": "i-1"}) + + network.add_intercept(phases=[InterceptPhase.AUTH_REQUIRED]) + + assert connection.sent["method"] == "network.addIntercept" + assert connection.sent["params"]["phases"] == ["authRequired"] + + +def test_a_union_discriminator_selects_and_sends_its_variant(): + network, connection = _domain(Network, reply={}) + + network.continue_with_auth(request="r", action="cancel") + + assert connection.sent == {"method": "network.continueWithAuth", "params": {"request": "r", "action": "cancel"}} + + +# --- extras preserved only for a re-sendable extensible type (ADR item 8) --- + +_STRING_VALUE = {"type": "string", "value": "v"} + + +def test_a_re_sendable_extensible_type_round_trips_unknown_wire_keys(): + # storage.PartialCookie is reachable from a command's params, so it keeps and echoes extras. + cookie = PartialCookie.from_json({"name": "n", "value": _STRING_VALUE, "domain": "d", "vendorSpecific": "x"}) + assert cookie.extensions == {"vendorSpecific": "x"} + assert cookie.as_json()["vendorSpecific"] == "x" + + +def test_a_received_only_extensible_type_drops_unknown_wire_keys(): + # network.Cookie is only ever received, never sent back, so unknown keys are ignored, not stored. + cookie = Cookie.from_json( + { + "name": "n", + "value": _STRING_VALUE, + "domain": "d", + "path": "/", + "size": 1, + "httpOnly": False, + "secure": True, + "sameSite": "lax", + "vendorSpecific": "x", + } + ) + assert not hasattr(cookie, "extensions") + assert "vendorSpecific" not in cookie.as_json() + + +# --- construction --- + + +def test_a_generated_domain_requires_a_driver_or_transport(): + with pytest.raises(WebDriverException, match=r"a WebDriver or Transport is required"): + BrowsingContext(object()) diff --git a/py/test/unit/selenium/webdriver/common/bidi_serialization_tests.py b/py/test/unit/selenium/webdriver/common/bidi_serialization_tests.py new file mode 100644 index 0000000000000..8adf68d3651f5 --- /dev/null +++ b/py/test/unit/selenium/webdriver/common/bidi_serialization_tests.py @@ -0,0 +1,453 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Unit tests for the hand-written BiDi serialization runtime. + +Exercises `selenium.webdriver.common._bidi.serialization` — the Record/Union/UNSET +machinery every generated module builds on — in isolation, using small value +types declared here the way the generator emits them. The generated modules are +covered by the command-surface and browser round-trip tests; the subject here is +the runtime's own rules: strict inbound validation, UNSET omit-vs-null, and union +dispatch. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +import pytest + +from selenium.webdriver.common._bidi.serialization import ( + UNSET, + BiDiSerializationError, + Record, + Union, + UnsetType, + meta, + register, + resolve, +) + +# --- fixtures: value types declared the way the generator emits them --- + + +@register("test.Color") +class Color(str, Enum): + RED = "red" + BLUE = "blue" + + +@register("test.Point") +@dataclass(frozen=True) +class Point(Record): + x: int = field(metadata=meta("x", required=True, primitive="int")) + y: int = field(metadata=meta("y", required=True, primitive="int")) + + +@register("test.Optionals") +@dataclass(frozen=True) +class Optionals(Record): + req: str = field(metadata=meta("req", required=True, primitive="str")) + nullable: str | None = field(metadata=meta("nullable", required=True, nullable=True, primitive="str")) + opt: str | UnsetType = field(default=UNSET, metadata=meta("opt", primitive="str")) + + +@register("test.Scalars") +@dataclass(frozen=True) +class Scalars(Record): + count: int = field(metadata=meta("count", required=True, primitive="int")) + ratio: float = field(metadata=meta("ratio", required=True, primitive="float")) + flag: bool = field(metadata=meta("flag", required=True, primitive="bool")) + name: str = field(metadata=meta("name", required=True, primitive="str")) + + +@register("test.Painted") +@dataclass(frozen=True) +class Painted(Record): + color: Color = field(metadata=meta("color", required=True, enum="test.Color")) + + +@register("test.Tags") +@dataclass(frozen=True) +class Tags(Record): + tags: list[str] = field(metadata=meta("tags", required=True, is_list=True, primitive="str")) + + +@register("test.Line") +@dataclass(frozen=True) +class Line(Record): + start: Point = field(metadata=meta("start", required=True, ref="test.Point")) + + +@register("test.Path") +@dataclass(frozen=True) +class Path(Record): + points: list[Point] = field(metadata=meta("points", required=True, ref="test.Point", is_list=True)) + + +@register("test.Extensible") +@dataclass(frozen=True) +class Extensible(Record): + _EXTENSIBLE = True + + known: str = field(metadata=meta("known", required=True, primitive="str")) + extensions: dict[str, Any] | UnsetType = field(default=UNSET, metadata=meta("extensions")) + + +@register("test.Cat") +@dataclass(frozen=True) +class Cat(Record): + meow: str = field(metadata=meta("meow", required=True, primitive="str")) + kind: str = field(default="cat", init=False, metadata=meta("kind", required=True, fixed="cat")) + + +@register("test.Dog") +@dataclass(frozen=True) +class Dog(Record): + bark: str = field(metadata=meta("bark", required=True, primitive="str")) + kind: str = field(default="dog", init=False, metadata=meta("kind", required=True, fixed="dog")) + + +@register("test.Animal") +class Animal(Union): + _DISCRIMINATOR = "kind" + _VARIANTS = {"cat": "test.Cat", "dog": "test.Dog"} + _DISCRIMINATOR_VALUES = frozenset({"cat", "dog"}) + + +@register("test.Fallback") +class Fallback(Union): + _DISCRIMINATOR = "kind" + _VARIANTS = {"cat": "test.Cat"} + _FALLBACK = "test.Dog" + _DISCRIMINATOR_VALUES = frozenset({"cat"}) + + +@register("test.Circle") +@dataclass(frozen=True) +class Circle(Record): + radius: int = field(metadata=meta("radius", required=True, primitive="int")) + + +@register("test.Rect") +@dataclass(frozen=True) +class Rect(Record): + width: int = field(metadata=meta("width", required=True, primitive="int")) + height: int = field(metadata=meta("height", required=True, primitive="int")) + + +@register("test.Shape") +class Shape(Union): + _PRESENCE = (("test.Circle", ("radius",)), ("test.Rect", ("width", "height"))) + + +@register("test.BareOrObject") +class BareOrObject(Union): + _DISCRIMINATOR = "type" + _VARIANTS = {} + + +@register("test.ObjectOnly") +class ObjectOnly(Union): + _DISCRIMINATOR = "kind" + _VARIANTS = {"cat": "test.Cat", "dog": "test.Dog"} + _OBJECT_ONLY = True + + +@register("test.StringMap") +@dataclass(frozen=True) +class StringMap(Record): + # A map encoded as [key, value] pairs the way the generator emits one: the value + # type is an object-only union and `scalar` lets a bare-string key pass through + # (an object key still deserializes), while the value must stay an object. + value: list[list[Any]] = field( + metadata=meta("value", required=True, ref="test.ObjectOnly", is_list=True, scalar="str") + ) + + +# --- UNSET sentinel --- + + +def test_unset_is_a_singleton(): + assert UnsetType() is UNSET + + +def test_unset_is_falsy(): + assert not UNSET + + +def test_unset_repr(): + assert repr(UNSET) == "UNSET" + + +# --- registry --- + + +def test_resolve_returns_the_registered_class(): + assert resolve("test.Point") is Point + + +def test_resolve_unknown_raises(): + with pytest.raises(BiDiSerializationError, match=r"unknown BiDi type 'test.Nope'"): + resolve("test.Nope") + + +# --- outbound: as_json --- + + +def test_as_json_maps_field_names_to_wire_keys(): + assert Point(x=1, y=2).as_json() == {"x": 1, "y": 2} + + +def test_as_json_omits_unset_but_emits_explicit_null_for_nullable(): + assert Optionals(req="r", nullable=None).as_json() == {"req": "r", "nullable": None} + + +def test_round_trips_through_the_wire(): + assert Point.from_json(Point(x=1, y=2).as_json()) == Point(x=1, y=2) + + +# --- inbound: required / optional / null --- + + +def test_missing_required_field_raises(): + with pytest.raises(BiDiSerializationError, match=r"Point.y missing required 'y'"): + Point.from_json({"x": 1}) + + +def test_missing_optional_field_becomes_unset(): + assert Optionals.from_json({"req": "r", "nullable": "n"}).opt is UNSET + + +def test_explicit_null_for_a_nullable_field_becomes_none(): + assert Optionals.from_json({"req": "r", "nullable": None}).nullable is None + + +def test_explicit_null_for_a_non_nullable_field_raises(): + with pytest.raises(BiDiSerializationError, match=r"Point.x received null but is not nullable"): + Point.from_json({"x": None, "y": 2}) + + +# --- construction-time validation (__post_init__) --- + + +def test_constructing_with_none_for_a_non_nullable_field_raises(): + with pytest.raises(BiDiSerializationError, match=r"Point.x cannot be None"): + Point(x=None, y=2) + + +def test_constructing_with_an_invalid_enum_value_raises(): + with pytest.raises(BiDiSerializationError, match=r"is not a valid Color"): + Painted(color="green") + + +def test_constructing_with_a_valid_enum_member_is_accepted(): + assert Painted(color=Color.RED).as_json() == {"color": "red"} + + +# --- strict primitive checks (inbound) --- + + +def test_integer_rejects_a_fractional_float(): + with pytest.raises(BiDiSerializationError, match=r"expected int"): + Scalars.from_json({"count": 1.5, "ratio": 1.0, "flag": True, "name": "n"}) + + +def test_integer_rejects_even_a_whole_valued_float(): + with pytest.raises(BiDiSerializationError, match=r"expected int"): + Scalars.from_json({"count": 5.0, "ratio": 1.0, "flag": True, "name": "n"}) + + +def test_integer_rejects_a_bool(): + with pytest.raises(BiDiSerializationError, match=r"expected int"): + Scalars.from_json({"count": True, "ratio": 1.0, "flag": True, "name": "n"}) + + +def test_number_accepts_both_int_and_float(): + assert Scalars.from_json({"count": 1, "ratio": 2, "flag": True, "name": "n"}).ratio == 2 + assert Scalars.from_json({"count": 1, "ratio": 2.5, "flag": True, "name": "n"}).ratio == 2.5 + + +def test_number_rejects_a_bool(): + with pytest.raises(BiDiSerializationError, match=r"expected float"): + Scalars.from_json({"count": 1, "ratio": True, "flag": True, "name": "n"}) + + +def test_bool_accepts_a_bool_but_rejects_an_int(): + assert Scalars.from_json({"count": 1, "ratio": 1.0, "flag": False, "name": "n"}).flag is False + with pytest.raises(BiDiSerializationError, match=r"expected bool"): + Scalars.from_json({"count": 1, "ratio": 1.0, "flag": 1, "name": "n"}) + + +def test_string_rejects_a_number(): + with pytest.raises(BiDiSerializationError, match=r"expected str"): + Scalars.from_json({"count": 1, "ratio": 1.0, "flag": True, "name": 3}) + + +# --- list vs scalar shape --- + + +def test_a_scalar_for_a_list_field_raises(): + with pytest.raises(BiDiSerializationError, match=r"expected a list"): + Tags.from_json({"tags": "a"}) + + +def test_a_list_for_a_scalar_field_raises(): + with pytest.raises(BiDiSerializationError, match=r"expected a single value"): + Point.from_json({"x": [1], "y": 2}) + + +def test_a_list_of_primitives_round_trips(): + assert Tags.from_json({"tags": ["a", "b"]}).tags == ["a", "b"] + + +# --- nested refs --- + + +def test_a_nested_record_round_trips(): + line = Line.from_json({"start": {"x": 1, "y": 2}}) + assert line.start == Point(x=1, y=2) + assert line.as_json() == {"start": {"x": 1, "y": 2}} + + +def test_a_non_object_for_a_record_ref_raises(): + with pytest.raises(BiDiSerializationError, match=r"Line.start: expected an object"): + Line.from_json({"start": "nope"}) + + +def test_a_list_of_records_round_trips(): + path = Path.from_json({"points": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]}) + assert path.points == [Point(x=1, y=2), Point(x=3, y=4)] + + +# --- enum inbound --- + + +def test_an_inbound_enum_token_is_restored_to_its_member(): + assert Painted.from_json({"color": "blue"}).color is Color.BLUE + + +def test_an_inbound_enum_value_outside_the_schema_raises(): + with pytest.raises(BiDiSerializationError, match=r"is not a valid Color"): + Painted.from_json({"color": "green"}) + + +# --- extensible records --- + + +def test_an_extensible_record_captures_unknown_keys(): + assert Extensible.from_json({"known": "k", "extra": "e", "more": 1}).extensions == {"extra": "e", "more": 1} + + +def test_an_extensible_record_merges_captured_keys_back_on_serialization(): + assert Extensible(known="k", extensions={"extra": "e"}).as_json() == {"known": "k", "extra": "e"} + + +def test_a_non_extensible_record_ignores_unknown_keys(): + assert Point.from_json({"x": 1, "y": 2, "z": 3}) == Point(x=1, y=2) + + +# --- union dispatch: inbound --- + + +def test_a_discriminated_union_dispatches_by_its_tag(): + assert Animal.from_json({"kind": "cat", "meow": "hi"}) == Cat(meow="hi") + + +def test_a_structural_union_dispatches_by_which_fields_are_present(): + assert Shape.from_json({"radius": 5}) == Circle(radius=5) + assert Shape.from_json({"width": 3, "height": 4}) == Rect(width=3, height=4) + + +def test_an_unknown_discriminator_falls_back_to_the_declared_variant(): + assert Fallback.from_json({"kind": "fish", "bark": "woof"}) == Dog(bark="woof") + + +def test_a_bare_scalar_arm_is_returned_unchanged(): + assert BareOrObject.from_json("viewport") == "viewport" + + +def test_a_variant_outside_the_schema_raises_instead_of_passing_through(): + with pytest.raises(BiDiSerializationError, match=r"not in this Selenium's BiDi schema"): + Animal.from_json({"kind": "fish"}) + + +# --- object-only unions: a non-object payload is a wire error, not a scalar arm --- + + +def test_an_object_only_union_still_dispatches_an_object(): + assert ObjectOnly.from_json({"kind": "cat", "meow": "hi"}) == Cat(meow="hi") + + +def test_an_object_only_union_rejects_a_non_object(): + with pytest.raises(BiDiSerializationError, match=r"expected an object on the wire"): + ObjectOnly.from_json("viewport") + + +# --- scalar map entries: object-only value, but a bare-string key still passes --- + + +def test_a_map_entry_deserializes_object_key_and_value(): + payload = {"value": [[{"kind": "cat", "meow": "a"}, {"kind": "dog", "bark": "b"}]]} + assert StringMap.from_json(payload).value == [[Cat(meow="a"), Dog(bark="b")]] + + +def test_a_map_entry_passes_a_bare_string_key_through(): + assert StringMap.from_json({"value": [["k", {"kind": "cat", "meow": "a"}]]}).value == [["k", Cat(meow="a")]] + + +def test_a_map_entry_rejects_a_wrong_typed_scalar_key(): + with pytest.raises(BiDiSerializationError, match=r"map key expected str"): + StringMap.from_json({"value": [[5, {"kind": "cat", "meow": "a"}]]}) + + +def test_a_map_entry_value_is_object_only(): + with pytest.raises(BiDiSerializationError, match=r"expected an object on the wire"): + StringMap.from_json({"value": [["k", "not-an-object"]]}) + + +def test_a_malformed_map_entry_raises(): + with pytest.raises(BiDiSerializationError, match=r"expected a \[key, value\] pair"): + StringMap.from_json({"value": [["k"]]}) + + +# --- union dispatch: outbound (build) --- + + +def test_build_selects_a_variant_by_its_discriminator_and_bakes_the_tag(): + animal = Animal.build(kind="cat", meow="hi") + assert animal == Cat(meow="hi") + assert animal.kind == "cat" + + +def test_build_selects_a_variant_by_which_fields_are_supplied(): + assert Shape.build(radius=5) == Circle(radius=5) + + +def test_build_rejects_a_discriminator_value_outside_the_allowed_set(): + with pytest.raises(BiDiSerializationError, match=r"not a valid discriminator"): + Animal.build(kind="fish", meow="hi") + + +def test_build_rejects_a_field_that_does_not_belong_to_the_selected_variant(): + with pytest.raises(BiDiSerializationError, match=r"invalid combination.*bark"): + Animal.build(kind="cat", bark="woof") + + +def test_build_raises_when_no_variant_matches(): + with pytest.raises(BiDiSerializationError, match=r"no Shape variant matches"): + Shape.build(depth=5) diff --git a/py/test/unit/selenium/webdriver/common/bidi_transport_tests.py b/py/test/unit/selenium/webdriver/common/bidi_transport_tests.py new file mode 100644 index 0000000000000..ff02515ce7736 --- /dev/null +++ b/py/test/unit/selenium/webdriver/common/bidi_transport_tests.py @@ -0,0 +1,147 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Unit tests for the hand-written BiDi transport seam. + +Exercises `selenium.webdriver.common._bidi.transport` — the `Transport` that turns a +command into a wire frame and back, and the `Domain` base every generated module +subclasses. The websocket is stood in by `DrivingConnection`, which drives the +command coroutine with the exact next()/send() protocol `WebSocketConnection` +uses, so the seam is tested against the real contract rather than a mock of it. +""" + +from dataclasses import dataclass, field + +import pytest + +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.common._bidi.domain import Domain +from selenium.webdriver.common._bidi.serialization import Record, meta, register +from selenium.webdriver.common._bidi.transport import Transport + + +@register("test_transport.Params") +@dataclass(frozen=True) +class Params(Record): + context: str = field(metadata=meta("context", required=True, primitive="str")) + + +@register("test_transport.Result") +@dataclass(frozen=True) +class Result(Record): + value: str = field(metadata=meta("value", required=True, primitive="str")) + + +class DrivingConnection: + """Stands in for WebSocketConnection's ``send_cmd``, minus the socket. + + Records the outbound frame and returns a canned reply envelope, so the seam is + tested against the real send/reply contract rather than a mock of it. + """ + + def __init__(self, reply=None): + self.reply = reply + self.sent = None + + def send_cmd(self, method, params): + self.sent = {"method": method, "params": params} + return {"result": self.reply} + + +class DriverWithTransport: + def __init__(self, transport): + self._bidi_transport = transport + + +class DriverThatStartsBiDi: + def __init__(self, transport): + self._transport = transport + self._bidi_transport = None + self.started = False + + def _start_bidi(self): + self.started = True + self._bidi_transport = self._transport + + +# --- Transport --- + + +def test_execute_sends_the_method_and_serialized_params(): + connection = DrivingConnection(reply={"value": "ok"}) + + result = Transport(connection).execute("some.command", params=Params(context="c"), result=Result) + + assert connection.sent == {"method": "some.command", "params": {"context": "c"}} + assert result == Result(value="ok") + + +def test_execute_with_no_params_sends_an_empty_params_object(): + connection = DrivingConnection(reply={"value": "ok"}) + + Transport(connection).execute("some.command", result=Result) + + assert connection.sent == {"method": "some.command", "params": {}} + + +def test_execute_with_no_result_type_returns_the_raw_reply(): + connection = DrivingConnection(reply={"anything": 1}) + + result = Transport(connection).execute("some.command", params=Params(context="c")) + + assert result == {"anything": 1} + + +# --- Domain seam --- + + +def test_domain_accepts_a_transport_directly(): + connection = DrivingConnection(reply={"value": "ok"}) + + domain = Domain(Transport(connection)) + + assert domain._execute("cmd", params=Params(context="c"), result=Result) == Result(value="ok") + + +def test_domain_reuses_the_drivers_shared_transport(): + transport = Transport(DrivingConnection(reply={"value": "ok"})) + + domain = Domain(DriverWithTransport(transport)) + + assert domain._transport is transport + + +def test_domain_starts_bidi_when_the_transport_is_not_open_yet(): + transport = Transport(DrivingConnection(reply={"value": "ok"})) + driver = DriverThatStartsBiDi(transport) + + domain = Domain(driver) + + assert driver.started + assert domain._transport is transport + assert domain._execute("cmd", params=Params(context="c"), result=Result) == Result(value="ok") + + +def test_domain_without_a_transport_or_a_way_to_start_one_raises(): + with pytest.raises(WebDriverException, match=r"a WebDriver or Transport is required"): + Domain(object()) + + +def test_domains_from_the_same_driver_share_its_transport(): + driver = DriverWithTransport(Transport(DrivingConnection())) + + assert Domain(driver)._transport is Domain(driver)._transport diff --git a/rake_tasks/python.rake b/rake_tasks/python.rake index a0cd89ef18745..b1434a6ca45aa 100644 --- a/rake_tasks/python.rake +++ b/rake_tasks/python.rake @@ -76,7 +76,7 @@ task :local_dev, [:all] do |_task, arguments| dirs = Dir.children(bazel_bin) files = [] else - dirs = %w[common/bidi common/devtools] + dirs = %w[common/bidi common/_bidi common/devtools] files = %w[ remote/getAttribute.js remote/isDisplayed.js remote/findElements.js common/mutation-listener.js common/bidi-mutation-listener.js