Add dependencies for aiohttp
diff --git a/py/BUILD.bazel b/py/BUILD.bazel
index 0358708..c496535 100644
--- a/py/BUILD.bazel
+++ b/py/BUILD.bazel
@@ -46,6 +46,7 @@
 ]
 
 TEST_DEPS = [
+    requirement("aiohttp"),
     requirement("attrs"),
     requirement("debugpy"),
     requirement("idna"),
@@ -58,6 +59,7 @@
     requirement("pluggy"),
     requirement("py"),
     requirement("pytest"),
+    requirement("pytest-asyncio"),
     requirement("pytest-instafail"),
     requirement("pytest-trio"),
     requirement("pytest-mock"),
@@ -170,6 +172,7 @@
     imports = ["."],
     visibility = ["//visibility:public"],
     deps = [
+        requirement("aiohttp"),
         requirement("typing_extensions"),
         requirement("trio"),
         requirement("trio_websocket"),
@@ -184,6 +187,7 @@
         "py.selenium",
         "py.selenium.common",
         "py.selenium.webdriver",
+        "py.selenium.webdriver.async",
         "py.selenium.webdriver.chrome",
         "py.selenium.webdriver.chromium",
         "py.selenium.webdriver.common",
@@ -254,6 +258,7 @@
     python_requires = ">=3.8",
     python_tag = "py3",
     requires = [
+        "aiohttp=3.8.5",
         "urllib3[socks]>=1.26,<3",
         "trio~=0.17",
         "trio-websocket~=0.9",
@@ -279,6 +284,7 @@
         "test/__init__.py",
         "test/selenium/__init__.py",
         "test/selenium/webdriver/__init__.py",
+        "test/selenium/webdriver/async/__init__.py",
         "test/selenium/webdriver/chrome/__init__.py",
         "test/selenium/webdriver/common/__init__.py",
         "test/selenium/webdriver/common/conftest.py",
@@ -357,6 +363,7 @@
         size = "large",
         srcs = glob(
             [
+                "test/selenium/webdriver/async/**/*.py",
                 "test/selenium/webdriver/common/**/*.py",
                 "test/selenium/webdriver/support/**/*.py",
             ],
@@ -385,6 +392,9 @@
     srcs = glob(
         [
             "test/selenium/webdriver/chrome/**/*.py",
+            "test/selenium/webdriver/async/**/*.py",
+            "test/selenium/webdriver/common/**/*.py",
+            "test/selenium/webdriver/support/**/*.py",
         ],
         exclude = ["test/selenium/webdriver/common/print_pdf_tests.py"],
     ),
diff --git a/py/conftest.py b/py/conftest.py
index 562325b..4bf0e60 100644
--- a/py/conftest.py
+++ b/py/conftest.py
@@ -25,6 +25,7 @@
 from urllib.request import urlopen
 
 import pytest
+import pytest_asyncio
 
 from selenium import webdriver
 
@@ -94,12 +95,14 @@
 driver_instance = None
 
 
-@pytest.fixture(scope="function")
+@pytest_asyncio.fixture(scope="function")
 def driver(request):
     kwargs = {}
 
-    # browser can be changed with `--driver=firefox` as an argument or to addopts in pytest.ini
-    driver_class = getattr(request, "param", "Chrome").capitalize()
+    try:
+        driver_class = request.config.option.drivers[0].capitalize()
+    except AttributeError:
+        raise Exception("This test requires a --driver to be specified.")
 
     # skip tests if not available on the platform
     _platform = platform.system()
@@ -141,7 +144,9 @@
         if driver_class == "Chrome":
             options = get_options(driver_class, request.config)
         if driver_class == "Remote":
-            options = get_options("Firefox", request.config) or webdriver.FirefoxOptions()
+            options = (
+                get_options("Firefox", request.config) or webdriver.FirefoxOptions()
+            )
             options.set_capability("moz:firefoxOptions", {})
             options.enable_downloads = True
         if driver_class == "WebKitGTK":
@@ -206,7 +211,7 @@
 
 
 @pytest.fixture(scope="session", autouse=True)
-def stop_driver(request):
+async def stop_driver(request):
     def fin():
         global driver_instance
         if driver_instance is not None:
@@ -230,8 +235,8 @@
         def url(self, name, localhost=False):
             return webserver.where_is(name, localhost)
 
-        def load(self, name):
-            driver.get(self.url(name))
+        async def load(self, name):
+            await driver.get(self.url(name))
 
     return Pages()
 
@@ -285,7 +290,9 @@
             ]
         )
         print(f"Selenium server running as process: {process.pid}")
-        assert wait_for_server(url, 10), f"Timed out waiting for Selenium server at {url}"
+        assert wait_for_server(
+            url, 10
+        ), f"Timed out waiting for Selenium server at {url}"
         print("Selenium server is ready")
         yield process
         process.terminate()
@@ -304,7 +311,7 @@
 
 
 @pytest.fixture
-def edge_service():
+async def edge_service():
     from selenium.webdriver.edge.service import Service as EdgeService
 
     return EdgeService
diff --git a/py/pytest.ini b/py/pytest.ini
index e84b365..5c08303 100644
--- a/py/pytest.ini
+++ b/py/pytest.ini
@@ -2,7 +2,6 @@
 console_output_style = progress
 faulthandler_timeout = 60
 log_cli = True
-trio_mode = true
 markers =
     xfail_chrome: Tests expected to fail in Chrome
     xfail_chromiumedge: Tests expected to fail in Chromium Edge
diff --git a/py/requirements.txt b/py/requirements.txt
index 4123169..d1b0801 100644
--- a/py/requirements.txt
+++ b/py/requirements.txt
@@ -1,3 +1,4 @@
+aiohttp==3.8.5
 async-generator==1.10
 attrs==23.1.0
 certifi==2023.7.22
@@ -20,7 +21,8 @@
 pyOpenSSL==22.0.0
 pyparsing==3.1.1
 PySocks==1.7.1
-pytest==7.4.2
+pytest==7.4.3
+pytest-asyncio==0.21.1
 pytest-instafail==0.5.0
 pytest-mock==3.12.0
 pytest-trio==0.8.0
diff --git a/py/requirements_lock.txt b/py/requirements_lock.txt
index 2d4f373..40b28aa 100644
--- a/py/requirements_lock.txt
+++ b/py/requirements_lock.txt
@@ -4,6 +4,99 @@
 #
 #    bazel run //py:requirements.update
 #
+aiohttp==3.8.5 \
+    --hash=sha256:00ad4b6f185ec67f3e6562e8a1d2b69660be43070bd0ef6fcec5211154c7df67 \
+    --hash=sha256:0175d745d9e85c40dcc51c8f88c74bfbaef9e7afeeeb9d03c37977270303064c \
+    --hash=sha256:01d4c0c874aa4ddfb8098e85d10b5e875a70adc63db91f1ae65a4b04d3344cda \
+    --hash=sha256:043d2299f6dfdc92f0ac5e995dfc56668e1587cea7f9aa9d8a78a1b6554e5755 \
+    --hash=sha256:0c413c633d0512df4dc7fd2373ec06cc6a815b7b6d6c2f208ada7e9e93a5061d \
+    --hash=sha256:0d21c684808288a98914e5aaf2a7c6a3179d4df11d249799c32d1808e79503b5 \
+    --hash=sha256:0e584a10f204a617d71d359fe383406305a4b595b333721fa50b867b4a0a1548 \
+    --hash=sha256:1274477e4c71ce8cfe6c1ec2f806d57c015ebf84d83373676036e256bc55d690 \
+    --hash=sha256:13bf85afc99ce6f9ee3567b04501f18f9f8dbbb2ea11ed1a2e079670403a7c84 \
+    --hash=sha256:153c2549f6c004d2754cc60603d4668899c9895b8a89397444a9c4efa282aaf4 \
+    --hash=sha256:1f7372f7341fcc16f57b2caded43e81ddd18df53320b6f9f042acad41f8e049a \
+    --hash=sha256:23fb25a9f0a1ca1f24c0a371523546366bb642397c94ab45ad3aedf2941cec6a \
+    --hash=sha256:28c543e54710d6158fc6f439296c7865b29e0b616629767e685a7185fab4a6b9 \
+    --hash=sha256:2a482e6da906d5e6e653be079b29bc173a48e381600161c9932d89dfae5942ef \
+    --hash=sha256:2ad5c3c4590bb3cc28b4382f031f3783f25ec223557124c68754a2231d989e2b \
+    --hash=sha256:2ce2ac5708501afc4847221a521f7e4b245abf5178cf5ddae9d5b3856ddb2f3a \
+    --hash=sha256:2cf57fb50be5f52bda004b8893e63b48530ed9f0d6c96c84620dc92fe3cd9b9d \
+    --hash=sha256:2e1b1e51b0774408f091d268648e3d57f7260c1682e7d3a63cb00d22d71bb945 \
+    --hash=sha256:2e2e9839e14dd5308ee773c97115f1e0a1cb1d75cbeeee9f33824fa5144c7634 \
+    --hash=sha256:2e460be6978fc24e3df83193dc0cc4de46c9909ed92dd47d349a452ef49325b7 \
+    --hash=sha256:312fcfbacc7880a8da0ae8b6abc6cc7d752e9caa0051a53d217a650b25e9a691 \
+    --hash=sha256:33279701c04351a2914e1100b62b2a7fdb9a25995c4a104259f9a5ead7ed4802 \
+    --hash=sha256:33776e945d89b29251b33a7e7d006ce86447b2cfd66db5e5ded4e5cd0340585c \
+    --hash=sha256:34dd0c107799dcbbf7d48b53be761a013c0adf5571bf50c4ecad5643fe9cfcd0 \
+    --hash=sha256:3562b06567c06439d8b447037bb655ef69786c590b1de86c7ab81efe1c9c15d8 \
+    --hash=sha256:368a42363c4d70ab52c2c6420a57f190ed3dfaca6a1b19afda8165ee16416a82 \
+    --hash=sha256:4149d34c32f9638f38f544b3977a4c24052042affa895352d3636fa8bffd030a \
+    --hash=sha256:461908b2578955045efde733719d62f2b649c404189a09a632d245b445c9c975 \
+    --hash=sha256:4a01951fabc4ce26ab791da5f3f24dca6d9a6f24121746eb19756416ff2d881b \
+    --hash=sha256:4e874cbf8caf8959d2adf572a78bba17cb0e9d7e51bb83d86a3697b686a0ab4d \
+    --hash=sha256:4f21e83f355643c345177a5d1d8079f9f28b5133bcd154193b799d380331d5d3 \
+    --hash=sha256:5443910d662db951b2e58eb70b0fbe6b6e2ae613477129a5805d0b66c54b6cb7 \
+    --hash=sha256:5798a9aad1879f626589f3df0f8b79b3608a92e9beab10e5fda02c8a2c60db2e \
+    --hash=sha256:5d20003b635fc6ae3f96d7260281dfaf1894fc3aa24d1888a9b2628e97c241e5 \
+    --hash=sha256:5db3a5b833764280ed7618393832e0853e40f3d3e9aa128ac0ba0f8278d08649 \
+    --hash=sha256:5ed1c46fb119f1b59304b5ec89f834f07124cd23ae5b74288e364477641060ff \
+    --hash=sha256:62360cb771707cb70a6fd114b9871d20d7dd2163a0feafe43fd115cfe4fe845e \
+    --hash=sha256:6809a00deaf3810e38c628e9a33271892f815b853605a936e2e9e5129762356c \
+    --hash=sha256:68c5a82c8779bdfc6367c967a4a1b2aa52cd3595388bf5961a62158ee8a59e22 \
+    --hash=sha256:6e4a280e4b975a2e7745573e3fc9c9ba0d1194a3738ce1cbaa80626cc9b4f4df \
+    --hash=sha256:6e6783bcc45f397fdebc118d772103d751b54cddf5b60fbcc958382d7dd64f3e \
+    --hash=sha256:72a860c215e26192379f57cae5ab12b168b75db8271f111019509a1196dfc780 \
+    --hash=sha256:7607ec3ce4993464368505888af5beb446845a014bc676d349efec0e05085905 \
+    --hash=sha256:773dd01706d4db536335fcfae6ea2440a70ceb03dd3e7378f3e815b03c97ab51 \
+    --hash=sha256:78d847e4cde6ecc19125ccbc9bfac4a7ab37c234dd88fbb3c5c524e8e14da543 \
+    --hash=sha256:7dde0009408969a43b04c16cbbe252c4f5ef4574ac226bc8815cd7342d2028b6 \
+    --hash=sha256:80bd372b8d0715c66c974cf57fe363621a02f359f1ec81cba97366948c7fc873 \
+    --hash=sha256:841cd8233cbd2111a0ef0a522ce016357c5e3aff8a8ce92bcfa14cef890d698f \
+    --hash=sha256:84de26ddf621d7ac4c975dbea4c945860e08cccde492269db4e1538a6a6f3c35 \
+    --hash=sha256:84f8ae3e09a34f35c18fa57f015cc394bd1389bce02503fb30c394d04ee6b938 \
+    --hash=sha256:8af740fc2711ad85f1a5c034a435782fbd5b5f8314c9a3ef071424a8158d7f6b \
+    --hash=sha256:8b929b9bd7cd7c3939f8bcfffa92fae7480bd1aa425279d51a89327d600c704d \
+    --hash=sha256:910bec0c49637d213f5d9877105d26e0c4a4de2f8b1b29405ff37e9fc0ad52b8 \
+    --hash=sha256:96943e5dcc37a6529d18766597c491798b7eb7a61d48878611298afc1fca946c \
+    --hash=sha256:a0215ce6041d501f3155dc219712bc41252d0ab76474615b9700d63d4d9292af \
+    --hash=sha256:a3cf433f127efa43fee6b90ea4c6edf6c4a17109d1d037d1a52abec84d8f2e42 \
+    --hash=sha256:a6ce61195c6a19c785df04e71a4537e29eaa2c50fe745b732aa937c0c77169f3 \
+    --hash=sha256:a7a75ef35f2df54ad55dbf4b73fe1da96f370e51b10c91f08b19603c64004acc \
+    --hash=sha256:a94159871304770da4dd371f4291b20cac04e8c94f11bdea1c3478e557fbe0d8 \
+    --hash=sha256:aa1990247f02a54185dc0dff92a6904521172a22664c863a03ff64c42f9b5410 \
+    --hash=sha256:ab88bafedc57dd0aab55fa728ea10c1911f7e4d8b43e1d838a1739f33712921c \
+    --hash=sha256:ad093e823df03bb3fd37e7dec9d4670c34f9e24aeace76808fc20a507cace825 \
+    --hash=sha256:ae871a964e1987a943d83d6709d20ec6103ca1eaf52f7e0d36ee1b5bebb8b9b9 \
+    --hash=sha256:b0ba0d15164eae3d878260d4c4df859bbdc6466e9e6689c344a13334f988bb53 \
+    --hash=sha256:b5411d82cddd212644cf9360879eb5080f0d5f7d809d03262c50dad02f01421a \
+    --hash=sha256:b9552ec52cc147dbf1944ac7ac98af7602e51ea2dcd076ed194ca3c0d1c7d0bc \
+    --hash=sha256:bfb9162dcf01f615462b995a516ba03e769de0789de1cadc0f916265c257e5d8 \
+    --hash=sha256:c0a9034379a37ae42dea7ac1e048352d96286626251862e448933c0f59cbd79c \
+    --hash=sha256:c1161b345c0a444ebcf46bf0a740ba5dcf50612fd3d0528883fdc0eff578006a \
+    --hash=sha256:c11f5b099adafb18e65c2c997d57108b5bbeaa9eeee64a84302c0978b1ec948b \
+    --hash=sha256:c44e65da1de4403d0576473e2344828ef9c4c6244d65cf4b75549bb46d40b8dd \
+    --hash=sha256:c48c5c0271149cfe467c0ff8eb941279fd6e3f65c9a388c984e0e6cf57538e14 \
+    --hash=sha256:c7a815258e5895d8900aec4454f38dca9aed71085f227537208057853f9d13f2 \
+    --hash=sha256:cae533195e8122584ec87531d6df000ad07737eaa3c81209e85c928854d2195c \
+    --hash=sha256:cc14be025665dba6202b6a71cfcdb53210cc498e50068bc088076624471f8bb9 \
+    --hash=sha256:cd56db019015b6acfaaf92e1ac40eb8434847d9bf88b4be4efe5bfd260aee692 \
+    --hash=sha256:d827176898a2b0b09694fbd1088c7a31836d1a505c243811c87ae53a3f6273c1 \
+    --hash=sha256:df72ac063b97837a80d80dec8d54c241af059cc9bb42c4de68bd5b61ceb37caa \
+    --hash=sha256:e5980a746d547a6ba173fd5ee85ce9077e72d118758db05d229044b469d9029a \
+    --hash=sha256:e5d47ae48db0b2dcf70bc8a3bc72b3de86e2a590fc299fdbbb15af320d2659de \
+    --hash=sha256:e91d635961bec2d8f19dfeb41a539eb94bd073f075ca6dae6c8dc0ee89ad6f91 \
+    --hash=sha256:ea353162f249c8097ea63c2169dd1aa55de1e8fecbe63412a9bc50816e87b761 \
+    --hash=sha256:eaeed7abfb5d64c539e2db173f63631455f1196c37d9d8d873fc316470dfbacd \
+    --hash=sha256:eca4bf3734c541dc4f374ad6010a68ff6c6748f00451707f39857f429ca36ced \
+    --hash=sha256:f83a552443a526ea38d064588613aca983d0ee0038801bc93c0c916428310c28 \
+    --hash=sha256:fb1558def481d84f03b45888473fc5a1f35747b5f334ef4e7a571bc0dfcb11f8 \
+    --hash=sha256:fd1ed388ea7fbed22c4968dd64bab0198de60750a25fe8c0c9d4bef5abe13824
+    # via -r py/requirements.txt
+aiosignal==1.3.1 \
+    --hash=sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc \
+    --hash=sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17
+    # via aiohttp
 async-generator==1.10 \
     --hash=sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b \
     --hash=sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144
@@ -11,11 +104,16 @@
     #   -r py/requirements.txt
     #   trio
     #   trio-websocket
+async-timeout==4.0.3 \
+    --hash=sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f \
+    --hash=sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028
+    # via aiohttp
 attrs==23.1.0 \
     --hash=sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04 \
     --hash=sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015
     # via
     #   -r py/requirements.txt
+    #   aiohttp
     #   outcome
     #   trio
 certifi==2023.7.22 \
@@ -171,7 +269,9 @@
     --hash=sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33 \
     --hash=sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519 \
     --hash=sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561
-    # via requests
+    # via
+    #   aiohttp
+    #   requests
 cryptography==42.0.4 \
     --hash=sha256:01911714117642a3f1792c7f376db572aadadbafcd8d75bb527166009c9f1d1b \
     --hash=sha256:0e89f7b84f421c56e7ff69f11c441ebda73b8a8e6488d322ef71746224c20fce \
@@ -242,6 +342,71 @@
     # via
     #   pytest
     #   trio
+frozenlist==1.4.0 \
+    --hash=sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6 \
+    --hash=sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01 \
+    --hash=sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251 \
+    --hash=sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9 \
+    --hash=sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b \
+    --hash=sha256:10ff5faaa22786315ef57097a279b833ecab1a0bfb07d604c9cbb1c4cdc2ed87 \
+    --hash=sha256:17ae5cd0f333f94f2e03aaf140bb762c64783935cc764ff9c82dff626089bebf \
+    --hash=sha256:19488c57c12d4e8095a922f328df3f179c820c212940a498623ed39160bc3c2f \
+    --hash=sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0 \
+    --hash=sha256:1e78fb68cf9c1a6aa4a9a12e960a5c9dfbdb89b3695197aa7064705662515de2 \
+    --hash=sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b \
+    --hash=sha256:2b8bcf994563466db019fab287ff390fffbfdb4f905fc77bc1c1d604b1c689cc \
+    --hash=sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c \
+    --hash=sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467 \
+    --hash=sha256:491e014f5c43656da08958808588cc6c016847b4360e327a62cb308c791bd2d9 \
+    --hash=sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1 \
+    --hash=sha256:556de4430ce324c836789fa4560ca62d1591d2538b8ceb0b4f68fb7b2384a27a \
+    --hash=sha256:5833593c25ac59ede40ed4de6d67eb42928cca97f26feea219f21d0ed0959b79 \
+    --hash=sha256:6221d84d463fb110bdd7619b69cb43878a11d51cbb9394ae3105d082d5199167 \
+    --hash=sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300 \
+    --hash=sha256:6c38721585f285203e4b4132a352eb3daa19121a035f3182e08e437cface44bf \
+    --hash=sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea \
+    --hash=sha256:7211ef110a9194b6042449431e08c4d80c0481e5891e58d429df5899690511c2 \
+    --hash=sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab \
+    --hash=sha256:7645a8e814a3ee34a89c4a372011dcd817964ce8cb273c8ed6119d706e9613e3 \
+    --hash=sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb \
+    --hash=sha256:7f4f399d28478d1f604c2ff9119907af9726aed73680e5ed1ca634d377abb087 \
+    --hash=sha256:88f7bc0fcca81f985f78dd0fa68d2c75abf8272b1f5c323ea4a01a4d7a614efc \
+    --hash=sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8 \
+    --hash=sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62 \
+    --hash=sha256:93ea75c050c5bb3d98016b4ba2497851eadf0ac154d88a67d7a6816206f6fa7f \
+    --hash=sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326 \
+    --hash=sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c \
+    --hash=sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431 \
+    --hash=sha256:a0c6da9aee33ff0b1a451e867da0c1f47408112b3391dd43133838339e410963 \
+    --hash=sha256:a6c8097e01886188e5be3e6b14e94ab365f384736aa1fca6a0b9e35bd4a30bc7 \
+    --hash=sha256:aa384489fefeb62321b238e64c07ef48398fe80f9e1e6afeff22e140e0850eef \
+    --hash=sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3 \
+    --hash=sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956 \
+    --hash=sha256:b826d97e4276750beca7c8f0f1a4938892697a6bcd8ec8217b3312dad6982781 \
+    --hash=sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472 \
+    --hash=sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc \
+    --hash=sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839 \
+    --hash=sha256:c11b0746f5d946fecf750428a95f3e9ebe792c1ee3b1e96eeba145dc631a9672 \
+    --hash=sha256:c5374b80521d3d3f2ec5572e05adc94601985cc526fb276d0c8574a6d749f1b3 \
+    --hash=sha256:ca265542ca427bf97aed183c1676e2a9c66942e822b14dc6e5f42e038f92a503 \
+    --hash=sha256:ce31ae3e19f3c902de379cf1323d90c649425b86de7bbdf82871b8a2a0615f3d \
+    --hash=sha256:ceb6ec0a10c65540421e20ebd29083c50e6d1143278746a4ef6bcf6153171eb8 \
+    --hash=sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b \
+    --hash=sha256:d5655a942f5f5d2c9ed93d72148226d75369b4f6952680211972a33e59b1dfdc \
+    --hash=sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f \
+    --hash=sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559 \
+    --hash=sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b \
+    --hash=sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95 \
+    --hash=sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb \
+    --hash=sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963 \
+    --hash=sha256:e66d2a64d44d50d2543405fb183a21f76b3b5fd16f130f5c99187c3fb4e64919 \
+    --hash=sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f \
+    --hash=sha256:f0ed05f5079c708fe74bf9027e95125334b6978bf07fd5ab923e9e55e5fbb9d3 \
+    --hash=sha256:f61e2dc5ad442c52b4887f1fdc112f97caeff4d9e6ebe78879364ac59f1663e1 \
+    --hash=sha256:fec520865f42e5c7f050c2a79038897b1c7d1595e907a9e08e3353293ffc948e
+    # via
+    #   aiohttp
+    #   aiosignal
 h11==0.14.0 \
     --hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \
     --hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761
@@ -255,6 +420,7 @@
     #   -r py/requirements.txt
     #   requests
     #   trio
+    #   yarl
 importlib-metadata==6.8.0 \
     --hash=sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb \
     --hash=sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743
@@ -358,7 +524,10 @@
     --hash=sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849 \
     --hash=sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937 \
     --hash=sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d
-    # via -r py/requirements.txt
+    # via
+    #   -r py/requirements.txt
+    #   aiohttp
+    #   yarl
 nh3==0.2.15 \
     --hash=sha256:0d02d0ff79dfd8208ed25a39c12cbda092388fff7f1662466e27d97ad011b770 \
     --hash=sha256:3277481293b868b2715907310c7be0f1b9d10491d5adf9fce11756a97e97eddf \
@@ -431,14 +600,19 @@
     # via
     #   -r py/requirements.txt
     #   urllib3
-pytest==7.4.2 \
-    --hash=sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002 \
-    --hash=sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069
+pytest==7.4.3 \
+    --hash=sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac \
+    --hash=sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5
     # via
     #   -r py/requirements.txt
+    #   pytest-asyncio
     #   pytest-instafail
     #   pytest-mock
     #   pytest-trio
+pytest-asyncio==0.21.1 \
+    --hash=sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d \
+    --hash=sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b
+    # via -r py/requirements.txt
 pytest-instafail==0.5.0 \
     --hash=sha256:33a606f7e0c8e646dc3bfee0d5e3a4b7b78ef7c36168cfa1f3d93af7ca706c9e \
     --hash=sha256:6855414487e9e4bb76a118ce952c3c27d3866af15487506c4ded92eb72387819
@@ -527,6 +701,82 @@
     # via
     #   -r py/requirements.txt
     #   trio-websocket
+yarl==1.9.2 \
+    --hash=sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571 \
+    --hash=sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3 \
+    --hash=sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3 \
+    --hash=sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c \
+    --hash=sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7 \
+    --hash=sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04 \
+    --hash=sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191 \
+    --hash=sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea \
+    --hash=sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4 \
+    --hash=sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4 \
+    --hash=sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095 \
+    --hash=sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e \
+    --hash=sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74 \
+    --hash=sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef \
+    --hash=sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33 \
+    --hash=sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde \
+    --hash=sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45 \
+    --hash=sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf \
+    --hash=sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b \
+    --hash=sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac \
+    --hash=sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0 \
+    --hash=sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528 \
+    --hash=sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716 \
+    --hash=sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb \
+    --hash=sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18 \
+    --hash=sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72 \
+    --hash=sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6 \
+    --hash=sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582 \
+    --hash=sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5 \
+    --hash=sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368 \
+    --hash=sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc \
+    --hash=sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9 \
+    --hash=sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be \
+    --hash=sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a \
+    --hash=sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80 \
+    --hash=sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8 \
+    --hash=sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6 \
+    --hash=sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417 \
+    --hash=sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574 \
+    --hash=sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59 \
+    --hash=sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608 \
+    --hash=sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82 \
+    --hash=sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1 \
+    --hash=sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3 \
+    --hash=sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d \
+    --hash=sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8 \
+    --hash=sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc \
+    --hash=sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac \
+    --hash=sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8 \
+    --hash=sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955 \
+    --hash=sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0 \
+    --hash=sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367 \
+    --hash=sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb \
+    --hash=sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a \
+    --hash=sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623 \
+    --hash=sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2 \
+    --hash=sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6 \
+    --hash=sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7 \
+    --hash=sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4 \
+    --hash=sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051 \
+    --hash=sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938 \
+    --hash=sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8 \
+    --hash=sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9 \
+    --hash=sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3 \
+    --hash=sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5 \
+    --hash=sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9 \
+    --hash=sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333 \
+    --hash=sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185 \
+    --hash=sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3 \
+    --hash=sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560 \
+    --hash=sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b \
+    --hash=sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7 \
+    --hash=sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78 \
+    --hash=sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7
+    # via aiohttp
 zipp==3.17.0 \
     --hash=sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31 \
     --hash=sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0
diff --git a/py/selenium/webdriver/async/__init__.py b/py/selenium/webdriver/async/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/py/selenium/webdriver/async/__init__.py
diff --git a/py/selenium/webdriver/async/remote/remote_connection.py b/py/selenium/webdriver/async/remote/remote_connection.py
new file mode 100644
index 0000000..d50b3d8
--- /dev/null
+++ b/py/selenium/webdriver/async/remote/remote_connection.py
@@ -0,0 +1,158 @@
+# 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.
+
+import logging
+import os
+import platform
+import socket
+import string
+from base64 import b64encode
+from urllib import parse
+
+from selenium import __version__
+
+from ..remote import utils
+from ..remote.command import Command
+
+from ..remote.errorhandler import ErrorCode
+
+
+remote_commands = {
+    Command.NEW_SESSION: ("POST", "/session"),
+    Command.QUIT: ("DELETE", "/session/$sessionId"),
+    Command.W3C_GET_CURRENT_WINDOW_HANDLE: ("GET", "/session/$sessionId/window"),
+    Command.W3C_GET_WINDOW_HANDLES: ("GET", "/session/$sessionId/window/handles"),
+    Command.GET: ("POST", "/session/$sessionId/url"),
+    Command.GO_FORWARD: ("POST", "/session/$sessionId/forward"),
+    Command.GO_BACK: ("POST", "/session/$sessionId/back"),
+    Command.REFRESH: ("POST", "/session/$sessionId/refresh"),
+    Command.W3C_EXECUTE_SCRIPT: ("POST", "/session/$sessionId/execute/sync"),
+    Command.W3C_EXECUTE_SCRIPT_ASYNC: ("POST", "/session/$sessionId/execute/async"),
+    Command.GET_CURRENT_URL: ("GET", "/session/$sessionId/url"),
+    Command.GET_TITLE: ("GET", "/session/$sessionId/title"),
+    Command.GET_PAGE_SOURCE: ("GET", "/session/$sessionId/source"),
+    Command.SCREENSHOT: ("GET", "/session/$sessionId/screenshot"),
+    Command.ELEMENT_SCREENSHOT: ("GET", "/session/$sessionId/element/$id/screenshot"),
+    Command.FIND_ELEMENT: ("POST", "/session/$sessionId/element"),
+    Command.FIND_ELEMENTS: ("POST", "/session/$sessionId/elements"),
+    Command.W3C_GET_ACTIVE_ELEMENT: ("GET", "/session/$sessionId/element/active"),
+    Command.FIND_CHILD_ELEMENT: ("POST", "/session/$sessionId/element/$id/element"),
+    Command.FIND_CHILD_ELEMENTS: ("POST", "/session/$sessionId/element/$id/elements"),
+    Command.CLICK_ELEMENT: ("POST", "/session/$sessionId/element/$id/click"),
+    Command.CLEAR_ELEMENT: ("POST", "/session/$sessionId/element/$id/clear"),
+    Command.GET_ELEMENT_TEXT: ("GET", "/session/$sessionId/element/$id/text"),
+    Command.SEND_KEYS_TO_ELEMENT: ("POST", "/session/$sessionId/element/$id/value"),
+    Command.GET_ELEMENT_TAG_NAME: ("GET", "/session/$sessionId/element/$id/name"),
+    Command.IS_ELEMENT_SELECTED: ("GET", "/session/$sessionId/element/$id/selected"),
+    Command.IS_ELEMENT_ENABLED: ("GET", "/session/$sessionId/element/$id/enabled"),
+    Command.GET_ELEMENT_RECT: ("GET", "/session/$sessionId/element/$id/rect"),
+    Command.GET_ELEMENT_ATTRIBUTE: (
+        "GET",
+        "/session/$sessionId/element/$id/attribute/$name",
+    ),
+    Command.GET_ELEMENT_PROPERTY: (
+        "GET",
+        "/session/$sessionId/element/$id/property/$name",
+    ),
+    Command.GET_ELEMENT_ARIA_ROLE: (
+        "GET",
+        "/session/$sessionId/element/$id/computedrole",
+    ),
+    Command.GET_ELEMENT_ARIA_LABEL: (
+        "GET",
+        "/session/$sessionId/element/$id/computedlabel",
+    ),
+    Command.GET_SHADOW_ROOT: ("GET", "/session/$sessionId/element/$id/shadow"),
+    Command.FIND_ELEMENT_FROM_SHADOW_ROOT: (
+        "POST",
+        "/session/$sessionId/shadow/$shadowId/element",
+    ),
+    Command.FIND_ELEMENTS_FROM_SHADOW_ROOT: (
+        "POST",
+        "/session/$sessionId/shadow/$shadowId/elements",
+    ),
+    Command.GET_ALL_COOKIES: ("GET", "/session/$sessionId/cookie"),
+    Command.ADD_COOKIE: ("POST", "/session/$sessionId/cookie"),
+    Command.GET_COOKIE: ("GET", "/session/$sessionId/cookie/$name"),
+    Command.DELETE_ALL_COOKIES: ("DELETE", "/session/$sessionId/cookie"),
+    Command.DELETE_COOKIE: ("DELETE", "/session/$sessionId/cookie/$name"),
+    Command.SWITCH_TO_FRAME: ("POST", "/session/$sessionId/frame"),
+    Command.SWITCH_TO_PARENT_FRAME: ("POST", "/session/$sessionId/frame/parent"),
+    Command.SWITCH_TO_WINDOW: ("POST", "/session/$sessionId/window"),
+    Command.NEW_WINDOW: ("POST", "/session/$sessionId/window/new"),
+    Command.CLOSE: ("DELETE", "/session/$sessionId/window"),
+    Command.GET_ELEMENT_VALUE_OF_CSS_PROPERTY: (
+        "GET",
+        "/session/$sessionId/element/$id/css/$propertyName",
+    ),
+    Command.EXECUTE_ASYNC_SCRIPT: ("POST", "/session/$sessionId/execute_async"),
+    Command.SET_TIMEOUTS: ("POST", "/session/$sessionId/timeouts"),
+    Command.GET_TIMEOUTS: ("GET", "/session/$sessionId/timeouts"),
+    Command.W3C_DISMISS_ALERT: ("POST", "/session/$sessionId/alert/dismiss"),
+    Command.W3C_ACCEPT_ALERT: ("POST", "/session/$sessionId/alert/accept"),
+    Command.W3C_SET_ALERT_VALUE: ("POST", "/session/$sessionId/alert/text"),
+    Command.W3C_GET_ALERT_TEXT: ("GET", "/session/$sessionId/alert/text"),
+    Command.W3C_ACTIONS: ("POST", "/session/$sessionId/actions"),
+    Command.W3C_CLEAR_ACTIONS: ("DELETE", "/session/$sessionId/actions"),
+    Command.SET_WINDOW_RECT: ("POST", "/session/$sessionId/window/rect"),
+    Command.GET_WINDOW_RECT: ("GET", "/session/$sessionId/window/rect"),
+    Command.W3C_MAXIMIZE_WINDOW: ("POST", "/session/$sessionId/window/maximize"),
+    Command.SET_SCREEN_ORIENTATION: ("POST", "/session/$sessionId/orientation"),
+    Command.GET_SCREEN_ORIENTATION: ("GET", "/session/$sessionId/orientation"),
+    Command.GET_NETWORK_CONNECTION: ("GET", "/session/$sessionId/network_connection"),
+    Command.SET_NETWORK_CONNECTION: ("POST", "/session/$sessionId/network_connection"),
+    Command.GET_LOG: ("POST", "/session/$sessionId/se/log"),
+    Command.GET_AVAILABLE_LOG_TYPES: ("GET", "/session/$sessionId/se/log/types"),
+    Command.CURRENT_CONTEXT_HANDLE: ("GET", "/session/$sessionId/context"),
+    Command.CONTEXT_HANDLES: ("GET", "/session/$sessionId/contexts"),
+    Command.SWITCH_TO_CONTEXT: ("POST", "/session/$sessionId/context"),
+    Command.FULLSCREEN_WINDOW: ("POST", "/session/$sessionId/window/fullscreen"),
+    Command.MINIMIZE_WINDOW: ("POST", "/session/$sessionId/window/minimize"),
+    Command.PRINT_PAGE: ("POST", "/session/$sessionId/print"),
+    Command.ADD_VIRTUAL_AUTHENTICATOR: (
+        "POST",
+        "/session/$sessionId/webauthn/authenticator",
+    ),
+    Command.REMOVE_VIRTUAL_AUTHENTICATOR: (
+        "DELETE",
+        "/session/$sessionId/webauthn/authenticator/$authenticatorId",
+    ),
+    Command.ADD_CREDENTIAL: (
+        "POST",
+        "/session/$sessionId/webauthn/authenticator/$authenticatorId/credential",
+    ),
+    Command.GET_CREDENTIALS: (
+        "GET",
+        "/session/$sessionId/webauthn/authenticator/$authenticatorId/credentials",
+    ),
+    Command.REMOVE_CREDENTIAL: (
+        "DELETE",
+        "/session/$sessionId/webauthn/authenticator/$authenticatorId/credentials/$credentialId",
+    ),
+    Command.REMOVE_ALL_CREDENTIALS: (
+        "DELETE",
+        "/session/$sessionId/webauthn/authenticator/$authenticatorId/credentials",
+    ),
+    Command.SET_USER_VERIFIED: (
+        "POST",
+        "/session/$sessionId/webauthn/authenticator/$authenticatorId/uv",
+    ),
+    Command.UPLOAD_FILE: ("POST", "/session/$sessionId/se/file"),
+    Command.GET_DOWNLOADABLE_FILES: ("GET", "/session/$sessionId/se/files"),
+    Command.DOWNLOAD_FILE: ("POST", "/session/$sessionId/se/files"),
+    Command.DELETE_DOWNLOADABLE_FILES: ("DELETE", "/session/$sessionId/se/files"),
+}
diff --git a/py/selenium/webdriver/async/remote/webdriver.py b/py/selenium/webdriver/async/remote/webdriver.py
new file mode 100644
index 0000000..531e31b
--- /dev/null
+++ b/py/selenium/webdriver/async/remote/webdriver.py
@@ -0,0 +1,1253 @@
+# 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 WebDriver implementation."""
+import base64
+import contextlib
+import copy
+import os
+import pkgutil
+import types
+import typing
+import warnings
+import zipfile
+from abc import ABCMeta
+from base64 import b64decode
+from base64 import urlsafe_b64encode
+from contextlib import asynccontextmanager
+from contextlib import contextmanager
+from importlib import import_module
+from typing import Dict
+from typing import List
+from typing import Optional
+from typing import Union
+
+from selenium.common.exceptions import InvalidArgumentException
+from selenium.common.exceptions import JavascriptException
+from selenium.common.exceptions import NoSuchCookieException
+from selenium.common.exceptions import NoSuchElementException
+from selenium.common.exceptions import WebDriverException
+from selenium.webdriver.common.by import By
+from selenium.webdriver.common.html5.application_cache import ApplicationCache
+from selenium.webdriver.common.options import BaseOptions
+from selenium.webdriver.common.print_page_options import PrintOptions
+from selenium.webdriver.common.timeouts import Timeouts
+from selenium.webdriver.common.virtual_authenticator import Credential
+from selenium.webdriver.common.virtual_authenticator import VirtualAuthenticatorOptions
+from selenium.webdriver.common.virtual_authenticator import (
+    required_virtual_authenticator,
+)
+from selenium.webdriver.support.relative_locator import RelativeBy
+
+from ..remote.bidi_connection import BidiConnection
+from .command import Command
+from ..remote.errorhandler import ErrorHandler
+from ..remote.file_detector import FileDetector
+from ..remote.file_detector import LocalFileDetector
+from .mobile import Mobile
+from ..remote.remote_connection import RemoteConnection
+from ..remote.script_key import ScriptKey
+from ..remote.shadowroot import ShadowRoot
+from ..remote.switch_to import SwitchTo
+from .webelement import WebElement
+
+cdp = None
+
+
+def import_cdp():
+    global cdp
+    if not cdp:
+        cdp = import_module("selenium.webdriver.common.bidi.cdp")
+
+
+def _create_caps(caps):
+    """Makes a W3C alwaysMatch capabilities object.
+
+    Filters out capability names that are not in the W3C spec. Spec-compliant
+    drivers will reject requests containing unknown capability names.
+
+    Moves the Firefox profile, if present, from the old location to the new Firefox
+    options object.
+
+    :Args:
+     - caps - A dictionary of capabilities requested by the caller.
+    """
+    caps = copy.deepcopy(caps)
+    always_match = {}
+    for k, v in caps.items():
+        always_match[k] = v
+    return {"capabilities": {"firstMatch": [{}], "alwaysMatch": always_match}}
+
+
+def get_remote_connection(
+    capabilities, command_executor, keep_alive, ignore_local_proxy=False
+):
+    from selenium.webdriver.chromium.remote_connection import ChromiumRemoteConnection
+    from selenium.webdriver.firefox.remote_connection import FirefoxRemoteConnection
+    from selenium.webdriver.safari.remote_connection import SafariRemoteConnection
+
+    candidates = [
+        RemoteConnection,
+        ChromiumRemoteConnection,
+        SafariRemoteConnection,
+        FirefoxRemoteConnection,
+    ]
+    handler = next(
+        (c for c in candidates if c.browser_name == capabilities.get("browserName")),
+        RemoteConnection,
+    )
+
+    return handler(
+        command_executor, keep_alive=keep_alive, ignore_proxy=ignore_local_proxy
+    )
+
+
+def create_matches(options: List[BaseOptions]) -> Dict:
+    capabilities = {"capabilities": {}}
+    opts = []
+    for opt in options:
+        opts.append(opt.to_capabilities())
+    opts_size = len(opts)
+    samesies = {}
+
+    # Can not use bitwise operations on the dicts or lists due to
+    # https://bugs.python.org/issue38210
+    for i in range(opts_size):
+        min_index = i
+        if i + 1 < opts_size:
+            first_keys = opts[min_index].keys()
+
+            for kys in first_keys:
+                if kys in opts[i + 1].keys():
+                    if opts[min_index][kys] == opts[i + 1][kys]:
+                        samesies.update({kys: opts[min_index][kys]})
+
+    always = {}
+    for k, v in samesies.items():
+        always[k] = v
+
+    for i in opts:
+        for k in always:
+            del i[k]
+
+    capabilities["capabilities"]["alwaysMatch"] = always
+    capabilities["capabilities"]["firstMatch"] = opts
+
+    return capabilities
+
+
+class BaseWebDriver(metaclass=ABCMeta):
+    """Abstract Base Class for all Webdriver subtypes.
+
+    ABC's allow custom implementations of Webdriver to be registered so
+    that isinstance type checks will succeed.
+    """
+
+
+class WebDriver(BaseWebDriver):
+    """Controls a browser by sending commands to a remote server. This server
+    is expected to be running the WebDriver wire protocol as defined at
+    https://www.selenium.dev/documentation/legacy/json_wire_protocol/.
+
+    :Attributes:
+     - session_id - String ID of the browser session started and controlled by this WebDriver.
+     - capabilities - Dictionary of effective capabilities of this browser session as returned
+         by the remote server. See https://www.selenium.dev/documentation/legacy/desired_capabilities/
+     - command_executor - remote_connection.RemoteConnection object used to execute commands.
+     - error_handler - errorhandler.ErrorHandler object used to handle errors.
+    """
+
+    _web_element_cls = WebElement
+    _shadowroot_cls = ShadowRoot
+
+    def __init__(
+        self,
+        command_executor="http://127.0.0.1:4444",
+        keep_alive=True,
+        file_detector=None,
+        options: Union[BaseOptions, List[BaseOptions]] = None,
+    ) -> None:
+        """Create a new driver that will issue commands using the wire
+        protocol.
+
+        :Args:
+         - command_executor - Either a string representing URL of the remote server or a custom
+             remote_connection.RemoteConnection object. Defaults to 'http://127.0.0.1:4444/wd/hub'.
+         - keep_alive - Whether to configure remote_connection.RemoteConnection to use
+             HTTP keep-alive. Defaults to True.
+         - file_detector - Pass custom file detector object during instantiation. If None,
+             then default LocalFileDetector() will be used.
+         - options - instance of a driver options.Options class
+        """
+
+        if isinstance(options, list):
+            capabilities = create_matches(options)
+            _ignore_local_proxy = False
+        else:
+            capabilities = options.to_capabilities()
+            _ignore_local_proxy = options._ignore_local_proxy
+        self.command_executor = command_executor
+        if isinstance(self.command_executor, (str, bytes)):
+            self.command_executor = get_remote_connection(
+                capabilities,
+                command_executor=command_executor,
+                keep_alive=keep_alive,
+                ignore_local_proxy=_ignore_local_proxy,
+            )
+        self._is_remote = True
+        self.session_id = None
+        self.caps = {}
+        self.pinned_scripts = {}
+        self.error_handler = ErrorHandler()
+        self._switch_to = SwitchTo(self)
+        self._mobile = Mobile(self)
+        self.file_detector = file_detector or LocalFileDetector()
+        self._authenticator_id = None
+        self.start_client()
+        self.start_session(capabilities)
+
+    def __repr__(self):
+        return f'<{type(self).__module__}.{type(self).__name__} (session="{self.session_id}")>'
+
+    def __enter__(self):
+        return self
+
+    def __exit__(
+        self,
+        exc_type: typing.Optional[typing.Type[BaseException]],
+        exc: typing.Optional[BaseException],
+        traceback: typing.Optional[types.TracebackType],
+    ):
+        self.quit()
+
+    @contextmanager
+    def file_detector_context(self, file_detector_class, *args, **kwargs):
+        """Overrides the current file detector (if necessary) in limited
+        context. Ensures the original file detector is set afterwards.
+
+        Example::
+
+            with webdriver.file_detector_context(UselessFileDetector):
+                someinput.send_keys('/etc/hosts')
+
+        :Args:
+         - file_detector_class - Class of the desired file detector. If the class is different
+             from the current file_detector, then the class is instantiated with args and kwargs
+             and used as a file detector during the duration of the context manager.
+         - args - Optional arguments that get passed to the file detector class during
+             instantiation.
+         - kwargs - Keyword arguments, passed the same way as args.
+        """
+        last_detector = None
+        if not isinstance(self.file_detector, file_detector_class):
+            last_detector = self.file_detector
+            self.file_detector = file_detector_class(*args, **kwargs)
+        try:
+            yield
+        finally:
+            if last_detector:
+                self.file_detector = last_detector
+
+    @property
+    def mobile(self) -> Mobile:
+        return self._mobile
+
+    @property
+    def name(self) -> str:
+        """Returns the name of the underlying browser for this instance.
+
+        :Usage:
+            ::
+
+                name = driver.name
+        """
+        if "browserName" in self.caps:
+            return self.caps["browserName"]
+        raise KeyError("browserName not specified in session capabilities")
+
+    def start_client(self):
+        """Called before starting a new session.
+
+        This method may be overridden to define custom startup behavior.
+        """
+        pass
+
+    def stop_client(self):
+        """Called after executing a quit command.
+
+        This method may be overridden to define custom shutdown
+        behavior.
+        """
+        pass
+
+    def start_session(self, capabilities: dict) -> None:
+        """Creates a new session with the desired capabilities.
+
+        :Args:
+         - capabilities - a capabilities dict to start the session with.
+        """
+
+        caps = _create_caps(capabilities)
+        response = self.execute(Command.NEW_SESSION, caps)["value"]
+        self.session_id = response.get("sessionId")
+        self.caps = response.get("capabilities")
+
+    def _wrap_value(self, value):
+        if isinstance(value, dict):
+            converted = {}
+            for key, val in value.items():
+                converted[key] = self._wrap_value(val)
+            return converted
+        if isinstance(value, self._web_element_cls):
+            return {"element-6066-11e4-a52e-4f735466cecf": value.id}
+        if isinstance(value, self._shadowroot_cls):
+            return {"shadow-6066-11e4-a52e-4f735466cecf": value.id}
+        if isinstance(value, list):
+            return list(self._wrap_value(item) for item in value)
+        return value
+
+    def create_web_element(self, element_id: str) -> WebElement:
+        """Creates a web element with the specified `element_id`."""
+        return self._web_element_cls(self, element_id)
+
+    def _unwrap_value(self, value):
+        if isinstance(value, dict):
+            if "element-6066-11e4-a52e-4f735466cecf" in value:
+                return self.create_web_element(
+                    value["element-6066-11e4-a52e-4f735466cecf"]
+                )
+            if "shadow-6066-11e4-a52e-4f735466cecf" in value:
+                return self._shadowroot_cls(
+                    self, value["shadow-6066-11e4-a52e-4f735466cecf"]
+                )
+            for key, val in value.items():
+                value[key] = self._unwrap_value(val)
+            return value
+        if isinstance(value, list):
+            return list(self._unwrap_value(item) for item in value)
+        return value
+
+    def execute(self, driver_command: str, params: dict = None) -> dict:
+        """Sends a command to be executed by a command.CommandExecutor.
+
+        :Args:
+         - driver_command: The name of the command to execute as a string.
+         - params: A dictionary of named parameters to send with the command.
+
+        :Returns:
+          The command's JSON response loaded into a dictionary object.
+        """
+        params = self._wrap_value(params)
+
+        if self.session_id:
+            if not params:
+                params = {"sessionId": self.session_id}
+            elif "sessionId" not in params:
+                params["sessionId"] = self.session_id
+
+        response = self.command_executor.execute(driver_command, params)
+        if response:
+            self.error_handler.check_response(response)
+            response["value"] = self._unwrap_value(response.get("value", None))
+            return response
+        # If the server doesn't send a response, assume the command was
+        # a success
+        return {"success": 0, "value": None, "sessionId": self.session_id}
+
+    async def get(self, url: str) -> None:
+        """Loads a web page in the current browser session."""
+        await self.execute(Command.GET, {"url": url})
+
+    @property
+    def title(self) -> str:
+        """Returns the title of the current page.
+
+        :Usage:
+            ::
+
+                title = driver.title
+        """
+        return self.execute(Command.GET_TITLE).get("value", "")
+
+    def pin_script(self, script: str, script_key=None) -> ScriptKey:
+        """Store common javascript scripts to be executed later by a unique
+        hashable ID."""
+        script_key_instance = ScriptKey(script_key)
+        self.pinned_scripts[script_key_instance.id] = script
+        return script_key_instance
+
+    def unpin(self, script_key: ScriptKey) -> None:
+        """Remove a pinned script from storage."""
+        try:
+            self.pinned_scripts.pop(script_key.id)
+        except KeyError:
+            raise KeyError(
+                f"No script with key: {script_key} existed in {self.pinned_scripts}"
+            ) from None
+
+    def get_pinned_scripts(self) -> List[str]:
+        return list(self.pinned_scripts)
+
+    def execute_script(self, script, *args):
+        """Synchronously Executes JavaScript in the current window/frame.
+
+        :Args:
+         - script: The JavaScript to execute.
+         - \\*args: Any applicable arguments for your JavaScript.
+
+        :Usage:
+            ::
+
+                driver.execute_script('return document.title;')
+        """
+        if isinstance(script, ScriptKey):
+            try:
+                script = self.pinned_scripts[script.id]
+            except KeyError:
+                raise JavascriptException("Pinned script could not be found")
+
+        converted_args = list(args)
+        command = Command.W3C_EXECUTE_SCRIPT
+
+        return self.execute(command, {"script": script, "args": converted_args})[
+            "value"
+        ]
+
+    def execute_async_script(self, script: str, *args):
+        """Asynchronously Executes JavaScript in the current window/frame.
+
+        :Args:
+         - script: The JavaScript to execute.
+         - \\*args: Any applicable arguments for your JavaScript.
+
+        :Usage:
+            ::
+
+                script = "var callback = arguments[arguments.length - 1]; " \\
+                         "window.setTimeout(function(){ callback('timeout') }, 3000);"
+                driver.execute_async_script(script)
+        """
+        converted_args = list(args)
+        command = Command.W3C_EXECUTE_SCRIPT_ASYNC
+
+        return self.execute(command, {"script": script, "args": converted_args})[
+            "value"
+        ]
+
+    @property
+    def current_url(self) -> str:
+        """Gets the URL of the current page.
+
+        :Usage:
+            ::
+
+                driver.current_url
+        """
+        return self.execute(Command.GET_CURRENT_URL)["value"]
+
+    @property
+    def page_source(self) -> str:
+        """Gets the source of the current page.
+
+        :Usage:
+            ::
+
+                driver.page_source
+        """
+        return self.execute(Command.GET_PAGE_SOURCE)["value"]
+
+    def close(self) -> None:
+        """Closes the current window.
+
+        :Usage:
+            ::
+
+                driver.close()
+        """
+        self.execute(Command.CLOSE)
+
+    def quit(self) -> None:
+        """Quits the driver and closes every associated window.
+
+        :Usage:
+            ::
+
+                driver.quit()
+        """
+        try:
+            self.execute(Command.QUIT)
+        finally:
+            self.stop_client()
+            self.command_executor.close()
+
+    @property
+    def current_window_handle(self) -> str:
+        """Returns the handle of the current window.
+
+        :Usage:
+            ::
+
+                driver.current_window_handle
+        """
+        return self.execute(Command.W3C_GET_CURRENT_WINDOW_HANDLE)["value"]
+
+    @property
+    def window_handles(self) -> List[str]:
+        """Returns the handles of all windows within the current session.
+
+        :Usage:
+            ::
+
+                driver.window_handles
+        """
+        return self.execute(Command.W3C_GET_WINDOW_HANDLES)["value"]
+
+    def maximize_window(self) -> None:
+        """Maximizes the current window that webdriver is using."""
+        command = Command.W3C_MAXIMIZE_WINDOW
+        self.execute(command, None)
+
+    def fullscreen_window(self) -> None:
+        """Invokes the window manager-specific 'full screen' operation."""
+        self.execute(Command.FULLSCREEN_WINDOW)
+
+    def minimize_window(self) -> None:
+        """Invokes the window manager-specific 'minimize' operation."""
+        self.execute(Command.MINIMIZE_WINDOW)
+
+    def print_page(self, print_options: Optional[PrintOptions] = None) -> str:
+        """Takes PDF of the current page.
+
+        The driver makes a best effort to return a PDF based on the
+        provided parameters.
+        """
+        options = {}
+        if print_options:
+            options = print_options.to_dict()
+
+        return self.execute(Command.PRINT_PAGE, options)["value"]
+
+    @property
+    def switch_to(self) -> SwitchTo:
+        """
+        :Returns:
+            - SwitchTo: an object containing all options to switch focus into
+
+        :Usage:
+            ::
+
+                element = driver.switch_to.active_element
+                alert = driver.switch_to.alert
+                driver.switch_to.default_content()
+                driver.switch_to.frame('frame_name')
+                driver.switch_to.frame(1)
+                driver.switch_to.frame(driver.find_elements(By.TAG_NAME, "iframe")[0])
+                driver.switch_to.parent_frame()
+                driver.switch_to.window('main')
+        """
+        return self._switch_to
+
+    # Navigation
+    def back(self) -> None:
+        """Goes one step backward in the browser history.
+
+        :Usage:
+            ::
+
+                driver.back()
+        """
+        self.execute(Command.GO_BACK)
+
+    def forward(self) -> None:
+        """Goes one step forward in the browser history.
+
+        :Usage:
+            ::
+
+                driver.forward()
+        """
+        self.execute(Command.GO_FORWARD)
+
+    def refresh(self) -> None:
+        """Refreshes the current page.
+
+        :Usage:
+            ::
+
+                driver.refresh()
+        """
+        self.execute(Command.REFRESH)
+
+    # Options
+    def get_cookies(self) -> List[dict]:
+        """Returns a set of dictionaries, corresponding to cookies visible in
+        the current session.
+
+        :Usage:
+            ::
+
+                driver.get_cookies()
+        """
+        return self.execute(Command.GET_ALL_COOKIES)["value"]
+
+    def get_cookie(self, name) -> typing.Optional[typing.Dict]:
+        """Get a single cookie by name. Returns the cookie if found, None if
+        not.
+
+        :Usage:
+            ::
+
+                driver.get_cookie('my_cookie')
+        """
+        with contextlib.suppress(NoSuchCookieException):
+            return self.execute(Command.GET_COOKIE, {"name": name})["value"]
+        return None
+
+    def delete_cookie(self, name) -> None:
+        """Deletes a single cookie with the given name.
+
+        :Usage:
+            ::
+
+                driver.delete_cookie('my_cookie')
+        """
+        self.execute(Command.DELETE_COOKIE, {"name": name})
+
+    def delete_all_cookies(self) -> None:
+        """Delete all cookies in the scope of the session.
+
+        :Usage:
+            ::
+
+                driver.delete_all_cookies()
+        """
+        self.execute(Command.DELETE_ALL_COOKIES)
+
+    def add_cookie(self, cookie_dict) -> None:
+        """Adds a cookie to your current session.
+
+        :Args:
+         - cookie_dict: A dictionary object, with required keys - "name" and "value";
+            optional keys - "path", "domain", "secure", "httpOnly", "expiry", "sameSite"
+
+        :Usage:
+            ::
+
+                driver.add_cookie({'name' : 'foo', 'value' : 'bar'})
+                driver.add_cookie({'name' : 'foo', 'value' : 'bar', 'path' : '/'})
+                driver.add_cookie({'name' : 'foo', 'value' : 'bar', 'path' : '/', 'secure' : True})
+                driver.add_cookie({'name' : 'foo', 'value' : 'bar', 'sameSite' : 'Strict'})
+        """
+        if "sameSite" in cookie_dict:
+            assert cookie_dict["sameSite"] in ["Strict", "Lax", "None"]
+            self.execute(Command.ADD_COOKIE, {"cookie": cookie_dict})
+        else:
+            self.execute(Command.ADD_COOKIE, {"cookie": cookie_dict})
+
+    # Timeouts
+    def implicitly_wait(self, time_to_wait: float) -> None:
+        """Sets a sticky timeout to implicitly wait for an element to be found,
+        or a command to complete. This method only needs to be called one time
+        per session. To set the timeout for calls to execute_async_script, see
+        set_script_timeout.
+
+        :Args:
+         - time_to_wait: Amount of time to wait (in seconds)
+
+        :Usage:
+            ::
+
+                driver.implicitly_wait(30)
+        """
+        self.execute(
+            Command.SET_TIMEOUTS, {"implicit": int(float(time_to_wait) * 1000)}
+        )
+
+    def set_script_timeout(self, time_to_wait: float) -> None:
+        """Set the amount of time that the script should wait during an
+        execute_async_script call before throwing an error.
+
+        :Args:
+         - time_to_wait: The amount of time to wait (in seconds)
+
+        :Usage:
+            ::
+
+                driver.set_script_timeout(30)
+        """
+        self.execute(Command.SET_TIMEOUTS, {"script": int(float(time_to_wait) * 1000)})
+
+    def set_page_load_timeout(self, time_to_wait: float) -> None:
+        """Set the amount of time to wait for a page load to complete before
+        throwing an error.
+
+        :Args:
+         - time_to_wait: The amount of time to wait
+
+        :Usage:
+            ::
+
+                driver.set_page_load_timeout(30)
+        """
+        try:
+            self.execute(
+                Command.SET_TIMEOUTS, {"pageLoad": int(float(time_to_wait) * 1000)}
+            )
+        except WebDriverException:
+            self.execute(
+                Command.SET_TIMEOUTS,
+                {"ms": float(time_to_wait) * 1000, "type": "page load"},
+            )
+
+    @property
+    def timeouts(self) -> Timeouts:
+        """Get all the timeouts that have been set on the current session.
+
+        :Usage:
+            ::
+
+                driver.timeouts
+        :rtype: Timeout
+        """
+        timeouts = self.execute(Command.GET_TIMEOUTS)["value"]
+        timeouts["implicit_wait"] = timeouts.pop("implicit") / 1000
+        timeouts["page_load"] = timeouts.pop("pageLoad") / 1000
+        timeouts["script"] = timeouts.pop("script") / 1000
+        return Timeouts(**timeouts)
+
+    @timeouts.setter
+    def timeouts(self, timeouts) -> None:
+        """Set all timeouts for the session. This will override any previously
+        set timeouts.
+
+        :Usage:
+            ::
+                my_timeouts = Timeouts()
+                my_timeouts.implicit_wait = 10
+                driver.timeouts = my_timeouts
+        """
+        _ = self.execute(Command.SET_TIMEOUTS, timeouts._to_json())["value"]
+
+    async def find_element(self, by=By.ID, value: Optional[str] = None) -> WebElement:
+        """Find an element given a By strategy and locator.
+
+        :Usage:
+            ::
+
+                element = driver.find_element(By.ID, 'foo')
+
+        :rtype: WebElement
+        """
+        if isinstance(by, RelativeBy):
+            elements = self.find_elements(by=by, value=value)
+            if not elements:
+                raise NoSuchElementException(
+                    f"Cannot locate relative element with: {by.root}"
+                )
+            return elements[0]
+
+        if by == By.ID:
+            by = By.CSS_SELECTOR
+            value = f'[id="{value}"]'
+        elif by == By.CLASS_NAME:
+            by = By.CSS_SELECTOR
+            value = f".{value}"
+        elif by == By.NAME:
+            by = By.CSS_SELECTOR
+            value = f'[name="{value}"]'
+
+        return self.execute(Command.FIND_ELEMENT, {"using": by, "value": value})[
+            "value"
+        ]
+
+    def find_elements(self, by=By.ID, value: Optional[str] = None) -> List[WebElement]:
+        """Find elements given a By strategy and locator.
+
+        :Usage:
+            ::
+
+                elements = driver.find_elements(By.CLASS_NAME, 'foo')
+
+        :rtype: list of WebElement
+        """
+        if isinstance(by, RelativeBy):
+            _pkg = ".".join(__name__.split(".")[:-1])
+            raw_function = pkgutil.get_data(_pkg, "findElements.js").decode("utf8")
+            find_element_js = (
+                f"/* findElements */return ({raw_function}).apply(null, arguments);"
+            )
+            return self.execute_script(find_element_js, by.to_dict())
+
+        if by == By.ID:
+            by = By.CSS_SELECTOR
+            value = f'[id="{value}"]'
+        elif by == By.CLASS_NAME:
+            by = By.CSS_SELECTOR
+            value = f".{value}"
+        elif by == By.NAME:
+            by = By.CSS_SELECTOR
+            value = f'[name="{value}"]'
+
+        # Return empty list if driver returns null
+        # See https://github.com/SeleniumHQ/selenium/issues/4555
+        return (
+            self.execute(Command.FIND_ELEMENTS, {"using": by, "value": value})["value"]
+            or []
+        )
+
+    @property
+    def desired_capabilities(self) -> dict:
+        """Returns the drivers current desired capabilities being used."""
+        warnings.warn(
+            "desired_capabilities is deprecated. Please call capabilities.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return self.caps
+
+    @property
+    def capabilities(self) -> dict:
+        """Returns the drivers current capabilities being used."""
+        return self.caps
+
+    def get_screenshot_as_file(self, filename) -> bool:
+        """Saves a screenshot of the current window to a PNG image file.
+        Returns False if there is any IOError, else returns True. Use full
+        paths in your filename.
+
+        :Args:
+         - filename: The full path you wish to save your screenshot to. This
+           should end with a `.png` extension.
+
+        :Usage:
+            ::
+
+                driver.get_screenshot_as_file('/Screenshots/foo.png')
+        """
+        if not str(filename).lower().endswith(".png"):
+            warnings.warn(
+                "name used for saved screenshot does not match file type. It should end with a `.png` extension",
+                UserWarning,
+                stacklevel=2,
+            )
+        png = self.get_screenshot_as_png()
+        try:
+            with open(filename, "wb") as f:
+                f.write(png)
+        except OSError:
+            return False
+        finally:
+            del png
+        return True
+
+    def save_screenshot(self, filename) -> bool:
+        """Saves a screenshot of the current window to a PNG image file.
+        Returns False if there is any IOError, else returns True. Use full
+        paths in your filename.
+
+        :Args:
+         - filename: The full path you wish to save your screenshot to. This
+           should end with a `.png` extension.
+
+        :Usage:
+            ::
+
+                driver.save_screenshot('/Screenshots/foo.png')
+        """
+        return self.get_screenshot_as_file(filename)
+
+    def get_screenshot_as_png(self) -> bytes:
+        """Gets the screenshot of the current window as a binary data.
+
+        :Usage:
+            ::
+
+                driver.get_screenshot_as_png()
+        """
+        return b64decode(self.get_screenshot_as_base64().encode("ascii"))
+
+    def get_screenshot_as_base64(self) -> str:
+        """Gets the screenshot of the current window as a base64 encoded string
+        which is useful in embedded images in HTML.
+
+        :Usage:
+            ::
+
+                driver.get_screenshot_as_base64()
+        """
+        return self.execute(Command.SCREENSHOT)["value"]
+
+    def set_window_size(self, width, height, windowHandle: str = "current") -> None:
+        """Sets the width and height of the current window. (window.resizeTo)
+
+        :Args:
+         - width: the width in pixels to set the window to
+         - height: the height in pixels to set the window to
+
+        :Usage:
+            ::
+
+                driver.set_window_size(800,600)
+        """
+        self._check_if_window_handle_is_current(windowHandle)
+        self.set_window_rect(width=int(width), height=int(height))
+
+    def get_window_size(self, windowHandle: str = "current") -> dict:
+        """Gets the width and height of the current window.
+
+        :Usage:
+            ::
+
+                driver.get_window_size()
+        """
+
+        self._check_if_window_handle_is_current(windowHandle)
+        size = self.get_window_rect()
+
+        if size.get("value", None):
+            size = size["value"]
+
+        return {k: size[k] for k in ("width", "height")}
+
+    def set_window_position(self, x, y, windowHandle: str = "current") -> dict:
+        """Sets the x,y position of the current window. (window.moveTo)
+
+        :Args:
+         - x: the x-coordinate in pixels to set the window position
+         - y: the y-coordinate in pixels to set the window position
+
+        :Usage:
+            ::
+
+                driver.set_window_position(0,0)
+        """
+        self._check_if_window_handle_is_current(windowHandle)
+        return self.set_window_rect(x=int(x), y=int(y))
+
+    def get_window_position(self, windowHandle="current") -> dict:
+        """Gets the x,y position of the current window.
+
+        :Usage:
+            ::
+
+                driver.get_window_position()
+        """
+
+        self._check_if_window_handle_is_current(windowHandle)
+        position = self.get_window_rect()
+
+        return {k: position[k] for k in ("x", "y")}
+
+    def _check_if_window_handle_is_current(self, windowHandle: str) -> None:
+        """Warns if the window handle is not equal to `current`."""
+        if windowHandle != "current":
+            warnings.warn(
+                "Only 'current' window is supported for W3C compatible browsers.",
+                stacklevel=2,
+            )
+
+    def get_window_rect(self) -> dict:
+        """Gets the x, y coordinates of the window as well as height and width
+        of the current window.
+
+        :Usage:
+            ::
+
+                driver.get_window_rect()
+        """
+        return self.execute(Command.GET_WINDOW_RECT)["value"]
+
+    def set_window_rect(self, x=None, y=None, width=None, height=None) -> dict:
+        """Sets the x, y coordinates of the window as well as height and width
+        of the current window. This method is only supported for W3C compatible
+        browsers; other browsers should use `set_window_position` and
+        `set_window_size`.
+
+        :Usage:
+            ::
+
+                driver.set_window_rect(x=10, y=10)
+                driver.set_window_rect(width=100, height=200)
+                driver.set_window_rect(x=10, y=10, width=100, height=200)
+        """
+
+        if (x is None and y is None) and (not height and not width):
+            raise InvalidArgumentException("x and y or height and width need values")
+
+        return self.execute(
+            Command.SET_WINDOW_RECT, {"x": x, "y": y, "width": width, "height": height}
+        )["value"]
+
+    @property
+    def file_detector(self) -> FileDetector:
+        return self._file_detector
+
+    @file_detector.setter
+    def file_detector(self, detector) -> None:
+        """Set the file detector to be used when sending keyboard input. By
+        default, this is set to a file detector that does nothing.
+
+        see FileDetector
+        see LocalFileDetector
+        see UselessFileDetector
+
+        :Args:
+         - detector: The detector to use. Must not be None.
+        """
+        if not detector:
+            raise WebDriverException("You may not set a file detector that is null")
+        if not isinstance(detector, FileDetector):
+            raise WebDriverException("Detector has to be instance of FileDetector")
+        self._file_detector = detector
+
+    @property
+    def orientation(self):
+        """Gets the current orientation of the device.
+
+        :Usage:
+            ::
+
+                orientation = driver.orientation
+        """
+        return self.execute(Command.GET_SCREEN_ORIENTATION)["value"]
+
+    @orientation.setter
+    def orientation(self, value) -> None:
+        """Sets the current orientation of the device.
+
+        :Args:
+         - value: orientation to set it to.
+
+        :Usage:
+            ::
+
+                driver.orientation = 'landscape'
+        """
+        allowed_values = ["LANDSCAPE", "PORTRAIT"]
+        if value.upper() in allowed_values:
+            self.execute(Command.SET_SCREEN_ORIENTATION, {"orientation": value})
+        else:
+            raise WebDriverException(
+                "You can only set the orientation to 'LANDSCAPE' and 'PORTRAIT'"
+            )
+
+    @property
+    def application_cache(self):
+        """Returns a ApplicationCache Object to interact with the browser app
+        cache."""
+        return ApplicationCache(self)
+
+    @property
+    def log_types(self):
+        """Gets a list of the available log types. This only works with w3c
+        compliant browsers.
+
+        :Usage:
+            ::
+
+                driver.log_types
+        """
+        return self.execute(Command.GET_AVAILABLE_LOG_TYPES)["value"]
+
+    def get_log(self, log_type):
+        """Gets the log for a given log type.
+
+        :Args:
+         - log_type: type of log that which will be returned
+
+        :Usage:
+            ::
+
+                driver.get_log('browser')
+                driver.get_log('driver')
+                driver.get_log('client')
+                driver.get_log('server')
+        """
+        return self.execute(Command.GET_LOG, {"type": log_type})["value"]
+
+    @asynccontextmanager
+    async def bidi_connection(self):
+        global cdp
+        import_cdp()
+        if self.caps.get("se:cdp"):
+            ws_url = self.caps.get("se:cdp")
+            version = self.caps.get("se:cdpVersion").split(".")[0]
+        else:
+            version, ws_url = self._get_cdp_details()
+
+        if not ws_url:
+            raise WebDriverException(
+                "Unable to find url to connect to from capabilities"
+            )
+
+        devtools = cdp.import_devtools(version)
+        async with cdp.open_cdp(ws_url) as conn:
+            targets = await conn.execute(devtools.target.get_targets())
+            target_id = targets[0].target_id
+            async with conn.open_session(target_id) as session:
+                yield BidiConnection(session, cdp, devtools)
+
+    def _get_cdp_details(self):
+        import json
+
+        import urllib3
+
+        http = urllib3.PoolManager()
+        _firefox = False
+        if self.caps.get("browserName") == "chrome":
+            debugger_address = self.caps.get("goog:chromeOptions").get(
+                "debuggerAddress"
+            )
+        elif self.caps.get("browserName") == "msedge":
+            debugger_address = self.caps.get("ms:edgeOptions").get("debuggerAddress")
+        else:
+            _firefox = True
+            debugger_address = self.caps.get("moz:debuggerAddress")
+        res = http.request("GET", f"http://{debugger_address}/json/version")
+        data = json.loads(res.data)
+
+        browser_version = data.get("Browser")
+        websocket_url = data.get("webSocketDebuggerUrl")
+
+        import re
+
+        if _firefox:
+            # Mozilla Automation Team asked to only support 85
+            # until WebDriver Bidi is available.
+            version = 85
+        else:
+            version = re.search(r".*/(\d+)\.", browser_version).group(1)
+
+        return version, websocket_url
+
+    # Virtual Authenticator Methods
+    def add_virtual_authenticator(self, options: VirtualAuthenticatorOptions) -> None:
+        """Adds a virtual authenticator with the given options."""
+        self._authenticator_id = self.execute(
+            Command.ADD_VIRTUAL_AUTHENTICATOR, options.to_dict()
+        )["value"]
+
+    @property
+    def virtual_authenticator_id(self) -> str:
+        """Returns the id of the virtual authenticator."""
+        return self._authenticator_id
+
+    @required_virtual_authenticator
+    def remove_virtual_authenticator(self) -> None:
+        """Removes a previously added virtual authenticator.
+
+        The authenticator is no longer valid after removal, so no
+        methods may be called.
+        """
+        self.execute(
+            Command.REMOVE_VIRTUAL_AUTHENTICATOR,
+            {"authenticatorId": self._authenticator_id},
+        )
+        self._authenticator_id = None
+
+    @required_virtual_authenticator
+    def add_credential(self, credential: Credential) -> None:
+        """Injects a credential into the authenticator."""
+        self.execute(
+            Command.ADD_CREDENTIAL,
+            {**credential.to_dict(), "authenticatorId": self._authenticator_id},
+        )
+
+    @required_virtual_authenticator
+    def get_credentials(self) -> List[Credential]:
+        """Returns the list of credentials owned by the authenticator."""
+        credential_data = self.execute(
+            Command.GET_CREDENTIALS, {"authenticatorId": self._authenticator_id}
+        )
+        return [
+            Credential.from_dict(credential) for credential in credential_data["value"]
+        ]
+
+    @required_virtual_authenticator
+    def remove_credential(self, credential_id: Union[str, bytearray]) -> None:
+        """Removes a credential from the authenticator."""
+        # Check if the credential is bytearray converted to b64 string
+        if isinstance(credential_id, bytearray):
+            credential_id = urlsafe_b64encode(credential_id).decode()
+
+        self.execute(
+            Command.REMOVE_CREDENTIAL,
+            {"credentialId": credential_id, "authenticatorId": self._authenticator_id},
+        )
+
+    @required_virtual_authenticator
+    def remove_all_credentials(self) -> None:
+        """Removes all credentials from the authenticator."""
+        self.execute(
+            Command.REMOVE_ALL_CREDENTIALS, {"authenticatorId": self._authenticator_id}
+        )
+
+    @required_virtual_authenticator
+    def set_user_verified(self, verified: bool) -> None:
+        """Sets whether the authenticator will simulate success or fail on user
+        verification.
+
+        verified: True if the authenticator will pass user verification, False otherwise.
+        """
+        self.execute(
+            Command.SET_USER_VERIFIED,
+            {"authenticatorId": self._authenticator_id, "isUserVerified": verified},
+        )
+
+    def get_downloadable_files(self) -> dict:
+        """Retrieves the downloadable files as a map of file names and their
+        corresponding URLs."""
+        if "se:downloadsEnabled" not in self.capabilities:
+            raise WebDriverException(
+                "You must enable downloads in order to work with downloadable files."
+            )
+
+        return self.execute(Command.GET_DOWNLOADABLE_FILES)["value"]["names"]
+
+    def download_file(self, file_name: str, target_directory: str) -> None:
+        """Downloads a file with the specified file name to the target
+        directory.
+
+        file_name: The name of the file to download.
+        target_directory: The path to the directory to save the downloaded file.
+        """
+        if "se:downloadsEnabled" not in self.capabilities:
+            raise WebDriverException(
+                "You must enable downloads in order to work with downloadable files."
+            )
+
+        if not os.path.exists(target_directory):
+            os.makedirs(target_directory)
+
+        contents = self.execute(Command.DOWNLOAD_FILE, {"name": file_name})["value"][
+            "contents"
+        ]
+
+        target_file = os.path.join(target_directory, file_name)
+        with open(target_file, "wb") as file:
+            file.write(base64.b64decode(contents))
+
+        with zipfile.ZipFile(target_file, "r") as zip_ref:
+            zip_ref.extractall(target_directory)
+
+    def delete_downloadable_files(self) -> None:
+        """Deletes all downloadable files."""
+        if "se:downloadsEnabled" not in self.capabilities:
+            raise WebDriverException(
+                "You must enable downloads in order to work with downloadable files."
+            )
+
+        self.execute(Command.DELETE_DOWNLOADABLE_FILES)
diff --git a/py/selenium/webdriver/async/remote/webelement.py b/py/selenium/webdriver/async/remote/webelement.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/py/selenium/webdriver/async/remote/webelement.py
diff --git a/py/test/selenium/webdriver/async/__init__.py b/py/test/selenium/webdriver/async/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/py/test/selenium/webdriver/async/__init__.py
diff --git a/py/test/selenium/webdriver/async/click_tests.py b/py/test/selenium/webdriver/async/click_tests.py
new file mode 100644
index 0000000..6ddda57
--- /dev/null
+++ b/py/test/selenium/webdriver/async/click_tests.py
@@ -0,0 +1,39 @@
+# 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.
+
+import pytest, pytest_asyncio
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.support.wait import WebDriverWait
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def loadPage(pages):
+    await pages.load("clicks.html")
+
+
+@pytest.mark.asyncio
+async def test_can_click_on_alink_that_overflows_and_follow_it(driver):
+    driver.find_element(By.ID, "overflowLink").click()
+    WebDriverWait(driver, 3).until(EC.title_is("XHTML Test Page"))
+
+
+@pytest.mark.asyncio
+async def test_clicking_alink_made_up_of_numbers_is_handled_correctly(driver):
+    await driver.find_element(By.LINK_TEXT, "333333").click()
+    WebDriverWait(driver, 3).until(EC.title_is("XHTML Test Page"))