From b453d4cebd575722bd2b6de0d765bfe0e5c0a8fd Mon Sep 17 00:00:00 2001
From: Anthon Pang <apang@softwaredevelopment.ca>
Date: Mon, 4 Dec 2023 12:12:39 -0500
Subject: [PATCH 1/7] Update per W3C spec. Pruning legacy cruft.

---
 .github/workflows/ci.yml                      |   9 +-
 README.md                                     |  47 +-
 composer.json                                 |   8 +-
 lib/WebDriver/AbstractWebDriver.php           | 203 +++--
 lib/WebDriver/Actions.php                     | 192 +++++
 lib/WebDriver/Alert.php                       |  83 +-
 lib/WebDriver/AppCacheStatus.php              |   2 +-
 lib/WebDriver/ApplicationCache.php            |   3 +
 lib/WebDriver/Browser.php                     |  26 +-
 lib/WebDriver/Capability.php                  |  37 +-
 lib/WebDriver/Container.php                   | 172 ++---
 lib/WebDriver/Element.php                     | 221 ++++--
 lib/WebDriver/Exception.php                   | 305 ++++----
 lib/WebDriver/Exception/CurlExec.php          |   4 +-
 .../Exception/ElementIsNotSelectable.php      |  23 -
 lib/WebDriver/Exception/ElementNotVisible.php |  23 -
 .../Exception/IMEEngineActivationFailed.php   |  23 -
 lib/WebDriver/Exception/IMENotAvailable.php   |  23 -
 .../Exception/InvalidCoordinates.php          |  23 -
 .../Exception/InvalidElementCoordinates.php   |  23 -
 lib/WebDriver/Exception/InvalidRequest.php    |   2 +
 .../Exception/JsonParameterExpected.php       |   2 +
 lib/WebDriver/Exception/NoAlertOpenError.php  |  23 -
 .../Exception/NoParametersExpected.php        |   2 +
 lib/WebDriver/Exception/NoSuchDriver.php      |  23 -
 lib/WebDriver/Exception/ObsoleteCommand.php   |   2 +
 .../Exception/UnexpectedParameters.php        |   2 +
 .../Exception/UnknownLocatorStrategy.php      |   2 +
 lib/WebDriver/Exception/XPathLookupError.php  |  23 -
 lib/WebDriver/Execute.php                     |  65 +-
 lib/WebDriver/Extension/ChromeDevTools.php    |  41 +
 .../FederatedCredentialManagementAPI.php      | 158 ++++
 lib/WebDriver/Extension/Selenium.php          | 126 ++++
 .../{ => Extension/Selenium}/LogType.php      |   4 +-
 .../Extension/WebAuthenticationAPI.php        |  41 +
 .../WebAuthenticationAPI/Credential.php       |  45 ++
 .../WebAuthenticationAPI/Credentials.php      |  58 ++
 .../VirtualAuthenticator.php                  | 115 +++
 lib/WebDriver/Frame.php                       |   6 +-
 lib/WebDriver/Ime.php                         |  40 -
 lib/WebDriver/KeyInput.php                    |  74 ++
 lib/WebDriver/LegacyElement.php               |  40 -
 lib/WebDriver/LegacyExecute.php               |  68 --
 lib/WebDriver/LegacyWindow.php                |  85 ---
 lib/WebDriver/LocatorStrategy.php             |   8 +-
 lib/WebDriver/Log.php                         |  32 -
 lib/WebDriver/NullInput.php                   |  62 ++
 lib/WebDriver/Platform.php                    |  29 +
 lib/WebDriver/PointerInput.php                | 192 +++++
 lib/WebDriver/Proxy.php                       |  34 +
 lib/WebDriver/ProxyType.php                   |  31 +
 lib/WebDriver/SauceLabs/Capability.php        | 113 ---
 lib/WebDriver/SauceLabs/SauceRest.php         | 356 ---------
 lib/WebDriver/Service/CurlService.php         |  12 +-
 .../Service/CurlServiceInterface.php          |   4 +-
 lib/WebDriver/ServiceFactory.php              |  10 +-
 lib/WebDriver/Session.php                     | 706 ++++++++++++------
 lib/WebDriver/Shadow.php                      |  16 +-
 lib/WebDriver/Storage/AbstractStorage.php     | 114 ---
 lib/WebDriver/Storage/Local.php               |  21 -
 lib/WebDriver/Storage/Session.php             |  21 -
 lib/WebDriver/Timeouts.php                    | 110 ++-
 lib/WebDriver/Touch.php                       |  46 --
 lib/WebDriver/WebDriver.php                   | 121 +--
 lib/WebDriver/WebDriverInterface.php          |  32 -
 lib/WebDriver/WheelInput.php                  |  56 ++
 lib/WebDriver/Window.php                      |  57 +-
 phpstan.neon.dist                             |   5 +-
 test/CI/Travis/setup_apache.sh                |  10 -
 test/CI/Travis/setup_selenium.sh              |   7 -
 test/Test/WebDriver/ChromeDriverTest.php      |  15 +-
 test/Test/WebDriver/ElementTest.php           |  75 ++
 test/Test/WebDriver/ExceptionTest.php         |  21 +-
 test/Test/WebDriver/GeckoDriverTest.php       |  15 +-
 test/Test/WebDriver/SeleniumWebDriverTest.php |  15 +-
 test/Test/WebDriver/SessionTest.php           |  53 ++
 test/Test/WebDriver/ShadowTest.php            |  63 ++
 test/Test/WebDriver/WebDriverTestBase.php     |  16 +-
 test/{Assets => assets}/index.html            |   0
 79 files changed, 2879 insertions(+), 2096 deletions(-)
 create mode 100644 lib/WebDriver/Actions.php
 delete mode 100644 lib/WebDriver/Exception/ElementIsNotSelectable.php
 delete mode 100644 lib/WebDriver/Exception/ElementNotVisible.php
 delete mode 100644 lib/WebDriver/Exception/IMEEngineActivationFailed.php
 delete mode 100644 lib/WebDriver/Exception/IMENotAvailable.php
 delete mode 100644 lib/WebDriver/Exception/InvalidCoordinates.php
 delete mode 100644 lib/WebDriver/Exception/InvalidElementCoordinates.php
 delete mode 100644 lib/WebDriver/Exception/NoAlertOpenError.php
 delete mode 100644 lib/WebDriver/Exception/NoSuchDriver.php
 delete mode 100644 lib/WebDriver/Exception/XPathLookupError.php
 create mode 100644 lib/WebDriver/Extension/ChromeDevTools.php
 create mode 100644 lib/WebDriver/Extension/FederatedCredentialManagementAPI.php
 create mode 100644 lib/WebDriver/Extension/Selenium.php
 rename lib/WebDriver/{ => Extension/Selenium}/LogType.php (88%)
 create mode 100644 lib/WebDriver/Extension/WebAuthenticationAPI.php
 create mode 100644 lib/WebDriver/Extension/WebAuthenticationAPI/Credential.php
 create mode 100644 lib/WebDriver/Extension/WebAuthenticationAPI/Credentials.php
 create mode 100644 lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php
 delete mode 100644 lib/WebDriver/Ime.php
 create mode 100644 lib/WebDriver/KeyInput.php
 delete mode 100644 lib/WebDriver/LegacyElement.php
 delete mode 100644 lib/WebDriver/LegacyExecute.php
 delete mode 100644 lib/WebDriver/LegacyWindow.php
 delete mode 100644 lib/WebDriver/Log.php
 create mode 100644 lib/WebDriver/NullInput.php
 create mode 100644 lib/WebDriver/Platform.php
 create mode 100644 lib/WebDriver/PointerInput.php
 create mode 100644 lib/WebDriver/Proxy.php
 create mode 100644 lib/WebDriver/ProxyType.php
 delete mode 100644 lib/WebDriver/SauceLabs/Capability.php
 delete mode 100644 lib/WebDriver/SauceLabs/SauceRest.php
 delete mode 100644 lib/WebDriver/Storage/AbstractStorage.php
 delete mode 100644 lib/WebDriver/Storage/Local.php
 delete mode 100644 lib/WebDriver/Storage/Session.php
 delete mode 100644 lib/WebDriver/Touch.php
 delete mode 100644 lib/WebDriver/WebDriverInterface.php
 create mode 100644 lib/WebDriver/WheelInput.php
 delete mode 100644 test/CI/Travis/setup_apache.sh
 delete mode 100644 test/CI/Travis/setup_selenium.sh
 create mode 100644 test/Test/WebDriver/ElementTest.php
 create mode 100644 test/Test/WebDriver/SessionTest.php
 create mode 100644 test/Test/WebDriver/ShadowTest.php
 rename test/{Assets => assets}/index.html (100%)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f0d81c2f..36fcecc2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,14 +9,11 @@ env:
 
 jobs:
   Mink:
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-22.04
     strategy:
       matrix:
-        selenium: [ '3.141.59' ]
-        php: [ '7.2', '7.3', '7.4', '8.0', '8.1' ]
-        include:
-          - selenium: '2.53.1'
-            php: 'latest'
+        selenium: [ '4.16.1' ]
+        php: [ '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3' ]
       fail-fast: false
 
     steps:
diff --git a/README.md b/README.md
index 5d4012b6..a180ef7b 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,41 @@
-WebDriver for Selenium 2
-========================
-This WebDriver client implementation is based on Meta/Facebook's original [php-webdriver](https://github.com/instaclick/php-webdriver/tree/upstream)
-project by Justin Bishop. Meta/Facebook's current [php-webdriver](https://github.com/php-webdriver/php-webdriver) is a complete rewrite.
-
-Distinguishing features:
-* Up-to-date with [WebDriver: W3C Editor's Draft 25 Octoberl 2022](https://w3c.github.io/webdriver/)
-* Up-to-date with [Selenium 2 JSON Wire Protocol](https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/remote/DriverCommand.java) (including WebDriver commands yet to be documented).
-* In the *master* branch, class names and file organization follow PSR-0 conventions for namespaces.
+# W3C WebDriver Client
+
+This "classic" W3C WebDriver client implementation is based on the
+[php-webdriver](https://github.com/instaclick/php-webdriver/tree/upstream)
+project by Justin Bishop. Originally conceived as a thin wrapper around the
+JSON Wire Protocol, the client has been refactored to work with the W3C
+WebDriver Protocol, with some fallback/emulation for older drivers. We'll
+continue to track changes to the specs but there are no immediate plans to add
+WebDriver-BiDi support.
+
+If you are starting a new project (using PHP 7.3 or above), you should
+consider using Meta/Facebook's completely rewritten (and more actively
+maintained)
+[php-webdriver](https://github.com/php-webdriver/php-webdriver).
+
+### Distinguishing features:
+
+* Up-to-date with:
+  * [WebDriver: W3C Working Draft 14 November 2023](https://www.w3.org/TR/webdriver2)
+  * [Federated Credential Management API: Draft Community Group Report, 1 December 2023](https://fedidcg.github.io/FedCM/)
+  * [Web Authentication: An API for accessing Public Key Credentials, Level 2: W3C Recommendation, 8 April 2021](https://www.w3.org/TR/webauthn-2/)
+* In the *master* branch, class names and file organization follow PSR-0
+  conventions for namespaces.
 * Coding style follows PSR-1, PSR-2, and Symfony2 conventions.
-* Auto-generate API documentation via [phpDocumentor 2.x](http://phpdoc.org/).
 
 [![Latest Stable Version](https://poser.pugx.org/instaclick/php-webdriver/v/stable.png)](https://packagist.org/packages/instaclick/php-webdriver)
 [![Total Downloads](https://poser.pugx.org/instaclick/php-webdriver/downloads.png)](https://packagist.org/packages/instaclick/php-webdriver)
 
-Links
-=====
+## Links
+
 * [Packagist](http://packagist.org/packages/instaclick/php-webdriver)
 * [Github](https://github.com/instaclick/php-webdriver)
 * [W3C/WebDriver](https://github.com/w3c/webdriver)
 
-Notes
-=====
-* The *5.2.x* branch is no longer maintained. This branch features class names and file re-organization that follow PEAR/ZF1 conventions. Bug fixes and enhancements from the master branch likely won't be backported.
+## Notes
+
+* The *1.x* branch is up-to-date with the legacy
+  [Selenium 2 JSON Wire Protocol](https://www.selenium.dev/documentation/legacy/json_wire_protocol/).
+* The *5.2.x* branch is no longer maintained. This branch features class
+  names and file re-organization that follow PEAR/ZF1 conventions. Bug fixes
+  and enhancements from the master branch likely won't be backported.
diff --git a/composer.json b/composer.json
index 6edf268e..ff6d2a8e 100644
--- a/composer.json
+++ b/composer.json
@@ -6,7 +6,9 @@
         "selenium",
         "webdriver",
         "webtest",
-        "browser"
+        "browser",
+        "test",
+        "automation"
     ],
     "homepage": "http://instaclick.com/",
     "license": "Apache-2.0",
@@ -23,11 +25,11 @@
         }
     ],
     "require": {
-        "php": ">=5.3.2",
+        "php": ">=7.2",
         "ext-curl": "*"
     },
     "require-dev": {
-        "php": ">=7.1",
+        "php": ">=7.2",
         "phpunit/phpunit": "^8.5 || ^9.5"
     },
     "minimum-stability": "dev",
diff --git a/lib/WebDriver/AbstractWebDriver.php b/lib/WebDriver/AbstractWebDriver.php
index 55dce988..cd4910de 100644
--- a/lib/WebDriver/AbstractWebDriver.php
+++ b/lib/WebDriver/AbstractWebDriver.php
@@ -42,36 +42,45 @@ abstract class AbstractWebDriver
     private $transientOptions;
 
     /**
-     * Return array of supported method names and corresponding HTTP request methods
+     * @var array
+     */
+    private $extensions;
+
+    /**
+     * Return array of protocol methods
      *
      * @return array
      */
-    abstract protected function methods();
+    protected function methods()
+    {
+        return [];
+    }
 
     /**
-     * Return array of obsolete method names and corresponding HTTP request methods
+     * Return array of chainable method/property names
      *
      * @return array
      */
-    protected function obsoleteMethods()
+    protected function chainable()
     {
-        return array();
+        return [];
     }
 
     /**
      * Constructor
      *
-     * @param string $url URL to Selenium server
+     * @param string $url
      */
-    public function __construct($url = 'http://localhost:4444/wd/hub')
+    public function __construct($url)
     {
         $this->url = $url;
-        $this->transientOptions = array();
+        $this->transientOptions = [];
+        $this->extensions = [];
         $this->curlService = ServiceFactory::getInstance()->getService('service.curl');
     }
 
     /**
-     * Magic method which returns URL to Selenium server
+     * Return URL
      *
      * @return string
      */
@@ -81,7 +90,7 @@ public function __toString()
     }
 
     /**
-     * Returns URL to Selenium server
+     * Return URL
      *
      * @return string
      */
@@ -117,7 +126,7 @@ public function getCurlService()
      */
     public function setTransientOptions($transientOptions)
     {
-        $this->transientOptions = is_array($transientOptions) ? $transientOptions : array();
+        $this->transientOptions = is_array($transientOptions) ? $transientOptions : [];
     }
 
     /**
@@ -128,12 +137,100 @@ public function getTransientOptions()
         return $this->transientOptions;
     }
 
+    /**
+     * Register extension
+     *
+     * @param string $extension
+     * @param string $className
+     * @param string $path
+     */
+    public function register($extension, $className, $path)
+    {
+        if (class_exists($className, false)) {
+            $this->extensions[$extension] = [$className, $path];
+        }
+    }
+
+    /**
+     * Magic method that maps calls to class methods to execute WebDriver commands
+     *
+     * @param string $name      Method name
+     * @param array  $arguments Arguments
+     *
+     * @return mixed
+     *
+     * @throws \WebDriver\Exception if invalid WebDriver command
+     */
+    public function __call($name, $arguments)
+    {
+        if (count($arguments) > 1) {
+            throw WebDriverException::factory(
+                WebDriverException::_JSON_PARAMETERS_EXPECTED,
+                'Commands should have at most only one parameter, which should be the JSON Parameter object'
+            );
+        }
+
+        if (count($arguments) === 0 && is_array($this->extensions) && array_key_exists($name, $this->extensions)) {
+            $className = $this->extensions[$name][0];
+
+            return new $className($this->url . '/' . $this->extensions[$name][1]);
+        }
+
+        if (count($arguments) === 0 && array_key_exists($name, $this->chainable())) {
+            return call_user_func([$this, $name]);
+        }
+
+        if (preg_match('/^(get|post|delete)/', $name, $matches)) {
+            $requestMethod = strtoupper($matches[0]);
+            $webdriverCommand = strtolower(substr($name, strlen($requestMethod)));
+
+            $this->getRequestMethod($webdriverCommand); // validation
+        } else {
+            $webdriverCommand = $name;
+            $requestMethod = $this->getRequestMethod($webdriverCommand);
+        }
+
+        $methods = $this->methods();
+
+        if (! in_array($requestMethod, $methods[$webdriverCommand])) {
+            throw WebDriverException::factory(
+                WebDriverException::_INVALID_REQUEST,
+                sprintf(
+                    '%s is not an available http request method for the command %s.',
+                    $requestMethod,
+                    $webdriverCommand
+                )
+            );
+        }
+
+        $parameters = array_shift($arguments);
+        $result = $this->curl($requestMethod, '/' . $webdriverCommand, $parameters);
+
+        return $result['value'];
+    }
+
+    /**
+     * Magic method that maps property names to chainable methods
+     *
+     * @param string $name Property name
+     *
+     * @return mixed
+     */
+    public function __get($name)
+    {
+        if (array_key_exists($name, $this->chainable())) {
+            return call_user_func([$this, $name]);
+        }
+
+        trigger_error('Undefined property: ' . __CLASS__ . '::$' . $name, E_USER_WARNING);
+    }
+
     /**
      * Curl request to webdriver server.
      *
      * @param string               $requestMethod HTTP request method, e.g., 'GET', 'POST', or 'DELETE'
      * @param string               $command       If not defined in methods() this function will throw.
-     * @param array|integer|string $parameters    If an array(), they will be posted as JSON parameters
+     * @param array|integer|string $parameters    If an array, they will be posted as JSON parameters
      *                                            If a number or string, "/$params" is appended to url
      * @param array                $extraOptions  key=>value pairs of curl options to pass to curl_setopt()
      *
@@ -141,11 +238,11 @@ public function getTransientOptions()
      *
      * @throws \WebDriver\Exception if error
      */
-    protected function curl($requestMethod, $command, $parameters = null, $extraOptions = array())
+    protected function curl($requestMethod, $command, $parameters = null, $extraOptions = [])
     {
         if ($parameters && is_array($parameters) && $requestMethod !== 'POST') {
             throw WebDriverException::factory(
-                WebDriverException::NO_PARAMETERS_EXPECTED,
+                WebDriverException::_NO_PARAMETERS_EXPECTED,
                 sprintf(
                     'The http request method called for %s is %s but it has to be POST if you want to pass the JSON parameters %s',
                     $command,
@@ -155,9 +252,11 @@ protected function curl($requestMethod, $command, $parameters = null, $extraOpti
             );
         }
 
-        $url = sprintf('%s%s', $this->url, $command);
+        $url = $this->url . $command;
 
-        if ($parameters && (is_int($parameters) || is_string($parameters))) {
+        if (($requestMethod === 'GET' || $requestMethod === 'DELETE')
+            && $parameters && (is_int($parameters) || is_string($parameters))
+        ) {
             $url .= '/' . $parameters;
         }
 
@@ -170,13 +269,13 @@ protected function curl($requestMethod, $command, $parameters = null, $extraOpti
             array_replace($extraOptions, $this->transientOptions)
         );
 
-        $this->transientOptions = array();
+        $this->transientOptions = [];
 
         $httpCode = $info['http_code'];
 
         if ($httpCode === 0) {
             throw WebDriverException::factory(
-                WebDriverException::CURL_EXEC,
+                WebDriverException::_CURL_EXEC,
                 $info['error']
             );
         }
@@ -187,20 +286,20 @@ protected function curl($requestMethod, $command, $parameters = null, $extraOpti
             // Legacy webdriver 4xx responses are to be considered a plaintext error
             if ($httpCode >= 400 && $httpCode <= 499) {
                 throw WebDriverException::factory(
-                    WebDriverException::CURL_EXEC,
+                    WebDriverException::_CURL_EXEC,
                     'Webdriver http error: ' . $httpCode . ', payload :' . substr($rawResult, 0, 1000)
                 );
             }
 
             throw WebDriverException::factory(
-                WebDriverException::CURL_EXEC,
+                WebDriverException::_CURL_EXEC,
                 'Payload received from webdriver is not valid json: ' . substr($rawResult, 0, 1000)
             );
         }
 
         if (is_array($result) && ! array_key_exists('status', $result) && ! array_key_exists('value', $result)) {
             throw WebDriverException::factory(
-                WebDriverException::CURL_EXEC,
+                WebDriverException::_CURL_EXEC,
                 'Payload received from webdriver is valid but unexpected json: ' . substr($rawResult, 0, 1000)
             );
         }
@@ -234,63 +333,12 @@ protected function curl($requestMethod, $command, $parameters = null, $extraOpti
                   ?: $this->offsetGet('sessionId', $value)
                   ?: $this->offsetGet('webdriver.remote.sessionid', $value);
 
-        return array(
+        return [
             'value'      => $value,
             'info'       => $info,
             'sessionId'  => $sessionId,
             'sessionUrl' => $sessionId ? $this->url . '/session/' . $sessionId : $info['url'],
-        );
-    }
-
-    /**
-     * Magic method that maps calls to class methods to execute WebDriver commands
-     *
-     * @param string $name      Method name
-     * @param array  $arguments Arguments
-     *
-     * @return mixed
-     *
-     * @throws \WebDriver\Exception if invalid WebDriver command
-     */
-    public function __call($name, $arguments)
-    {
-        if (count($arguments) > 1) {
-            throw WebDriverException::factory(
-                WebDriverException::JSON_PARAMETERS_EXPECTED,
-                'Commands should have at most only one parameter, which should be the JSON Parameter object'
-            );
-        }
-
-        if (preg_match('/^(get|post|delete)/', $name, $matches)) {
-            $requestMethod = strtoupper($matches[0]);
-            $webdriverCommand = strtolower(substr($name, strlen($requestMethod)));
-
-            $this->getRequestMethod($webdriverCommand); // validation
-        } else {
-            $webdriverCommand = $name;
-            $requestMethod = $this->getRequestMethod($webdriverCommand);
-        }
-
-        $methods = $this->methods();
-
-        if (! in_array($requestMethod, (array) $methods[$webdriverCommand])) {
-            throw WebDriverException::factory(
-                WebDriverException::INVALID_REQUEST,
-                sprintf(
-                    '%s is not an available http request method for the command %s.',
-                    $requestMethod,
-                    $webdriverCommand
-                )
-            );
-        }
-
-        $result = $this->curl(
-            $requestMethod,
-            '/' . $webdriverCommand,
-            array_shift($arguments)
-        );
-
-        return $result['value'];
+        ];
     }
 
     /**
@@ -313,7 +361,7 @@ private function assertSerializable($parameters)
         }
 
         throw WebDriverException::factory(
-            WebDriverException::UNEXPECTED_PARAMETERS,
+            WebDriverException::_UNEXPECTED_PARAMETERS,
             sprintf(
                 "Unable to serialize non-scalar type %s",
                 is_object($parameters) ? get_class($parameters) : gettype($parameters)
@@ -347,14 +395,13 @@ private function getRequestMethod($webdriverCommand)
     {
         if (! array_key_exists($webdriverCommand, $this->methods())) {
             throw WebDriverException::factory(
-                array_key_exists($webdriverCommand, $this->obsoleteMethods())
-                ? WebDriverException::OBSOLETE_COMMAND : WebDriverException::UNKNOWN_COMMAND,
+                WebDriverException::UNKNOWN_COMMAND,
                 sprintf('%s is not a valid WebDriver command.', $webdriverCommand)
             );
         }
 
         $methods = $this->methods();
-        $requestMethods = (array) $methods[$webdriverCommand];
+        $requestMethods = $methods[$webdriverCommand];
 
         return array_shift($requestMethods);
     }
diff --git a/lib/WebDriver/Actions.php b/lib/WebDriver/Actions.php
new file mode 100644
index 00000000..d7e8cbf0
--- /dev/null
+++ b/lib/WebDriver/Actions.php
@@ -0,0 +1,192 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver;
+
+/**
+ * WebDriver\Actions class
+ *
+ * @package WebDriver
+ */
+class Actions extends AbstractWebDriver
+{
+    /**
+     * singleton
+     *
+     * @var \WebDriver\Actions
+     */
+    private static $instance;
+
+    /**
+     * @var array
+     */
+    private $inputSources = [
+        NullInput::TYPE    => [],
+        KeyInput::TYPE     => [],
+        PointerInput::TYPE => [],
+        WheelInput::TYPE   => [],
+    ];
+
+    /**
+     * @var array
+     */
+    private $actions;
+
+    /**
+     * {@inheritDoc}
+     */
+    private function __construct($url)
+    {
+        parent::__construct($url);
+
+        $this->clearAllActions();
+    }
+
+    /**
+     * Get singleton instance
+     *
+     * @param string $url
+     *
+     * @return \WebDriver\Actions
+     */
+    public static function getInstance($url)
+    {
+        if (self::$instance === null) {
+            self::$instance = new self($url);
+        }
+
+        return self::$instance;
+    }
+
+    /**
+     * Get Null Input Source
+     *
+     * @return \WebDriver\NullInput
+     */
+    public function getNullInput($id = 0)
+    {
+        if (! array_key_exists($id, $this->inputSources[NullInput::TYPE])) {
+            $inputSource = new NullInput($id);
+
+            $this->inputSources[NullInput::TYPE][$id] = $inputSource;
+        }
+
+        return $this->inputSources[NullInput::TYPE][$id];
+    }
+
+    /**
+     * Get Key Input Source
+     *
+     * @return \WebDriver\KeyInput
+     */
+    public function getKeyInput($id = 0)
+    {
+        if (! array_key_exists($id, $this->inputSources[KeyInput::TYPE])) {
+            $inputSource = new KeyInput($id);
+
+            $this->inputSources[KeyInput::TYPE][$id] = $inputSource;
+        }
+
+        return $this->inputSources[KeyInput::TYPE][$id];
+    }
+
+    /**
+     * Get Pointer Input Source
+     *
+     * @return \WebDriver\PointerInput
+     */
+    public function getPointerInput($id = 0, $subType = PointerInput::MOUSE)
+    {
+        if (! array_key_exists($id, $this->inputSources[PointerInput::TYPE])) {
+            $inputSource = new PointerInput($id, $subType);
+
+            $this->inputSources[PointerInput::TYPE][$id] = $inputSource;
+        }
+
+        return $this->inputSources[PointerInput::TYPE][$id];
+    }
+
+    /**
+     * Get Wheel Input Source
+     *
+     * @return \WebDriver\WheelInput
+     */
+    public function getWheelInput($id = 0)
+    {
+        if (! array_key_exists($id, $this->inputSources[WheelInput::TYPE])) {
+            $inputSource = new WheelInput($id);
+
+            $this->inputSources[WheelInput::TYPE][$id] = $inputSource;
+        }
+
+        return $this->inputSources[WheelInput::TYPE][$id];
+    }
+
+    /**
+     * Perform actions: /session/:sessionId/actions (POST)
+     *
+     * @return mixed
+     */
+    public function perform()
+    {
+        $actions = $this->actions;
+        $parameters = ['actions' => $actions];
+
+        $this->clearAllActions();
+
+        $result = $this->curl('POST', '', $parameters);
+
+        return $result['value'];
+    }
+
+    /**
+     * Release all action state: /session/:sessionId/actions (DELETE)
+     *
+     * @return mixed
+     */
+    public function releaseActions()
+    {
+        $result = $this->curl('DELETE', '');
+
+        return $result['value'];
+    }
+
+    /**
+     * Clear all actions from the builder
+     */
+    public function clearAllActions()
+    {
+        $this->actions = [];
+    }
+
+    /**
+     * Add action
+     *
+     * @param array $action
+     *
+     * @return \WebDriver\Actions
+     */
+    public function addAction($action)
+    {
+        if (($last = count($this->actions)) &&
+            $this->actions[$last - 1]['id'] === $action['id'] &&
+            $this->actions[$last - 1]['type'] === $action['type']
+        ) {
+            foreach ($action['actions'] as $item) {
+                $this->actions[$last - 1]['actions'][] = $item;
+            }
+        } else {
+            $this->actions[] = $action;
+        }
+
+        return $this;
+    }
+}
diff --git a/lib/WebDriver/Alert.php b/lib/WebDriver/Alert.php
index 3e446d4b..2989483e 100644
--- a/lib/WebDriver/Alert.php
+++ b/lib/WebDriver/Alert.php
@@ -16,10 +16,13 @@
  *
  * @package WebDriver
  *
+ * W3C
  * @method array accept() Accept alert.
  * @method array dismiss() Dismiss alert.
  * @method array getText() Get alert text.
- * @method array postText($json) Send alert text.
+ * @method array postText($parameters) Set alert value.
+ * Selenium
+ * @method array credentials($parameters) Set alert credentials.
  */
 class Alert extends AbstractWebDriver
 {
@@ -28,10 +31,78 @@ class Alert extends AbstractWebDriver
      */
     protected function methods()
     {
-        return array(
-            'accept' => array('POST'),
-            'dismiss' => array('POST'),
-            'text' => array('GET', 'POST'),
-        );
+        return [
+            'accept'      => ['POST'],
+            'dismiss'     => ['POST'],
+            'text'        => ['GET', 'POST'],
+
+            // @deprecated
+            'credentials' => ['POST'],
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function aliases()
+    {
+        return [
+            // @deprecated
+            'setAlertCredentials' => 'credentials',
+        ];
+    }
+
+    /**
+     * Accept alert: /session/:sessionId/alert/accept (POST)
+     *
+     * @return mixed
+     */
+    public function acceptAlert()
+    {
+        $result = $this->curl('POST', '/accept');
+
+        return $result['value'];
+    }
+
+    /**
+     * Dismiss alert: /session/:sessionId/alert/dismiss (POST)
+     *
+     * @return mixed
+     */
+    public function dismissAlert()
+    {
+        $result = $this->curl('POST', '/dismiss');
+
+        return $result['value'];
+    }
+
+    /**
+     * Get alert text: /session/:sessionId/alert/text (GET)
+     *
+     * @return mixed
+     */
+    public function getAlertText()
+    {
+        $result = $this->curl('GET', '/text');
+
+        return $result['value'];
+    }
+
+    /**
+     * Set alert value: /session/:sessionId/alert/text (POST)
+     *
+     * @param array|string $text Parameters {text: ...}
+     *
+     * @return mixed
+     */
+    public function setAlertValue($text)
+    {
+        $parameters = is_array($text)
+            ? $text
+            : ['text' => $text];
+
+        $result = $this->curl('POST', '/text', $parameters);
+
+        return $result['value'];
     }
 }
diff --git a/lib/WebDriver/AppCacheStatus.php b/lib/WebDriver/AppCacheStatus.php
index 879eac82..b306cfbc 100644
--- a/lib/WebDriver/AppCacheStatus.php
+++ b/lib/WebDriver/AppCacheStatus.php
@@ -16,7 +16,7 @@
  *
  * @package WebDriver
  *
- * @deprecated by W3C WebDriver
+ * @deprecated
  */
 final class AppCacheStatus
 {
diff --git a/lib/WebDriver/ApplicationCache.php b/lib/WebDriver/ApplicationCache.php
index c64ac3aa..a70a885b 100644
--- a/lib/WebDriver/ApplicationCache.php
+++ b/lib/WebDriver/ApplicationCache.php
@@ -16,6 +16,8 @@
  *
  * @package WebDriver
  *
+ * @deprecated
+ *
  * @method integer status() Get application cache status.
  */
 class ApplicationCache extends AbstractWebDriver
@@ -26,6 +28,7 @@ class ApplicationCache extends AbstractWebDriver
     protected function methods()
     {
         return array(
+            // @deprecated
             'status' => array('GET'),
         );
     }
diff --git a/lib/WebDriver/Browser.php b/lib/WebDriver/Browser.php
index 7e497774..ed51a34e 100644
--- a/lib/WebDriver/Browser.php
+++ b/lib/WebDriver/Browser.php
@@ -19,22 +19,14 @@
 final class Browser
 {
     /**
-     * Check browser names used in static functions in the selenium source:
-     * @see https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/remote/BrowserType.java
+     * @see https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/remote/Browser.java
      */
-    const ANDROID           = 'android';
-    const CHROME            = 'chrome';
-    const EDGE              = 'edge';
-    const EDGEHTML          = 'EdgeHTML';
-    const FIREFOX           = 'firefox';
-    const HTMLUNIT          = 'htmlunit';
-    const IE                = 'internet explorer';
-    const INTERNET_EXPLORER = 'internet explorer';
-    const IPHONE            = 'iPhone';
-    const IPAD              = 'iPad';
-    const MSEDGE            = 'MicrosoftEdge';
-    const OPERA             = 'opera';
-    const OPERA_BLINK       = 'operablink';
-    const PHANTOMJS         = 'phantomjs';
-    const SAFARI            = 'safari';
+    const CHROME              = 'chrome';
+    const EDGE                = 'MicrosoftEdge';
+    const FIREFOX             = 'firefox';
+    const HTMLUNIT            = 'htmlunit';
+    const IE                  = 'internet explorer';
+    const OPERA               = 'opera';
+    const SAFARI              = 'safari';
+    const SAFARI_TECH_PREVIEW = 'Safari Technology Preview';
 }
diff --git a/lib/WebDriver/Capability.php b/lib/WebDriver/Capability.php
index 4b3cec9f..17ac65a2 100644
--- a/lib/WebDriver/Capability.php
+++ b/lib/WebDriver/Capability.php
@@ -24,45 +24,16 @@ final class Capability
      * @see https://w3c.github.io/webdriver/webdriver-spec.html#capabilities
      * @see https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/remote/CapabilityType.java
      */
+    const ACCEPT_INSECURE_CERTS       = 'acceptInsecureCerts';
     const BROWSER_NAME                = 'browserName';
     const BROWSER_VERSION             = 'browserVersion';
+    const ENABLE_DOWNLOADS            = 'enableDownloads';
     const PLATFORM_NAME               = 'platformName';
     const PLATFORM_VERSION            = 'platformVersion';
-    const ACCEPT_SSL_CERTS            = 'acceptSslCerts';
     const PAGE_LOAD_STRATEGY          = 'pageLoadStrategy';
     const PROXY                       = 'proxy';
-    const TIMEOUTS                    = 'timeouts';
-
-    // legacy JSON Wire Protocol
-    const VERSION                     = 'version';
-    const PLATFORM                    = 'platform';
-    const JAVASCRIPT_ENABLED          = 'javascriptEnabled';
-    const TAKES_SCREENSHOT            = 'takesScreenshot';
-    const HANDLES_ALERTS              = 'handlesAlerts';
-    const DATABASE_ENABLED            = 'databaseEnabled';
-    const LOCATION_CONTEXT_ENABLED    = 'locationContextEnabled';
-    const APPLICATION_CACHE_ENABLED   = 'applicationCacheEnabled';
-    const BROWSER_CONNECTION_ENABLED  = 'browserConnectionEnabled';
-    const CSS_SELECTORS_ENABLED       = 'cssSelectorsEnabled';
-    const WEB_STORAGE_ENABLED         = 'webStorageEnabled';
-    const ROTATABLE                   = 'rotatable';
-    const NATIVE_EVENTS               = 'nativeEvents';
-    const UNEXPECTED_ALERT_BEHAVIOUR  = 'unexpectedAlertBehaviour';
-    const ELEMENT_SCROLL_BEHAVIOR     = 'elementScrollBehavior';
+    const SET_WINDOW_RECT             = 'setWindowRect';
     const STRICT_FILE_INTERACTABILITY = 'strictFileInteractability';
+    const TIMEOUTS                    = 'timeouts';
     const UNHANDLED_PROMPT_BEHAVIOR   = 'unhandlePromptBehavior';
-
-    /**
-     * Proxy types
-     *
-     * @see https://w3c.github.io/webdriver/webdriver-spec.html#proxy
-     */
-    const AUTODETECT = 'autodetect';
-    const MANUAL     = 'manual';
-    const NO_PROXY   = 'noproxy';
-    const PAC        = 'pac';
-    const SYSTEM     = 'system';
-
-    // legacy JSON Wire Protocol
-    const DIRECT     = 'direct';
 }
diff --git a/lib/WebDriver/Container.php b/lib/WebDriver/Container.php
index 5739efdb..4f11c292 100644
--- a/lib/WebDriver/Container.php
+++ b/lib/WebDriver/Container.php
@@ -49,23 +49,19 @@ public function __construct($url)
      *
      * @throws \WebDriver\Exception if element not found, or invalid XPath
      */
-    public function element($using = null, $value = null)
+    public function findElement($using = null, $value = null)
     {
-        $locatorJson = $this->parseArgs('element', func_get_args());
+        $parameters = $this->parseArgs('element', func_get_args());
 
         try {
-            $result = $this->curl(
-                'POST',
-                '/element',
-                $locatorJson
-            );
+            $result = $this->curl('POST', '/element', $parameters);
         } catch (WebDriverException\NoSuchElement $e) {
             throw WebDriverException::factory(
                 WebDriverException::NO_SUCH_ELEMENT,
                 sprintf(
                     "Element not found with %s, %s\n\n%s",
-                    $locatorJson['using'],
-                    $locatorJson['value'],
+                    $parameters['using'],
+                    $parameters['value'],
                     $e->getMessage()
                 ),
                 $e
@@ -79,8 +75,8 @@ public function element($using = null, $value = null)
                 WebDriverException::NO_SUCH_ELEMENT,
                 sprintf(
                     "Element not found with %s, %s\n",
-                    $locatorJson['using'],
-                    $locatorJson['value']
+                    $parameters['using'],
+                    $parameters['value']
                 )
             );
         }
@@ -100,69 +96,24 @@ public function element($using = null, $value = null)
      *
      * @throws \WebDriver\Exception if invalid XPath
      */
-    public function elements($using = null, $value = null)
+    public function findElements($using = null, $value = null)
     {
-        $locatorJson = $this->parseArgs('elements', func_get_args());
+        $parameters = $this->parseArgs('elements', func_get_args());
 
-        $result = $this->curl(
-            'POST',
-            '/elements',
-            $locatorJson
-        );
+        $result = $this->curl('POST', '/elements', $parameters);
 
         if (! is_array($result['value'])) {
-            return array();
+            return [];
         }
 
         return array_filter(
             array_map(
-                array($this, 'makeElement'),
+                [$this, 'makeElement'],
                 $result['value']
             )
         );
     }
 
-    /**
-     * Parse arguments allowing either separate $using and $value parameters, or
-     * as an array containing the JSON parameters
-     *
-     * @param string $method method name
-     * @param array  $argv   arguments
-     *
-     * @return array
-     *
-     * @throws \WebDriver\Exception if invalid number of arguments to the called method
-     */
-    private function parseArgs($method, $argv)
-    {
-        $argc = count($argv);
-
-        switch ($argc) {
-            case 2:
-                $using = $argv[0];
-                $value = $argv[1];
-                break;
-
-            case 1:
-                $arg = $argv[0];
-
-                if (is_array($arg)) {
-                    $using = $arg['using'];
-                    $value = $arg['value'];
-                    break;
-                }
-
-                // fall through
-            default:
-                throw WebDriverException::factory(
-                    WebDriverException::JSON_PARAMETERS_EXPECTED,
-                    sprintf('Invalid arguments to %s method: %s', $method, print_r($argv, true))
-                );
-        }
-
-        return $this->locate($using, $value);
-    }
-
     /**
      * Return JSON parameter for element / elements command
      *
@@ -177,15 +128,38 @@ public function locate($using, $value)
     {
         if (! in_array($using, $this->strategies)) {
             throw WebDriverException::factory(
-                WebDriverException::UNKNOWN_LOCATOR_STRATEGY,
+                WebDriverException::_UNKNOWN_LOCATOR_STRATEGY,
                 sprintf('Invalid locator strategy %s', $using)
             );
         }
 
-        return array(
+        return [
             'using' => $using,
             'value' => $value,
-        );
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __call($name, $arguments)
+    {
+        // define the aliases here because superclasses may define their own aliases
+        $aliases = [
+            'element'  => 'findElement',
+            'elements' => 'findElements',
+        ];
+
+        if (array_key_exists($name, $aliases)) {
+            return call_user_func_array([$this, $aliases[$name]], $arguments);
+        }
+
+        if (count($arguments) === 1 && in_array(str_replace('_', ' ', $name), $this->strategies)) {
+            return $this->locate($name, $arguments[0]);
+        }
+
+        // fallback to executing WebDriver commands
+        return parent::__call($name, $arguments);
     }
 
     /**
@@ -197,40 +171,19 @@ public function locate($using, $value)
      */
     protected function makeElement($value)
     {
-        if (array_key_exists(LegacyElement::LEGACY_ELEMENT_ID, (array) $value)) {
-            $identifier = $value[LegacyElement::LEGACY_ELEMENT_ID];
-
-            return new LegacyElement(
-                $this->getIdentifierPath($identifier),
-                $identifier
-            );
+        if (! is_array($value)) {
+            $value = [$value];
         }
 
         if (array_key_exists(Element::WEB_ELEMENT_ID, (array) $value)) {
             $identifier = $value[Element::WEB_ELEMENT_ID];
 
-            return new Element(
-                $this->getIdentifierPath($identifier),
-                $identifier
-            );
+            return new Element($this->getNewIdentifierPath($identifier), $identifier);
         }
 
         return null;
     }
 
-    /**
-     * {@inheritdoc}
-     */
-    public function __call($name, $arguments)
-    {
-        if (count($arguments) === 1 && in_array(str_replace('_', ' ', $name), $this->strategies)) {
-            return $this->locate($name, $arguments[0]);
-        }
-
-        // fallback to executing WebDriver commands
-        return parent::__call($name, $arguments);
-    }
-
     /**
      * Get wire protocol URL for an identifier
      *
@@ -238,5 +191,46 @@ public function __call($name, $arguments)
      *
      * @return string
      */
-    abstract protected function getIdentifierPath($identifier);
+    abstract protected function getNewIdentifierPath($identifier);
+
+    /**
+     * Parse arguments allowing either separate $using and $value parameters, or
+     * as an array containing the JSON parameters
+     *
+     * @param string $method method name
+     * @param array  $argv   arguments
+     *
+     * @return array
+     *
+     * @throws \WebDriver\Exception if invalid number of arguments to the called method
+     */
+    private function parseArgs($method, $argv)
+    {
+        $argc = count($argv);
+
+        switch ($argc) {
+            case 2:
+                $using = $argv[0];
+                $value = $argv[1];
+                break;
+
+            case 1:
+                $arg = $argv[0];
+
+                if (is_array($arg)) {
+                    $using = $arg['using'];
+                    $value = $arg['value'];
+                    break;
+                }
+
+                // fall through
+            default:
+                throw WebDriverException::factory(
+                    WebDriverException::_JSON_PARAMETERS_EXPECTED,
+                    sprintf('Invalid arguments to %s method: %s', $method, print_r($argv, true))
+                );
+        }
+
+        return $this->locate($using, $value);
+    }
 }
diff --git a/lib/WebDriver/Element.php b/lib/WebDriver/Element.php
index d572834d..4706dcb2 100644
--- a/lib/WebDriver/Element.php
+++ b/lib/WebDriver/Element.php
@@ -16,21 +16,25 @@
  *
  * @package WebDriver
  *
+ * W3C
+ * @method string attribute($attributeName) Get the value of an element's attribute.
  * @method void clear() Clear a TEXTAREA or text INPUT element's value.
  * @method void click() Click on an element.
- * @method boolean displayed() Determine if an element is currently displayed.
+ * @method array computedlabel() Get ARIA Role.
+ * @method array computedrole() Get Accessible Name.
+ * @method string css($propertyName) Query the value of an element's computed CSS property.
  * @method boolean enabled() Determine if an element is currently enabled.
- * @method boolean equals($otherId) Test if two element IDs refer to the same DOM element.
- * @method array location() Determine an element's location on the page.
- * @method array location_in_view() Determine an element's location on the screen once it has been scrolled into view.
  * @method string name() Query for an element's tag name.
+ * @method array property($propertyName) Get element property.
  * @method array rect() Get element rect.
  * @method array screenshot() Take element screenshot.
- * @method array selected() Is element selected?
- * @method array size() Determine an element's size in pixels.
- * @method void submit() Submit a FORM element.
+ * @method boolean selected() Is element selected?
  * @method string text() Returns the visible text for the element.
- * @method void postValue($json) Send a sequence of key strokes to an element.
+ * @method void value($parameters) Send a sequence of key strokes to an element.
+ * Selenium
+ * @method boolean equals($otherId) Test if two element IDs refer to the same DOM element.
+ * @method array location() Determine an element's location on the page.
+ * @method array size() Determine an element's size in pixels.
  */
 class Element extends Container
 {
@@ -48,42 +52,48 @@ class Element extends Container
      */
     protected function methods()
     {
-        return array(
-            'clear' => array('POST'),
-            'click' => array('POST'),
-            'enabled' => array('GET'),
-            'name' => array('GET'),
-            'rect' => array('GET'),
-            'screenshot' => array('GET'),
-            'selected' => array('GET'),
-            'text' => array('GET'),
-            'value' => array('POST'),
-
-            // Legacy JSON Wire Protocol
-            'displayed' => array('GET'),        // @see https://w3c.github.io/webdriver/#element-displayedness
-            'equals' => array('GET'),
-            'location' => array('GET'),
-            'location_in_view' => array('GET'),
-            'size' => array('GET'),
-            'submit' => array('POST'),
-        );
+        return [
+            'attribute'     => ['GET'],
+            'clear'         => ['POST'],
+            'click'         => ['POST'],
+            'computedlabel' => ['GET'],
+            'computedrole'  => ['GET'],
+            'css'           => ['GET'],
+            'enabled'       => ['GET'],
+            'name'          => ['GET'],
+            'property'      => ['GET'],
+            'rect'          => ['GET'],
+            'screenshot'    => ['GET'],
+            'selected'      => ['GET'],
+            'text'          => ['GET'],
+            'value'         => ['POST'],
+
+            // @deprecated
+            'equals'        => ['GET'],
+        ];
     }
 
     /**
      * {@inheritdoc}
      */
-    protected function obsoleteMethods()
+    protected function chainable()
     {
-        return array(
-            'active' => array('GET'),
-            'computedlabel' => array('GET'),
-            'computedrole' => array('GET'),
-            'drag' => array('POST'),
-            'hover' => array('POST'),
-            'selected' => array('POST'),
-            'toggle' => array('POST'),
-            'value' => array('GET'),
-        );
+        return [
+            'shadow' => 'getShadowRoot',
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function aliases()
+    {
+        return [
+            // @deprecated
+            'location'  => 'rect',
+            'size'      => 'rect',
+            'postValue' => 'sendKeys',
+        ];
     }
 
     /**
@@ -94,7 +104,7 @@ protected function obsoleteMethods()
      */
     public function __construct($url, $id)
     {
-        parent::__construct($url);
+        parent::__construct($url . "/$id");
 
         $this->id = $id;
     }
@@ -110,78 +120,153 @@ public function getID()
     }
 
     /**
-     * Get the value of an element's attribute: /session/:sessionId/element/:id/attribute/:name
+     * Get Accessible Name: /session/:sessionId/element/:elementId/computedlabel
      *
-     * @param string name
+     * @return mixed
+     */
+    public function getAccessibleName()
+    {
+        $result = $this->curl('GET', '/computedlabel');
+
+        return $result['value'];
+    }
+
+    /**
+     * Get ARIA Role: /session/:sessionId/element/:elementId/computedrole
      *
      * @return mixed
      */
-    public function attribute($name)
+    public function getAriaRole()
     {
-        $result = $this->curl('GET', "/attribute/$name");
+        $result = $this->curl('GET', '/computedrole');
 
         return $result['value'];
     }
 
     /**
-     * Query the value of an element’s computed CSS property: /session/:sessionId/element/:id/css/:propertyName
+     * Get element shadow root: /session/:sessionId/element/:elementId/shadow
+     *
+     * shadow root method chaining, e.g.,
+     * - $element->shadow()->method()
+     * - $element->shadow->method()
+     *
+     * @return \WebDriver\Shadow|null
+     *
+     */
+    public function getShadowRoot()
+    {
+        $result = $this->curl('POST', '/shadow');
+        $value  = $result['value'];
+
+        if (! is_array($value)) {
+            $value = [$value];
+        }
+
+        if (array_key_exists(Shadow::SHADOW_ROOT_ID, $value)) {
+            $shadowRootReference = $value[Shadow::SHADOW_ROOT_ID];
+
+            return new Shadow($this->getSessionPath() . '/shadow', $shadowRootReference);
+        }
+
+        return null;
+    }
+
+    /**
+     * Is Element Enabled: /session/:sessionId/element/:elementId/enabled
      *
-     * @param string $propertyName
+     * @return mixed
+     */
+    public function isEnabled()
+    {
+        $result = $this->curl('GET', '/enabled');
+
+        return $result['value'];
+    }
+
+    /**
+     * Is Element Selected: /session/:sessionId/element/:elementId/selected
      *
      * @return mixed
      */
-    public function css($propertyName)
+    public function isSelected()
     {
-        $result = $this->curl('GET', "/css/$propertyName");
+        $result = $this->curl('GET', '/selected');
 
         return $result['value'];
     }
 
     /**
-     * Get element property: /session/:sessionId/element/:id/property/:name
+     * Send Keys to Element: /session/:sessionId/element/:elementId/value
      *
-     * @param string $name
+     * @param array|string $text Parameters {text: ...}
      *
      * @return mixed
      */
-    public function property($name)
+    public function sendKeys($text)
     {
-        $result = $this->curl('GET', "/property/$name");
+        $parameters = is_array($text)
+            ? $text
+            : ['text' => $text, 'value' => [$text]];
+
+        if (! array_key_exists('text', $parameters) && array_key_exists('value', $parameters)) {
+            // trigger_error(__METHOD__ . ': use "text" property instead of "value"', E_USER_DEPRECATED);
+
+            $parameters['text'] = implode($parameters['value']);
+        }
+
+        if (array_key_exists('text', $parameters) && ! array_key_exists('value', $parameters)) {
+            $parameters['value'] = [$parameters['text']];
+        }
+
+        $result = $this->curl('POST', '/value', $parameters);
 
         return $result['value'];
     }
 
     /**
-     * Get element shadow root: /session/:sessionId/element/:elementId/shadow
+     * Submit a FORM Element: /session/:sessionId/element/:elementId/submit
      *
-     * shadow root method chaining, e.g.,
-     * - $element->method()
-     *
-     * @return \WebDriver\Shadow|null
+     * @deprecated
      *
+     * @return mixed
      */
-    public function shadow()
+    public function submit()
     {
-        $result = $this->curl('POST', '/shadow');
-        $value  = $result['value'];
+        // trigger_error(__METHOD__, E_USER_DEPRECATED);
 
-        if (array_key_exists(Shadow::SHADOW_ROOT_ID, (array) $value)) {
-            $shadowRootReference = $value[Shadow::SHADOW_ROOT_ID];
+        try {
+            $result = $this->curl('POST', '/submit');
+
+            return $result['value'];
+        } catch (\Exception $e) {
+            $session = new Session($this->getSessionPath(), []);
 
-            return new Shadow(
-                preg_replace('/' . preg_quote('element/' . $this->id, '/') . '$/', '/', $this->url), // remove /element/:elementid
-                $shadowRootReference
+            return $session->execute()->sync(
+                ['script' => <<<JS
+var elem = arguments[0];
+elem.submit();
+JS
+                , 'args' => [$this]]
             );
         }
-
-        return null;
     }
 
     /**
      * {@inheritdoc}
      */
-    protected function getIdentifierPath($identifier)
+    protected function getNewIdentifierPath($identifier)
+    {
+        return preg_replace('~/' . preg_quote($this->id) . '$~', "/$identifier", $this->url);
+    }
+
+    /**
+     * Get session path
+     *
+     * @return string
+     */
+    private function getSessionPath()
     {
-        return preg_replace('/' . preg_quote($this->id) . '$/', $identifier, $this->url);
+        // remove /element/:elementid
+        return preg_replace('~/element/' . preg_quote($this->id) . '$~', '', $this->url);
     }
 }
diff --git a/lib/WebDriver/Exception.php b/lib/WebDriver/Exception.php
index edee0d3a..0de022f0 100644
--- a/lib/WebDriver/Exception.php
+++ b/lib/WebDriver/Exception.php
@@ -11,6 +11,8 @@
 
 namespace WebDriver;
 
+use WebDriver\Exception as E;
+
 /**
  * WebDriver\Exception class
  *
@@ -19,182 +21,175 @@
 abstract class Exception extends \Exception
 {
     /**
-     * Response status codes
+     * Error codes
      *
-     * @see https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/remote/ErrorCodes.java
+     * @see https://www.w3.org/TR/webdriver2/#errors
      */
-    const SUCCESS = 0;
-    const NO_SUCH_DRIVER = 6;
-    const NO_SUCH_ELEMENT = 7;
-    const NO_SUCH_FRAME = 8;
-    const UNKNOWN_COMMAND = 9;
-    const STALE_ELEMENT_REFERENCE = 10;
-    const INVALID_ELEMENT_STATE = 12;
-    const UNKNOWN_ERROR = 13;
-    const JAVASCRIPT_ERROR = 17;
-    const XPATH_LOOKUP_ERROR = 19;
-    const TIMEOUT = 21;
-    const NO_SUCH_WINDOW = 23;
-    const INVALID_COOKIE_DOMAIN = 24;
-    const UNABLE_TO_SET_COOKIE = 25;
-    const UNEXPECTED_ALERT_OPEN = 26;
-    const NO_ALERT_OPEN_ERROR = 27;
-    const SCRIPT_TIMEOUT = 28;
-    const INVALID_ELEMENT_COORDINATES = 29;
-    const IME_NOT_AVAILABLE = 30;
-    const IME_ENGINE_ACTIVATION_FAILED = 31;
-    const INVALID_SELECTOR = 32;
-    const SESSION_NOT_CREATED = 33;
-    const MOVE_TARGET_OUT_OF_BOUNDS = 34;
-    const INVALID_XPATH_SELECTOR = 51;
-    const INVALID_XPATH_SELECTOR_RETURN_TYPER = 52;
-    const ELEMENT_NOT_INTERACTABLE = 60;
-    const INVALID_ARGUMENT = 61;
-    const NO_SUCH_COOKIE = 62;
-    const UNABLE_TO_CAPTURE_SCREEN = 63;
-    const ELEMENT_CLICK_INTERCEPTED = 64;
-    const NO_SUCH_SHADOW_ROOT = 65;
-    const METHOD_NOT_ALLOWED = 405;
-
-    // obsolete
-    const INDEX_OUT_OF_BOUNDS = 1;
-    const NO_COLLECTION = 2;
-    const NO_STRING = 3;
-    const NO_STRING_LENGTH = 4;
-    const NO_STRING_WRAPPER = 5;
-    const OBSOLETE_ELEMENT = 10;
-    const ELEMENT_NOT_DISPLAYED = 11;
-    const ELEMENT_NOT_VISIBLE = 11;
-    const UNHANDLED = 13;
-    const EXPECTED = 14;
-    const ELEMENT_IS_NOT_SELECTABLE = 15;
-    const ELEMENT_NOT_SELECTABLE = 15;
-    const NO_SUCH_DOCUMENT = 16;
-    const UNEXPECTED_JAVASCRIPT = 17;
-    const NO_SCRIPT_RESULT = 18;
-    const NO_SUCH_COLLECTION = 20;
-    const NULL_POINTER = 22;
-    const NO_MODAL_DIALOG_OPEN_ERROR = 27;
-
-    // user-defined
-    const CURL_EXEC = -1;
-    const OBSOLETE_COMMAND = -2;
-    const NO_PARAMETERS_EXPECTED = -3;
-    const JSON_PARAMETERS_EXPECTED = -4;
-    const UNEXPECTED_PARAMETERS = -5;
-    const INVALID_REQUEST = -6;
-    const UNKNOWN_LOCATOR_STRATEGY = -7;
-    const W3C_WEBDRIVER_ERROR = -8;
-
-    private static $errs = array(
-//      self::SUCCESS => array('Success', 'This should never be thrown!'),
-
-        self::NO_SUCH_DRIVER => array('NoSuchDriver', 'A session is either terminated or not started'),
-        self::NO_SUCH_ELEMENT => array('NoSuchElement', 'An element could not be located on the page using the given search parameters.'),
-        self::NO_SUCH_FRAME => array('NoSuchFrame', 'A request to switch to a frame could not be satisfied because the frame could not be found.'),
-        self::UNKNOWN_COMMAND => array('UnknownCommand', 'The requested resource could not be found, or a request was received using an HTTP method that is not supported by the mapped resource.'),
-        self::STALE_ELEMENT_REFERENCE => array('StaleElementReference', 'An element command failed because the referenced element is no longer attached to the DOM.'),
-        self::ELEMENT_NOT_VISIBLE => array('ElementNotVisible', 'An element command could not be completed because the element is not visible on the page.'),
-        self::INVALID_ELEMENT_STATE => array('InvalidElementState', 'An element command could not be completed because the element is in an invalid state (e.g., attempting to click a disabled element).'),
-        self::UNKNOWN_ERROR => array('UnknownError', 'An unknown server-side error occurred while processing the command.'),
-        self::ELEMENT_IS_NOT_SELECTABLE => array('ElementIsNotSelectable', 'An attempt was made to select an element that cannot be selected.'),
-        self::JAVASCRIPT_ERROR => array('JavaScriptError', 'An error occurred while executing user supplied JavaScript.'),
-        self::XPATH_LOOKUP_ERROR => array('XPathLookupError', 'An error occurred while searching for an element by XPath.'),
-        self::TIMEOUT => array('Timeout', 'An operation did not complete before its timeout expired.'),
-        self::NO_SUCH_WINDOW => array('NoSuchWindow', 'A request to switch to a different window could not be satisfied because the window could not be found.'),
-        self::INVALID_COOKIE_DOMAIN => array('InvalidCookieDomain', 'An illegal attempt was made to set a cookie under a different domain than the current page.'),
-        self::UNABLE_TO_SET_COOKIE => array('UnableToSetCookie', 'A request to set a cookie\'s value could not be satisfied.'),
-        self::UNEXPECTED_ALERT_OPEN => array('UnexpectedAlertOpen', 'A modal dialog was open, blocking this operation'),
-        self::NO_ALERT_OPEN_ERROR => array('NoAlertOpenError', 'An attempt was made to operate on a modal dialog when one was not open.'),
-        self::SCRIPT_TIMEOUT => array('ScriptTimeout', 'A script did not complete before its timeout expired.'),
-        self::INVALID_ELEMENT_COORDINATES => array('InvalidElementCoordinates', 'The coordinates provided to an interactions operation are invalid.'),
-        self::IME_NOT_AVAILABLE => array('IMENotAvailable', 'IME was not available.'),
-        self::IME_ENGINE_ACTIVATION_FAILED => array('IMEEngineActivationFailed', 'An IME engine could not be started.'),
-        self::INVALID_SELECTOR => array('InvalidSelector', 'Argument was an invalid selector (e.g., XPath/CSS).'),
-        self::SESSION_NOT_CREATED => array('SessionNotCreated', 'A new session could not be created (e.g., a required capability could not be set).'),
-        self::MOVE_TARGET_OUT_OF_BOUNDS => array('MoveTargetOutOfBounds', 'Target provided for a move action is out of bounds.'),
+    const DETACHED_SHADOW_ROOT      = 'detached shadow roomt';
+    const ELEMENT_CLICK_INTERCEPTED = 'element click intercepted';
+    const ELEMENT_NOT_INTERACTABLE  = 'element not interactable';
+    const INSECURE_CERTIFICATE      = 'insecure certificate';
+    const INVALID_ARGUMENT          = 'invalid argument';
+    const INVALID_COOKIE_DOMAIN     = 'invalid cookie domain';
+    const INVALID_ELEMENT_STATE     = 'invalid element state';
+    const INVALID_SELECTOR          = 'invalid selector';
+    const INVALID_SESSION_ID        = 'invalid session id';
+    const JAVASCRIPT_ERROR          = 'javascript error';
+    const MOVE_TARGET_OUT_OF_BOUNDS = 'move target out of bounds';
+    const NO_SUCH_ALERT             = 'no such alert';
+    const NO_SUCH_COOKIE            = 'no such cookie';
+    const NO_SUCH_ELEMENT           = 'no such element';
+    const NO_SUCH_FRAME             = 'no such frame';
+    const NO_SUCH_SHADOW_ROOT       = 'no such shadow root';
+    const NO_SUCH_WINDOW            = 'no such window';
+    const SCRIPT_TIMEOUT            = 'script timeout';
+    const SESSION_NOT_CREATED       = 'session not created';
+    const STALE_ELEMENT_REFERENCE   = 'stale element reference';
+    const TIMEOUT                   = 'timeout';
+    const UNABLE_TO_CAPTURE_SCREEN  = 'unable to capture screen';
+    const UNABLE_TO_SET_COOKIE      = 'unable to set cookie';
+    const UNEXPECTED_ALERT_OPEN     = 'unexpected alert open';
+    const UNKNOWN_COMMAND           = 'unknown command';
+    const UNKNOWN_ERROR             = 'unknown error';
+    const UNKNOWN_METHOD            = 'unknown method';
+    const UNSUPPORTED_OPERATION     = 'unsupported operation';
+
+    // @internal php-webdriver
+    const _CURL_EXEC                = 'curl exec';
+    const _INVALID_REQUEST          = 'invalid request';
+    const _JSON_PARAMETERS_EXPECTED = 'json parameters expected';
+    const _NO_PARAMETERS_EXPECTED   = 'no parameters expected';
+    const _OBSOLETE_COMMAND         = 'obsolete command';
+    const _UNEXPECTED_PARAMETERS    = 'unexpected parameters';
+    const _UNKNOWN_LOCATOR_STRATEGY = 'unknown locator strategy';
 
-        self::CURL_EXEC => array('CurlExec', 'curl_exec() error.'),
-        self::OBSOLETE_COMMAND => array('ObsoleteCommand', 'This WebDriver command is obsolete.'),
-        self::NO_PARAMETERS_EXPECTED => array('NoParametersExpected', 'This HTTP request method expects no parameters.'),
-        self::JSON_PARAMETERS_EXPECTED => array('JsonParameterExpected', 'This POST request expects a JSON parameter (array).'),
-        self::UNEXPECTED_PARAMETERS => array('UnexpectedParameters', 'This command does not expect this number of parameters.'),
-        self::INVALID_REQUEST => array('InvalidRequest', 'This command does not support this HTTP request method.'),
-        self::UNKNOWN_LOCATOR_STRATEGY => array('UnknownLocatorStrategy', 'This locator strategy is not supported.'),
-        self::INVALID_XPATH_SELECTOR => array('InvalidSelector', 'Argument was an invalid selector.'),
-        self::INVALID_XPATH_SELECTOR_RETURN_TYPER => array('InvalidSelector', 'Argument was an invalid selector.'),
-        self::ELEMENT_NOT_INTERACTABLE => array('ElementNotInteractable', 'A command could not be completed because the element is not pointer- or keyboard interactable.'),
-        self::INVALID_ARGUMENT => array('InvalidArgument', 'The arguments passed to a command are either invalid or malformed.'),
-        self::NO_SUCH_COOKIE => array('NoSuchCookie', 'No cookie matching the given path name was found amongst the associated cookies of the current browsing context\'s active document.'),
-        self::UNABLE_TO_CAPTURE_SCREEN => array('UnableToCaptureScreen', 'A screen capture was made impossible.'),
-        self::ELEMENT_CLICK_INTERCEPTED => array('ElementClickIntercepted', 'The Element Click command could not be completed because the element receiving the events is obscuring the element that was requested clicked.'),
-        self::NO_SUCH_SHADOW_ROOT => array('NoSuchShadowRoot', 'The element does not have a shadow root.'),
-        self::METHOD_NOT_ALLOWED => array('UnsupportedOperation', 'Indicates that a command that should have executed properly cannot be supported for some reason.'),
-
-        // @ss https://w3c.github.io/webdriver/#errors
-        'element not interactable' => array('ElementNotInteractable', 'A command could not be completed because the element is not pointer- or keyboard interactable.'),
-        'element not selectable' => array('ElementIsNotSelectable', 'An attempt was made to select an element that cannot be selected.'),
-        'insecure certificate' => array('InsecureCertificate', 'Navigation caused the user agent to hit a certificate warning, which is usually the result of an expired or invalid TLS certificate.'),
-        'invalid argument' => array('InvalidArgument', 'The arguments passed to a command are either invalid or malformed.'),
-        'invalid cookie domain' => array('InvalidCookieDomain', 'An illegal attempt was made to set a cookie under a different domain than the current page.'),
-        'invalid coordinates' => array('InvalidCoordinates', 'The coordinates provided to an interactions operation are invalid.'),
-        'invalid element state' => array('InvalidElementState', 'A command could not be completed because the element is in an invalid state, e.g. attempting to clear an element that isn\'t both editable and resettable.'),
-        'invalid selector' => array('InvalidSelector', 'Argument was an invalid selector.'),
-        'invalid session id' => array('InvalidSessionID', 'Occurs if the given session id is not in the list of active sessions, meaning the session either does not exist or that it\'s not active.'),
-        'javascript error' => array('JavaScriptError', 'An error occurred while executing JavaScript supplied by the user.'),
-        'move target out of bounds' => array('MoveTargetOutOfBounds', 'The target for mouse interaction is not in the browser\'s viewport and cannot be brought into that viewport.'),
-        'no such alert' => array('NoSuchAlert', 'An attempt was made to operate on a modal dialog when one was not open.'),
-        'no such cookie' => array('NoSuchCookie', 'No cookie matching the given path name was found amongst the associated cookies of the current browsing context\'s active document.'),
-        'no such element' => array('NoSuchElement', 'An element could not be located on the page using the given search parameters.'),
-        'no such frame' => array('NoSuchFrame', 'A command to switch to a frame could not be satisfied because the frame could not be found.'),
-        'no such window' => array('NoSuchWindow', 'A command to switch to a window could not be satisfied because the window could not be found.'),
-        'script timeout' => array('ScriptTimeout', 'A script did not complete before its timeout expired.'),
-        'session not created' => array('SessionNotCreated', 'A new session could not be created.'),
-        'stale element reference' => array('StaleElementReference', 'A command failed because the referenced element is no longer attached to the DOM.'),
-        'timeout' => array('Timeout', 'An operation did not complete before its timeout expired.'),
-        'unable to capture screen' => array('UnableToCaptureScreen', 'A screen capture was made impossible.'),
-        'unable to set cookie' => array('UnableToSetCookie', 'A command to set a cookie\'s value could not be satisfied.'),
-        'unexpected alert open' => array('UnexpectedAlertOpen', 'A modal dialog was open, blocking this operation.'),
-        'unknown command' => array('UnknownCommand', 'A command could not be executed because the remote end is not aware of it.'),
-        'unknown error' => array('UnknownError', 'An unknown error occurred in the remote end while processing the command.'),
-        'unknown method' => array('UnknownMethod', 'The requested command matched a known URL but did not match an method for that URL.'),
-        'unsupported operation' => array('UnsupportedOperation', 'Indicates that a command that should have executed properly cannot be supported for some reason.'),
+    /**
+     * Mapping JSON Wire Protocol (integer) error codes to W3C WebDriver (string) status codes
+     *
+     * @deprecated
+     *
+     * @see https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/remote/ErrorCodes.java
+     *
+     * @var array
+     */
+    private static $mapping = [
+        1   => 'index out of bounds',
+        2   => 'no collection',
+        3   => 'no string',
+        4   => 'no string length',
+        5   => 'no string wrapper',
+        6   => self::SESSION_NOT_CREATED,       // 'no such driver'
+        7   => self::NO_SUCH_ELEMENT,
+        8   => self::NO_SUCH_FRAME,
+        9   => self::UNSUPPORTED_OPERATION,
+        10  => self::STALE_ELEMENT_REFERENCE,   // 'obsolete element'
+        11  => 'element not displayed',
+        12  => self::INVALID_ELEMENT_STATE,
+        13  => self::UNKNOWN_ERROR,
+        14  => 'expected',
+        15  => self::ELEMENT_NOT_INTERACTABLE,  // 'element is not selectable'
+        16  => 'no such document',
+        17  => self::JAVASCRIPT_ERROR,
+        18  => 'no script result',
+        19  => self::INVALID_SELECTOR,          // 'xpath lookup error'
+        20  => 'no such collection',
+        21  => self::TIMEOUT,
+        22  => 'null pointer',
+        23  => self::NO_SUCH_WINDOW,
+        24  => self::INVALID_COOKIE_DOMAIN,
+        25  => self::UNABLE_TO_SET_COOKIE,
+        26  => self::UNEXPECTED_ALERT_OPEN,
+        27  => self::NO_SUCH_ALERT,             // 'no modal dialog open error'
+        28  => self::SCRIPT_TIMEOUT,
+        29  => 'invalid element coordinates',
+        30  => 'ime not available',
+        31  => 'ime engine activation failed',
+        32  => self::INVALID_SELECTOR,
+        33  => self::SESSION_NOT_CREATED,
+        34  => self::MOVE_TARGET_OUT_OF_BOUNDS,
+        35  => 'sql database error',
+        51  => self::INVALID_SELECTOR,          // 'invalid xpath selector'
+        52  => self::INVALID_SELECTOR,          // 'invalid xpath selector return typer'
+        60  => self::ELEMENT_NOT_INTERACTABLE,
+        61  => self::INVALID_ARGUMENT,
+        62  => self::NO_SUCH_COOKIE,
+        63  => self::UNABLE_TO_CAPTURE_SCREEN,
+        64  => self::ELEMENT_CLICK_INTERCEPTED,
+        65  => self::NO_SUCH_SHADOW_ROOT,
+        405 => self::UNSUPPORTED_OPERATION,     // 'method not allowed'
+    ];
 
-        // obsolete
-        'detached shadow root' => array('DetachedShadowRoot', 'A command failed because the referenced shadow root is no longer attached to the DOM.'),
-        'element click intercepted' => array('ElementClickIntercepted', 'The Element Click command could not be completed because the element receiving the events is obscuring the element that was requested clicked.'),
-        'no such shadow root' => array('NoSuchShadowRoot', 'The element does not have a shadow root.'),
-        'script timeout error' => array('ScriptTimeout', 'A script did not complete before its timeout expired.'),
-    );
+    /**
+     * Error data dictionary
+     *
+     * @var array
+     */
+    private static $errs = [
+        self::DETACHED_SHADOW_ROOT => [E\DetachedShadowRoot::class, 'A command failed because the referenced shadow root is no longer attached to the DOM.'],
+        self::ELEMENT_CLICK_INTERCEPTED => [E\ElementClickIntercepted::class, 'The Element Click command could not be completed because the element receiving the events is obscuring the element that was requested clicked.'],
+        self::ELEMENT_NOT_INTERACTABLE => [E\ElementNotInteractable::class, 'A command could not be completed because the element is not pointer- or keyboard interactable.'],
+        self::INSECURE_CERTIFICATE => [E\InsecureCertificate::class, 'Navigation caused the user agent to hit a certificate warning, which is usually the result of an expired or invalid TLS certificate.'],
+        self::INVALID_ARGUMENT => [E\InvalidArgument::class, 'The arguments passed to a command are either invalid or malformed.'],
+        self::INVALID_COOKIE_DOMAIN => [E\InvalidCookieDomain::class, 'An illegal attempt was made to set a cookie under a different domain than the current page.'],
+        self::INVALID_ELEMENT_STATE => [E\InvalidElementState::class, 'A command could not be completed because the element is in an invalid state, e.g. attempting to clear an element that isn\'t both editable and resettable.'],
+        self::INVALID_SELECTOR => [E\InvalidSelector::class, 'Argument was an invalid selector.'],
+        self::INVALID_SESSION_ID => [E\InvalidSessionID::class, 'Occurs if the given session id is not in the list of active sessions, meaning the session either does not exist or that it\'s not active.'],
+        self::JAVASCRIPT_ERROR => [E\JavaScriptError::class, 'An error occurred while executing JavaScript supplied by the user.'],
+        self::MOVE_TARGET_OUT_OF_BOUNDS => [E\MoveTargetOutOfBounds::class, 'The target for mouse interaction is not in the browser\'s viewport and cannot be brought into that viewport.'],
+        self::NO_SUCH_ALERT => [E\NoSuchAlert::class, 'An attempt was made to operate on a modal dialog when one was not open.'],
+        self::NO_SUCH_COOKIE => [E\NoSuchCookie::class, 'No cookie matching the given path name was found amongst the associated cookies of the current browsing context\'s active document.'],
+        self::NO_SUCH_ELEMENT => [E\NoSuchElement::class, 'An element could not be located on the page using the given search parameters.'],
+        self::NO_SUCH_FRAME => [E\NoSuchFrame::class, 'A command to switch to a frame could not be satisfied because the frame could not be found.'],
+        self::NO_SUCH_SHADOW_ROOT => [E\NoSuchShadowRoot::class, 'The element does not have a shadow root.'],
+        self::NO_SUCH_WINDOW => [E\NoSuchWindow::class, 'A command to switch to a window could not be satisfied because the window could not be found.'],
+        self::SCRIPT_TIMEOUT => [E\ScriptTimeout::class, 'A script did not complete before its timeout expired.'],
+        self::SESSION_NOT_CREATED => [E\SessionNotCreated::class, 'A new session could not be created.'],
+        self::STALE_ELEMENT_REFERENCE => [E\StaleElementReference::class, 'A command failed because the referenced element is no longer attached to the DOM.'],
+        self::TIMEOUT => [E\Timeout::class, 'An operation did not complete before its timeout expired.'],
+        self::UNABLE_TO_CAPTURE_SCREEN => [E\UnableToCaptureScreen::class, 'A screen capture was made impossible.'],
+        self::UNABLE_TO_SET_COOKIE => [E\UnableToSetCookie::class, 'A command to set a cookie\'s value could not be satisfied.'],
+        self::UNEXPECTED_ALERT_OPEN => [E\UnexpectedAlertOpen::class, 'A modal dialog was open, blocking this operation.'],
+        self::UNKNOWN_COMMAND => [E\UnknownCommand::class, 'A command could not be executed because the remote end is not aware of it.'],
+        self::UNKNOWN_ERROR => [E\UnknownError::class, 'An unknown error occurred in the remote end while processing the command.'],
+        self::UNKNOWN_METHOD => [E\UnknownMethod::class, 'The requested command matched a known URL but did not match an method for that URL.'],
+        self::UNSUPPORTED_OPERATION => [E\UnsupportedOperation::class, 'Indicates that a command that should have executed properly cannot be supported for some reason.'],
+
+        // @internal php-webdriver (user-defined)
+        self::_CURL_EXEC => [E\CurlExec::class, 'curl_exec() error.'],
+        self::_INVALID_REQUEST => [E\InvalidRequest::class, 'This command does not support this HTTP request method.'],
+        self::_JSON_PARAMETERS_EXPECTED => [E\JsonParameterExpected::class, 'This POST request expects a JSON parameter (array).'],
+        self::_NO_PARAMETERS_EXPECTED => [E\NoParametersExpected::class, 'This HTTP request method expects no parameters.'],
+        self::_OBSOLETE_COMMAND => [E\ObsoleteCommand::class, 'This WebDriver command is obsolete.'],
+        self::_UNEXPECTED_PARAMETERS => [E\UnexpectedParameters::class, 'This command does not expect this number of parameters.'],
+        self::_UNKNOWN_LOCATOR_STRATEGY => [E\UnknownLocatorStrategy::class, 'This locator strategy is not supported.'],
+    ];
 
     /**
      * Factory method to create WebDriver\Exception objects
      *
-     * @param integer    $code              Code
-     * @param string     $message           Message
-     * @param \Exception $previousException Previous exception
+     * @param string|integer $code              Status (or Error) Code
+     * @param string         $message           Message
+     * @param \Exception     $previousException Previous exception
      *
      * @return \Exception
      */
     public static function factory($code, $message = null, $previousException = null)
     {
-        // unknown error
-        if (! isset(self::$errs[$code])) {
+        if (is_numeric($code) && array_key_exists($code, self::$mapping)) {
+            $code = self::$mapping[$code];
+        }
+
+        if (! array_key_exists($code, self::$errs)) {
             $code = self::UNKNOWN_ERROR;
         }
 
         $errorDefinition = self::$errs[$code];
+        $className = $errorDefinition[0];
 
         if ($message === null || trim($message) === '') {
             $message = $errorDefinition[1];
         }
 
-        if (! is_numeric($code)) {
-            $code = self::W3C_WEBDRIVER_ERROR;
-        }
-
-        $className = __CLASS__ . '\\' . $errorDefinition[0];
-
-        return new $className($message, $code, $previousException);
+        return new $className($message, 0, $previousException);
     }
 }
diff --git a/lib/WebDriver/Exception/CurlExec.php b/lib/WebDriver/Exception/CurlExec.php
index bd9a5301..af95319e 100644
--- a/lib/WebDriver/Exception/CurlExec.php
+++ b/lib/WebDriver/Exception/CurlExec.php
@@ -16,6 +16,8 @@
 /**
  * WebDriver\Exception\CurlExec class
  *
+ * @internal php-webdriver
+ *
  * @package WebDriver
  */
 final class CurlExec extends BaseException
@@ -23,7 +25,7 @@ final class CurlExec extends BaseException
     /**
      * @var array
      */
-    private $curlInfo = array();
+    private $curlInfo = [];
 
     /**
      * Get curl info
diff --git a/lib/WebDriver/Exception/ElementIsNotSelectable.php b/lib/WebDriver/Exception/ElementIsNotSelectable.php
deleted file mode 100644
index cadd219d..00000000
--- a/lib/WebDriver/Exception/ElementIsNotSelectable.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/**
- * @copyright 2013 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Exception;
-
-use WebDriver\Exception as BaseException;
-
-/**
- * WebDriver\Exception\ElementIsNotSelectable class
- *
- * @package WebDriver
- */
-final class ElementIsNotSelectable extends BaseException
-{
-}
diff --git a/lib/WebDriver/Exception/ElementNotVisible.php b/lib/WebDriver/Exception/ElementNotVisible.php
deleted file mode 100644
index ca3e64d5..00000000
--- a/lib/WebDriver/Exception/ElementNotVisible.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/**
- * @copyright 2013 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Exception;
-
-use WebDriver\Exception as BaseException;
-
-/**
- * WebDriver\Exception\ElementNotVisible class
- *
- * @package WebDriver
- */
-final class ElementNotVisible extends BaseException
-{
-}
diff --git a/lib/WebDriver/Exception/IMEEngineActivationFailed.php b/lib/WebDriver/Exception/IMEEngineActivationFailed.php
deleted file mode 100644
index 24f9d7ee..00000000
--- a/lib/WebDriver/Exception/IMEEngineActivationFailed.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/**
- * @copyright 2013 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Exception;
-
-use WebDriver\Exception as BaseException;
-
-/**
- * WebDriver\Exception\IMEEngineActivationFailed class
- *
- * @package WebDriver
- */
-final class IMEEngineActivationFailed extends BaseException
-{
-}
diff --git a/lib/WebDriver/Exception/IMENotAvailable.php b/lib/WebDriver/Exception/IMENotAvailable.php
deleted file mode 100644
index 4b249b63..00000000
--- a/lib/WebDriver/Exception/IMENotAvailable.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/**
- * @copyright 2013 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Exception;
-
-use WebDriver\Exception as BaseException;
-
-/**
- * WebDriver\Exception\IMENotAvailable class
- *
- * @package WebDriver
- */
-final class IMENotAvailable extends BaseException
-{
-}
diff --git a/lib/WebDriver/Exception/InvalidCoordinates.php b/lib/WebDriver/Exception/InvalidCoordinates.php
deleted file mode 100644
index a3ae5b2e..00000000
--- a/lib/WebDriver/Exception/InvalidCoordinates.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/**
- * @copyright 2022 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Exception;
-
-use WebDriver\Exception as BaseException;
-
-/**
- * WebDriver\Exception\InvalidCoordinates class
- *
- * @package WebDriver
- */
-final class InvalidCoordinates extends BaseException
-{
-}
diff --git a/lib/WebDriver/Exception/InvalidElementCoordinates.php b/lib/WebDriver/Exception/InvalidElementCoordinates.php
deleted file mode 100644
index 2748098a..00000000
--- a/lib/WebDriver/Exception/InvalidElementCoordinates.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/**
- * @copyright 2013 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Exception;
-
-use WebDriver\Exception as BaseException;
-
-/**
- * WebDriver\Exception\InvalidElementCoordinates class
- *
- * @package WebDriver
- */
-final class InvalidElementCoordinates extends BaseException
-{
-}
diff --git a/lib/WebDriver/Exception/InvalidRequest.php b/lib/WebDriver/Exception/InvalidRequest.php
index 63793613..673abbe2 100644
--- a/lib/WebDriver/Exception/InvalidRequest.php
+++ b/lib/WebDriver/Exception/InvalidRequest.php
@@ -16,6 +16,8 @@
 /**
  * WebDriver\Exception\InvalidRequest class
  *
+ * @internal php-webdriver
+ *
  * @package WebDriver
  */
 final class InvalidRequest extends BaseException
diff --git a/lib/WebDriver/Exception/JsonParameterExpected.php b/lib/WebDriver/Exception/JsonParameterExpected.php
index 9bb8a0c5..9d4001b5 100644
--- a/lib/WebDriver/Exception/JsonParameterExpected.php
+++ b/lib/WebDriver/Exception/JsonParameterExpected.php
@@ -16,6 +16,8 @@
 /**
  * WebDriver\Exception\JsonParameterExpected class
  *
+ * @internal php-webdriver
+ *
  * @package WebDriver
  */
 final class JsonParameterExpected extends BaseException
diff --git a/lib/WebDriver/Exception/NoAlertOpenError.php b/lib/WebDriver/Exception/NoAlertOpenError.php
deleted file mode 100644
index 9a2d8a66..00000000
--- a/lib/WebDriver/Exception/NoAlertOpenError.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/**
- * @copyright 2013 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Exception;
-
-use WebDriver\Exception as BaseException;
-
-/**
- * WebDriver\Exception\NoAlertOpenError class
- *
- * @package WebDriver
- */
-final class NoAlertOpenError extends BaseException
-{
-}
diff --git a/lib/WebDriver/Exception/NoParametersExpected.php b/lib/WebDriver/Exception/NoParametersExpected.php
index 6fb50da3..69016e1c 100644
--- a/lib/WebDriver/Exception/NoParametersExpected.php
+++ b/lib/WebDriver/Exception/NoParametersExpected.php
@@ -16,6 +16,8 @@
 /**
  * WebDriver\Exception\NoParametersExpected class
  *
+ * @internal php-webdriver
+ *
  * @package WebDriver
  */
 final class NoParametersExpected extends BaseException
diff --git a/lib/WebDriver/Exception/NoSuchDriver.php b/lib/WebDriver/Exception/NoSuchDriver.php
deleted file mode 100644
index 8b8a5ff9..00000000
--- a/lib/WebDriver/Exception/NoSuchDriver.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/**
- * @copyright 2013 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Exception;
-
-use WebDriver\Exception as BaseException;
-
-/**
- * WebDriver\Exception\NoSuchDriver class
- *
- * @package WebDriver
- */
-final class NoSuchDriver extends BaseException
-{
-}
diff --git a/lib/WebDriver/Exception/ObsoleteCommand.php b/lib/WebDriver/Exception/ObsoleteCommand.php
index b125f74f..1e0be8f4 100644
--- a/lib/WebDriver/Exception/ObsoleteCommand.php
+++ b/lib/WebDriver/Exception/ObsoleteCommand.php
@@ -16,6 +16,8 @@
 /**
  * WebDriver\Exception\ObsoleteCommand class
  *
+ * @internal php-webdriver
+ *
  * @package WebDriver
  */
 final class ObsoleteCommand extends BaseException
diff --git a/lib/WebDriver/Exception/UnexpectedParameters.php b/lib/WebDriver/Exception/UnexpectedParameters.php
index 6d3f4c65..56faad2b 100644
--- a/lib/WebDriver/Exception/UnexpectedParameters.php
+++ b/lib/WebDriver/Exception/UnexpectedParameters.php
@@ -16,6 +16,8 @@
 /**
  * WebDriver\Exception\UnexpectedParameters class
  *
+ * @internal php-webdriver
+ *
  * @package WebDriver
  */
 final class UnexpectedParameters extends BaseException
diff --git a/lib/WebDriver/Exception/UnknownLocatorStrategy.php b/lib/WebDriver/Exception/UnknownLocatorStrategy.php
index ddff5ba9..75a770ca 100644
--- a/lib/WebDriver/Exception/UnknownLocatorStrategy.php
+++ b/lib/WebDriver/Exception/UnknownLocatorStrategy.php
@@ -16,6 +16,8 @@
 /**
  * WebDriver\Exception\UnknownLocatorStrategy class
  *
+ * @internal php-webdriver
+ *
  * @package WebDriver
  */
 final class UnknownLocatorStrategy extends BaseException
diff --git a/lib/WebDriver/Exception/XPathLookupError.php b/lib/WebDriver/Exception/XPathLookupError.php
deleted file mode 100644
index bf6b2c2e..00000000
--- a/lib/WebDriver/Exception/XPathLookupError.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/**
- * @copyright 2013 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Exception;
-
-use WebDriver\Exception as BaseException;
-
-/**
- * WebDriver\Exception\XPathLookupError class
- *
- * @package WebDriver
- */
-final class XPathLookupError extends BaseException
-{
-}
diff --git a/lib/WebDriver/Execute.php b/lib/WebDriver/Execute.php
index 1ade8bae..ccd1bbe8 100644
--- a/lib/WebDriver/Execute.php
+++ b/lib/WebDriver/Execute.php
@@ -18,26 +18,23 @@
  */
 class Execute extends AbstractWebDriver
 {
-    /**
-     * {@inheritdoc}
-     */
-    protected function methods()
-    {
-        return array();
-    }
-
     /**
      * Inject a snippet of JavaScript into the page for execution in the context of the currently selected frame. (asynchronous)
      *
-     * @param array{script: string, args: array} $jsonScript
+     * @param string|array $script
+     * @param array|null   $args
      *
      * @return mixed
      */
-    public function async(array $jsonScript)
+    public function async($script, $args = null)
     {
-        $jsonScript['args'] = $this->serializeArguments($jsonScript['args']);
+        $parameters = func_num_args() === 1 && is_array($script)
+            ? $script
+            : ['script' => $script, 'args' => $args];
+
+        $parameters['args'] = $this->serializeArguments($parameters['args']);
 
-        $result = $this->curl('POST', '/async', $jsonScript);
+        $result = $this->curl('POST', '/execute/async', $parameters);
 
         return $this->unserializeResult($result['value']);
     }
@@ -45,15 +42,20 @@ public function async(array $jsonScript)
     /**
      * Inject a snippet of JavaScript into the page for execution in the context of the currently selected frame. (synchronous)
      *
-     * @param array{script: string, args: array} $jsonScript
+     * @param string|array $script
+     * @param array|null   $args
      *
      * @return mixed
      */
-    public function sync(array $jsonScript)
+    public function sync($script, $args = null)
     {
-        $jsonScript['args'] = $this->serializeArguments($jsonScript['args']);
+        $parameters = func_num_args() === 1 && is_array($script)
+            ? $script
+            : ['script' => $script, 'args' => $args];
 
-        $result = $this->curl('POST', '/sync', $jsonScript);
+        $parameters['args'] = $this->serializeArguments($parameters['args']);
+
+        $result = $this->curl('POST', '/execute/sync', $parameters);
 
         return $this->unserializeResult($result['value']);
     }
@@ -70,9 +72,7 @@ public function sync(array $jsonScript)
     protected function serializeArguments(array $arguments)
     {
         foreach ($arguments as $key => $value) {
-            if ($value instanceof LegacyElement) {
-                $arguments[$key] = [LegacyElement::LEGACY_ELEMENT_ID => $value->getID()];
-            } elseif ($value instanceof Element) {
+            if ($value instanceof Element) {
                 $arguments[$key] = [Element::WEB_ELEMENT_ID => $value->getID()];
             } elseif ($value instanceof Shadow) {
                 $arguments[$key] = [Shadow::SHADOW_ROOT_ID => $value->getID()];
@@ -117,41 +117,18 @@ protected function unserializeResult($result)
      */
     protected function makeElement($value)
     {
-        if (array_key_exists(LegacyElement::LEGACY_ELEMENT_ID, $value)) {
-            $identifier = $value[LegacyElement::LEGACY_ELEMENT_ID];
-
-            return new LegacyElement(
-                $this->getIdentifierPath('/element/' . $identifier),
-                $identifier
-            );
-        }
-
         if (array_key_exists(Element::WEB_ELEMENT_ID, $value)) {
             $identifier = $value[Element::WEB_ELEMENT_ID];
 
-            return new Element(
-                $this->getIdentifierPath('/element/' . $identifier),
-                $identifier
-            );
+            return new Element($this->url . '/element', $identifier);
         }
 
         if (array_key_exists(Shadow::SHADOW_ROOT_ID, $value)) {
             $identifier = $value[Shadow::SHADOW_ROOT_ID];
 
-            return new Shadow(
-                $this->getIdentifierPath('/shadow/' . $identifier),
-                $identifier
-            );
+            return new Shadow($this->url . '/shadow', $identifier);
         }
 
         return null;
     }
-
-    /**
-     * {@inheritdoc}
-     */
-    protected function getIdentifierPath($identifier)
-    {
-        return preg_replace('~/execute$~', '', $this->url) . $identifier; // remove /execute from path
-    }
 }
diff --git a/lib/WebDriver/Extension/ChromeDevTools.php b/lib/WebDriver/Extension/ChromeDevTools.php
new file mode 100644
index 00000000..9d8b523b
--- /dev/null
+++ b/lib/WebDriver/Extension/ChromeDevTools.php
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver\Extension;
+
+use WebDriver\AbstractWebDriver;
+
+/**
+ * Chrome Dev Tools extension
+ *
+ * @package WebDriver
+ */
+class ChromeDevTools extends AbstractWebDriver
+{
+    /**
+     * Execute Command: /session/:sessionId/goog/cdp/execute (POST)
+     *
+     * @param array|string $cmd    Command or Parameters {'cmd': ..., 'params': ...}
+     * @param mixed        $params Optional paramaters
+     *
+     * @return mixed
+     */
+    public function execute($cmd, $params = null)
+    {
+        $parameters = is_array($cmd)
+            ? $cmd
+            : ['cmd' => $cmd, 'params' => $params ?? new \stdclass];
+
+        $result = $this->curl('POST', '/execute', $parameters);
+
+        return $result['value'];
+    }
+}
diff --git a/lib/WebDriver/Extension/FederatedCredentialManagementAPI.php b/lib/WebDriver/Extension/FederatedCredentialManagementAPI.php
new file mode 100644
index 00000000..eb78f741
--- /dev/null
+++ b/lib/WebDriver/Extension/FederatedCredentialManagementAPI.php
@@ -0,0 +1,158 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver\Extension;
+
+use WebDriver\AbstractWebDriver;
+
+/**
+ * Federated Credential Management API extensions
+ *
+ * @see https://fedidcg.github.io/FedCM/#automation
+ *
+ * @package WebDriver
+ *
+ * @method array canceldialog() Cancel dialog.
+ * @method array clickdialogbutton() Click dialog button.
+ * @method array selectaccount() Select account.
+ * @method array accountlist() Get accounts.
+ * @method array gettitle() Get FedCM title.
+ * @method array getdialogtype() Get FedCM dialog type.
+ * @method array setdelayenabled() Set delay enabled.
+ */
+class FederatedCredentialManagementAPI extends AbstractWebDriver
+{
+    /**
+     * {@inheritdoc}
+     */
+    protected function methods()
+    {
+        return [
+            'canceldialog'      => ['POST'],
+            'clickdialogbutton' => ['POST'],
+            'selectaccount'     => ['POST'],
+            'accountlist'       => ['GET'],
+            'gettitle'          => ['GET'],
+            'getdialogtype'     => ['GET'],
+            'setdelayenabled'   => ['POST'],
+        ];
+    }
+
+    /**
+     * Cancel Dialog: /session/:sessionId/fedcm/canceldialog (POST)
+     *
+     * @return mixed
+     */
+    public function cancelDialog()
+    {
+        $result = $this->curl('POST', '/canceldialog');
+
+        return $result['value'];
+    }
+
+    /**
+     * Select Account: /session/:sessionId/fedcm/selectaccount (POST)
+     *
+     * @param mixed $parameters Parameters {accountIndex: ...}
+     *
+     * @return mixed
+     */
+    public function selectAccount($parameters)
+    {
+        if (is_integer($parameters)) {
+            $parameters = ['accountIndex' => $parameters];
+        }
+
+        $result = $this->curl('POST', '/selectaccount', $parameters);
+
+        return $result['value'];
+    }
+
+    /**
+     * Click Dialog Button: /session/:sessionId/fedcm/clickdialogbutton (POST)
+     *
+     * @param array $parameters Parameters {dialogButton: ...}
+     *
+     * @return mixed
+     */
+    public function clickDialogButton($parameters)
+    {
+        $result = $this->curl('POST', '/clickdialogbutton', $parameters);
+
+        return $result['value'];
+    }
+
+    /**
+     * Get Accounts: /session/:sessionId/fedcm/accountlist (GET)
+     *
+     * @return mixed
+     */
+    public function getAccounts()
+    {
+        $result = $this->curl('GET', '/accountlist');
+
+        return $result['value'];
+    }
+
+    /**
+     * Get FedCM Title: /session/:sessionId/fedcm/gettitle (GET)
+     *
+     * @return mixed
+     */
+    public function getTitle()
+    {
+        $result = $this->curl('GET', '/gettitle');
+
+        return $result['value'];
+    }
+
+    /**
+     * Get FedCM Dialog Type: /session/:sessionId/fedcm/getdialogtype (GET)
+     *
+     * @return mixed
+     */
+    public function getDialogType()
+    {
+        $result = $this->curl('GET', '/getdialogtype');
+
+        return $result['value'];
+    }
+
+    /**
+     * Set Delay Enabled: /session/:sessionId/fedcm/setdelayenabled (POST)
+     *
+     * @param mixed $parameters Parameters {enabled: ...}
+     *
+     * @return mixed
+     */
+    public function setDelayEnabled($parameters)
+    {
+        if (is_bool($parameters)) {
+            $parameters = ['enabled' => $parameters];
+        }
+
+        $result = $this->curl('POST', '/setdelayenabled', $parameters);
+
+        return $result['value'];
+    }
+
+    /**
+     * Reset Cooldown: /session/:sessionId/fedcm/resetCooldown (POST)
+     *
+     * @return mixed
+     */
+    public function resetCooldown()
+    {
+        $result = $this->curl('POST', '/resetCooldown');
+
+        return $result['value'];
+    }
+}
diff --git a/lib/WebDriver/Extension/Selenium.php b/lib/WebDriver/Extension/Selenium.php
new file mode 100644
index 00000000..b4da88cd
--- /dev/null
+++ b/lib/WebDriver/Extension/Selenium.php
@@ -0,0 +1,126 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver\Extension;
+
+use WebDriver\AbstractWebDriver;
+
+/**
+ * Selenium extensions
+ *
+ * {@internal
+ *     /se/files Downloads requires se:downloadsEnabled
+ * }}
+ *
+ * @package WebDriver
+ */
+class Selenium extends AbstractWebDriver
+{
+    /**
+     * Get log: /session/:sessionId/se/log
+     *
+     * @param mixed $parameters
+     *
+     * @return mixed
+     */
+    public function getLog($parameters)
+    {
+        if (is_string($parameters)) {
+            $parameters = [
+                'type' => $parameters,
+            ];
+        }
+
+        $result = $this->curl('POST', '/log', $parameters);
+
+        return $result['value'];
+    }
+
+    /**
+     * Get available log types: /session/:sessionId/se/log/types
+     *
+     * @return mixed
+     */
+    public function getAvailableLogTypes()
+    {
+        $result = $this->curl('GET', '/log/types');
+
+        return $result['value'];
+    }
+
+    /**
+     * Download File: /session/:sessionId/se/files (POST)
+     *
+     * @param array|string $parameters Parameters {'name': ...}
+     *
+     * @eturn mixed
+     */
+    public function downloadFile($parameters)
+    {
+        if (is_string($parameters)) {
+            $parameters = ['name' => $parameters];
+        }
+
+        $result = $this->curl('POST', '/se/files', $parameters);
+
+        return $result['value'];
+    }
+
+    /**
+     * Get Downloadable Files: /session/:sessionId/se/files (GET)
+     *
+     * @eturn mixed
+     */
+    public function getDownloadableFiles()
+    {
+        $result = $this->curl('GET', '/se/files');
+
+        return $result['value'];
+    }
+
+    /**
+     * Delete Downloadable Files: /session/:sessionId/se/files (DELETE)
+     *
+     * @eturn mixed
+     */
+    public function deleteDownloadableFiles()
+    {
+        $result = $this->curl('DELETE', '/se/files');
+
+        return $result['value'];
+    }
+
+    /**
+     * Upload File: /session/:sessionId/se/file (POST)
+     *
+     * @param array|string $parameters Parameters {file: ...}
+     *
+     * @eturn mixed
+     */
+    public function uploadFile($parameters)
+    {
+        if (is_string($parameters)) {
+            $parameters = ['file' => $parameters];
+        }
+
+        // expecting ZIP file format
+        if (substr($parameters['file'], 0, 4) === "PK\x03\x04") {
+            $parameters['file'] = base64_encode($parameters['file']);
+        } elseif (substr($parameters['file'], 0, 5) !== 'UEsDB') {
+            // suspicious but looks intentional
+            trigger_error('UPLOAD_FILE expected base64 encoded ZIP file', E_USER_NOTICE);
+        }
+
+        $result = $this->curl('POST', '/se/file', $parameters);
+
+        return $result['value'];
+    }
+}
diff --git a/lib/WebDriver/LogType.php b/lib/WebDriver/Extension/Selenium/LogType.php
similarity index 88%
rename from lib/WebDriver/LogType.php
rename to lib/WebDriver/Extension/Selenium/LogType.php
index 939a325a..52a3b697 100644
--- a/lib/WebDriver/LogType.php
+++ b/lib/WebDriver/Extension/Selenium/LogType.php
@@ -9,7 +9,7 @@
  * @author Anthon Pang <apang@softwaredevelopment.ca>
  */
 
-namespace WebDriver;
+namespace WebDriver\Extension\Selenium;
 
 /**
  * WebDriver\LogType class
@@ -27,6 +27,6 @@ final class LogType
     const CLIENT      = 'client';
     const DRIVER      = 'driver';
     const PERFORMANCE = 'performance';
-    const PROFILER    = 'driver';
+    const PROFILER    = 'profiler';
     const SERVER      = 'server';
 }
diff --git a/lib/WebDriver/Extension/WebAuthenticationAPI.php b/lib/WebDriver/Extension/WebAuthenticationAPI.php
new file mode 100644
index 00000000..9bf7b7e6
--- /dev/null
+++ b/lib/WebDriver/Extension/WebAuthenticationAPI.php
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver\Extension;
+
+use WebDriver\AbstractWebDriver;
+use WebDriver\Extension\WebAuthenticationAPI\VirtualAuthenticator;
+
+/**
+ * Web Authentication API
+ *
+ * @see https://www.w3.org/TR/webauthn-2/#sctn-automation
+ *
+ * @package WebDriver
+ */
+class WebAuthenticationAPI extends AbstractWebDriver
+{
+    /**
+     * Add virtual authenticator: /session/:sessionId/webauthn/authenticator (POST)
+     *
+     * @param mixed $parameters Authenticator Configuration {protocol: ... transport: ..., hasResidentKey: ..., hasUserVerification: ..., isUserConsenting: ..., isUserVerified: ..., extensions: ..., uvm: ...}
+     *
+     * @return mixed
+     */
+    public function addVirtualAuthenticator($parameters)
+    {
+        $result = $this->curl('POST', '', $parameters);
+
+        $authenticatorId = $result['value'];
+
+        return new VirtualAuthenticator($this->url, $authenticatorId);
+    }
+}
diff --git a/lib/WebDriver/Extension/WebAuthenticationAPI/Credential.php b/lib/WebDriver/Extension/WebAuthenticationAPI/Credential.php
new file mode 100644
index 00000000..8de37ac1
--- /dev/null
+++ b/lib/WebDriver/Extension/WebAuthenticationAPI/Credential.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver\Extension\WebAuthenticationAPI;
+
+use WebDriver\AbstractWebDriver;
+
+/**
+ * Virtual Authenticator Credential
+ *
+ * @package WebDriver
+ */
+class Credential extends AbstractWebDriver
+{
+    /**
+     * Constructor
+     *
+     * @param string $url URL
+     * @param string $id  Credential ID
+     */
+    public function __construct($url, $id)
+    {
+        parent::__construct($url . "/$id");
+    }
+
+    /**
+     * Remove Credential: /session/:sessionId/webauthn/authenticator/:authenticatorId/credentials/:credentialId (DELETE)
+     *
+     * @return mixed
+     */
+    public function removeCredential()
+    {
+        $result = $this->curl('DELETE', '');
+
+        return $result['value'];
+    }
+}
diff --git a/lib/WebDriver/Extension/WebAuthenticationAPI/Credentials.php b/lib/WebDriver/Extension/WebAuthenticationAPI/Credentials.php
new file mode 100644
index 00000000..b31f5815
--- /dev/null
+++ b/lib/WebDriver/Extension/WebAuthenticationAPI/Credentials.php
@@ -0,0 +1,58 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver\Extension\WebAuthenticationAPI;
+
+use WebDriver\AbstractWebDriver;
+
+/**
+ * Virtual Authenticator Credentials
+ *
+ * @package WebDriver
+ *
+ * @method array credentials() Get credentials.
+ */
+class Credentials extends AbstractWebDriver
+{
+    /**
+     * {@inheritdoc}
+     */
+    protected function methods()
+    {
+        return [
+            'credentials' => ['GET'],
+        ];
+    }
+
+    /**
+     * Get Credentials: /session/:sessionId/webauthn/authenticator/:authenticatorId/credentials (GET)
+     *
+     * @return mixed
+     */
+    public function getCredentials()
+    {
+        $result = $this->curl('GET', '');
+
+        return $result['value'];
+    }
+
+    /**
+     * Remove All Credentials: /session/:sessionId/webauthn/authenticator/:authenticatorId/credentials (DELETE)
+     *
+     * @return mixed
+     */
+    public function removeCredentials()
+    {
+        $result = $this->curl('DELETE', '');
+
+        return $result['value'];
+    }
+}
diff --git a/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php b/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php
new file mode 100644
index 00000000..74225b6f
--- /dev/null
+++ b/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php
@@ -0,0 +1,115 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver\Extension\WebAuthenticationAPI;
+
+use WebDriver\AbstractWebDriver;
+
+/**
+ * Virtual Authenticator
+ *
+ * @package WebDriver
+ *
+ * @method array credential($parameters) Add credential.
+ * @method array uv($parameters) Set user verified.
+ */
+class VirtualAuthenticator extends AbstractWebDriver
+{
+    /**
+     * {@inheritdoc}
+     */
+    protected function methods()
+    {
+        return [
+            'credential' => ['POST'],
+            'uv'         => ['POST'],
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function chainable()
+    {
+        return [
+            'credentials' => 'credentials',
+        ];
+    }
+
+    /**
+     * Constructor
+     *
+     * @param string $url URL
+     * @param string $id  Authenticator ID
+     */
+    public function __construct($url, $id)
+    {
+        parent::__construct($url . "/$id");
+    }
+
+    /**
+     * Remove Virtual Authenticator: /session/:sessionId/webauthn/authenticator/:authenticatorId (DELETE)
+     *
+     * @return mixed
+     */
+    public function removeVirtualAuthenticator()
+    {
+        $result = $this->curl('DELETE', '');
+
+        return $result['value'];
+    }
+
+    /**
+     * Add credential: /session/:sessionId/webauthn/authenticator/:authenticatorId/credential (POST)
+     *
+     * @param array $parameters Credential Parameters {credentialId: ..., isResidentCredential: ..., rpId: ..., privateKey: ..., userHandle: ..., signCount: ..., largeBlob: ...}
+     *
+     * @return \WebDriver\Extension\WebAuthenticationAPI\Credential
+     */
+    public function addCredential($parameters)
+    {
+        $credentialId = $parameters['credentialId'];
+
+        $this->curl('POST', '/credential', $parameters);
+
+        return new Credential($this->url . '/credentials', $credentialId);
+    }
+
+    /**
+     * Set user verified: /session/:sessionId/webauthn/authenticator/:authenticatorId/uv (POST)
+     *
+     * @param array|boolean $parameters Parameters {isUserVerified: ...}
+     *
+     * @return mixed
+     */
+    public function setUserVerified($parameters)
+    {
+        if (is_bool($parameters)) {
+            $parameters = ['isUserVerified' => $parameters];
+        }
+
+        $result = $this->curl('POST', '/uv', $parameters);
+
+        return $result['value'];
+    }
+
+    /**
+     * Get Credentials object for chaining
+     * - $authenticator->credentials()->method()
+     * - $authenticator->credentials->method()
+     *
+     * @return \WebDriver\Extension\WebAuthenticationAPI\Credentials
+     */
+    protected function credentials()
+    {
+        return new Credentials($this->url . '/credentials');
+    }
+}
diff --git a/lib/WebDriver/Frame.php b/lib/WebDriver/Frame.php
index 37ff9164..53afc2ee 100644
--- a/lib/WebDriver/Frame.php
+++ b/lib/WebDriver/Frame.php
@@ -27,8 +27,8 @@ class Frame extends AbstractWebDriver
      */
     protected function methods()
     {
-        return array(
-            'parent' => array('POST'),
-        );
+        return [
+            'parent' => ['POST'],
+        ];
     }
 }
diff --git a/lib/WebDriver/Ime.php b/lib/WebDriver/Ime.php
deleted file mode 100644
index 8c419c2a..00000000
--- a/lib/WebDriver/Ime.php
+++ /dev/null
@@ -1,40 +0,0 @@
-<?php
-
-/**
- * @copyright 2011 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver;
-
-/**
- * WebDriver\Ime class
- *
- * @package WebDriver
- *
- * @method void activate($json) Make an engine that is available active.
- * @method boolean activated() Indicates whether IME input is active at the moment.
- * @method string active_engine() Get the name of the active IME engine.
- * @method array available_engines() List all available engines on the machines.
- * @method void deactivate() De-activates the currently active IME engine.
- */
-class Ime extends AbstractWebDriver
-{
-    /**
-     * {@inheritdoc}
-     */
-    protected function methods()
-    {
-        return array(
-            'activate' => array('POST'),
-            'activated' => array('GET'),
-            'active_engine' => array('GET'),
-            'available_engines' => array('GET'),
-            'deactivate' => array('POST'),
-        );
-    }
-}
diff --git a/lib/WebDriver/KeyInput.php b/lib/WebDriver/KeyInput.php
new file mode 100644
index 00000000..543577f7
--- /dev/null
+++ b/lib/WebDriver/KeyInput.php
@@ -0,0 +1,74 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver;
+
+/**
+ * WebDriver\KeyInput class
+ *
+ * @package WebDriver
+ */
+class KeyInput extends NullInput
+{
+    const TYPE = 'key';
+
+    // actions
+    const KEY_DOWN = 'keyDown';
+    const KEY_UP   = 'keyUp';
+
+    /**
+     * Key Down
+     *
+     * {@internal action item properties:
+     *     value: string - mandatory; contains a single unicode code point
+     * }}
+     *
+     * @param array $action Action item
+     *
+     * @return array
+     */
+    public function keyDown($action)
+    {
+        $action['type'] = self::KEY_DOWN;
+
+        return [
+            'id'      => $this->id,
+            'type'    => static::TYPE,
+            'actions' => [
+                $action,
+            ],
+        ];
+    }
+
+    /**
+     * Key Up
+     *
+     * {@internal action item properties:
+     *     value: string - mandatory; contains a single unicode code point
+     * }}
+     *
+     * @param array $action Action item
+     *
+     * @return array
+     */
+    public function keyUp($action)
+    {
+        $action['type'] = self::KEY_UP;
+
+        return [
+            'id'      => $this->id,
+            'type'    => static::TYPE,
+            'actions' => [
+                $action,
+            ],
+        ];
+    }
+}
diff --git a/lib/WebDriver/LegacyElement.php b/lib/WebDriver/LegacyElement.php
deleted file mode 100644
index e3b2e871..00000000
--- a/lib/WebDriver/LegacyElement.php
+++ /dev/null
@@ -1,40 +0,0 @@
-<?php
-
-/**
- * @copyright 2022 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver;
-
-/**
- * WebDriver\LegacyElement class
- *
- * @package WebDriver
- *
- * @method string attribute($attributeName) Get the value of an element's attribute.
- * @method void clear() Clear a TEXTAREA or text INPUT element's value.
- * @method void click() Click on an element.
- * @method string css($propertyName) Query the value of an element's computed CSS property.
- * @method boolean displayed() Determine if an element is currently displayed.
- * @method boolean enabled() Determine if an element is currently enabled.
- * @method boolean equals($otherId) Test if two element IDs refer to the same DOM element.
- * @method array location() Determine an element's location on the page.
- * @method array location_in_view() Determine an element's location on the screen once it has been scrolled into view.
- * @method string name() Query for an element's tag name.
- * @method array property($propertyName) Get element property.
- * @method array rect() Get element rect.
- * @method array screenshot() Take element screenshot.
- * @method array size() Determine an element's size in pixels.
- * @method void submit() Submit a FORM element.
- * @method string text() Returns the visible text for the element.
- * @method void postValue($json) Send a sequence of key strokes to an element.
- */
-class LegacyElement extends Element
-{
-    const LEGACY_ELEMENT_ID = 'ELEMENT';
-}
diff --git a/lib/WebDriver/LegacyExecute.php b/lib/WebDriver/LegacyExecute.php
deleted file mode 100644
index 295dde4a..00000000
--- a/lib/WebDriver/LegacyExecute.php
+++ /dev/null
@@ -1,68 +0,0 @@
-<?php
-
-/**
- * @copyright 2022 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver;
-
-/**
- * WebDriver\LegacyExecute class
- *
- * @package WebDriver
- */
-class LegacyExecute extends Execute
-{
-    /**
-     * {@inheritdoc}
-     */
-    protected function methods()
-    {
-        return array();
-    }
-
-    /**
-     * Inject a snippet of JavaScript into the page for execution in the context of the currently selected frame. (asynchronous)
-     *
-     * @param array{script: string, args: array} $jsonScript
-     *
-     * @return mixed
-     */
-    public function async(array $jsonScript)
-    {
-        $jsonScript['args'] = $this->serializeArguments($jsonScript['args']);
-
-        $result = $this->curl('POST', '/execute_async', $jsonScript);
-
-        return $this->unserializeResult($result['value']);
-    }
-
-    /**
-     * Inject a snippet of JavaScript into the page for execution in the context of the currently selected frame. (synchronous)
-     *
-     * @param array{script: string, args: array} $jsonScript
-     *
-     * @return mixed
-     */
-    public function sync(array $jsonScript)
-    {
-        $jsonScript['args'] = $this->serializeArguments($jsonScript['args']);
-
-        $result = $this->curl('POST', '/execute', $jsonScript);
-
-        return $this->unserializeResult($result['value']);
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    protected function getIdentifierPath($identifier)
-    {
-        return $this->url . $identifier;
-    }
-}
diff --git a/lib/WebDriver/LegacyWindow.php b/lib/WebDriver/LegacyWindow.php
deleted file mode 100644
index 97010a2c..00000000
--- a/lib/WebDriver/LegacyWindow.php
+++ /dev/null
@@ -1,85 +0,0 @@
-<?php
-
-/**
- * @copyright 2011 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver;
-
-/**
- * WebDriver\LegacyWindow class
- *
- * @package WebDriver
- *
- * @method void maximize() Maximize the window if not already maximized.
- * @method array getPosition() Get position of the window.
- * @method void postPosition($json) Change position of the window.
- * @method array getSize() Get size of the window.
- * @method void postSize($json) Change the size of the window.
- */
-class LegacyWindow extends AbstractWebDriver
-{
-    /**
-     * @var string
-     */
-    private $windowHandle;
-
-    /**
-     * {@inheritdoc}
-     */
-    protected function methods()
-    {
-        return array(
-            // Legacy JSON Wire Protocol
-            'maximize' => array('POST'),
-            'position' => array('GET', 'POST'),
-            'size' => array('GET', 'POST'),
-        );
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    protected function obsoleteMethods()
-    {
-        return array(
-            'restore' => array('POST'),
-        );
-    }
-
-    /**
-     * Constructor
-     *
-     * @param string      $url
-     * @param string|null $windowHandle
-     */
-    public function __construct($url, $windowHandle = null)
-    {
-        parent::__construct($url);
-
-        $this->windowHandle = $windowHandle;
-    }
-
-    /**
-     * Get window handle: /session/:sessionId/window_handle (GET)
-     * - $session->window($handle)->getHandle()
-     * - $session->window()->getHandle()
-     *
-     * @return string
-     */
-    public function getHandle()
-    {
-        if (! $this->windowHandle) {
-            $result = $this->curl('GET', '_handle');
-
-            $this->windowHandle = $result['value'];
-        }
-
-        return $this->windowHandle;
-    }
-}
diff --git a/lib/WebDriver/LocatorStrategy.php b/lib/WebDriver/LocatorStrategy.php
index b0e6923e..9bb9e0ab 100644
--- a/lib/WebDriver/LocatorStrategy.php
+++ b/lib/WebDriver/LocatorStrategy.php
@@ -18,12 +18,14 @@
  */
 final class LocatorStrategy
 {
-    const CLASS_NAME        = 'class name';
     const CSS_SELECTOR      = 'css selector';
-    const ID                = 'id';
-    const NAME              = 'name';
     const LINK_TEXT         = 'link text';
     const PARTIAL_LINK_TEXT = 'partial link text';
     const TAG_NAME          = 'tag name';
     const XPATH             = 'xpath';
+
+    // deprecated
+    const CLASS_NAME        = 'class name';
+    const ID                = 'id';
+    const NAME              = 'name';
 }
diff --git a/lib/WebDriver/Log.php b/lib/WebDriver/Log.php
deleted file mode 100644
index 4a491737..00000000
--- a/lib/WebDriver/Log.php
+++ /dev/null
@@ -1,32 +0,0 @@
-<?php
-
-/**
- * @copyright 2014 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver;
-
-/**
- * WebDriver\Log class
- *
- * @package WebDriver
- *
- * @method array types() Get available log types.
- */
-class Log extends AbstractWebDriver
-{
-    /**
-     * {@inheritdoc}
-     */
-    protected function methods()
-    {
-        return array(
-            'types' => array('GET'),
-        );
-    }
-}
diff --git a/lib/WebDriver/NullInput.php b/lib/WebDriver/NullInput.php
new file mode 100644
index 00000000..9cdefb07
--- /dev/null
+++ b/lib/WebDriver/NullInput.php
@@ -0,0 +1,62 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver;
+
+/**
+ * WebDriver\NullInput class
+ *
+ * @package WebDriver
+ */
+class NullInput
+{
+    const TYPE = 'none';
+
+    // actions
+    const PAUSE = 'pause';
+
+    /**
+     * @var integer
+     */
+    protected $id;
+
+    /**
+     * @param integer $id
+     */
+    public function __construct($id)
+    {
+        $this->id = $id;
+    }
+
+    /**
+     * Creates a pause of the given duration
+     *
+     * {@internal action item properties:
+     *    duration: int
+     * }}
+     *
+     * @param array $action Action item
+     *
+     * @return array
+     */
+    public function pause($action)
+    {
+        $action['type'] = self::PAUSE;
+
+        return [
+            'id'      => $this->id,
+            'type'    => static::TYPE,
+            'actions' => [
+                $action,
+            ],
+        ];
+    }
+}
diff --git a/lib/WebDriver/Platform.php b/lib/WebDriver/Platform.php
new file mode 100644
index 00000000..65ef9682
--- /dev/null
+++ b/lib/WebDriver/Platform.php
@@ -0,0 +1,29 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver;
+
+/**
+ * WebDriver\Platform class
+ *
+ * @internal non-exhaustive list
+ *
+ * @package WebDriver
+ */
+final class Platform
+{
+    const ANDROID = 'android';
+    const IOS     = 'ios';
+    const LINUX   = 'linux';
+    const MAC     = 'mac';
+    const WINDOWS = 'windows';
+    const UNIX    = 'unix';
+}
diff --git a/lib/WebDriver/PointerInput.php b/lib/WebDriver/PointerInput.php
new file mode 100644
index 00000000..e7549bf0
--- /dev/null
+++ b/lib/WebDriver/PointerInput.php
@@ -0,0 +1,192 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver;
+
+/**
+ * WebDriver\PointerInput class
+ *
+ * @package WebDriver
+ */
+class PointerInput extends NullInput
+{
+    const TYPE = 'pointer';
+
+    // sub-types
+    const MOUSE = 'mouse';
+    const PEN   = 'pen';
+    const TOUCH = 'touch';
+
+    // actions
+    const POINTER_DOWN   = 'pointerDown';
+    const POINTER_UP     = 'pointerUp';
+    const POINTER_MOVE   = 'pointerMove';
+    const POINTER_CANCEL = 'pointerCancel';
+
+    // buttons
+    const LEFT_BUTTON    = 0;
+    const MIDDLE_BUTTON  = 1;
+    const RIGHT_BUTTON   = 2;
+    const X1_BUTTON      = 3;
+    const BACK_BUTTON    = 3;
+    const X2_BUTTON      = 4;
+    const FORWARD_BUTTON = 4;
+
+    /**
+     * @var string
+     */
+    private $subType;
+
+    /**
+     * @param integer $id
+     * @param string  $subType
+     */
+    public function __construct($id, $subType)
+    {
+        parent::__construct($id);
+
+        $this->subType = $subType;
+    }
+
+    /**
+     * Pointer Down
+     *
+     * {@internal action item properties:
+     *   button: int (0..) - mandatory
+     *   height: number (0..)
+     *   width: number (0..)
+     *   pressure: float (0..1)
+     *   tangentialPressure: float (-1..1)
+     *   tiltX: int (-90..90)
+     *   tiltY: int (-90..90)
+     *   twist: int (0..359)
+     *   altitudeAngle: float (0..pi/2)
+     *   azimuthAngle: float (0..2pi)
+     * }}
+     *
+     * @param array $action Action item
+     *
+     * @return array
+     */
+    public function pointerDown($action)
+    {
+        $action['type'] = self::POINTER_DOWN;
+
+        return [
+            'id'      => $this->id,
+            'type'    => static::TYPE,
+            'actions' => [
+                $action,
+            ],
+            'parameters' => [
+                'pointerType' => $this->subType,
+            ],
+        ];
+    }
+
+    /**
+     * Pointer Up
+     *
+     * {@internal action item properties:
+     *   button: int (0..) - mandatory
+     *   height: number (0..)
+     *   width: number (0..)
+     *   pressure: float (0..1)
+     *   tangentialPressure: float (-1..1)
+     *   tiltX: int (-90..90)
+     *   tiltY: int (-90..90)
+     *   twist: int (0..359)
+     *   altitudeAngle: float (0..pi/2)
+     *   azimuthAngle: float (0..2pi)
+     * }}
+     *
+     * @param array $action Action item
+     *
+     * @return array
+     */
+    public function pointerUp($action)
+    {
+        $action['type'] = self::POINTER_UP;
+
+        return [
+            'id'      => $this->id,
+            'type'    => static::TYPE,
+            'actions' => [
+                $action
+            ],
+            'parameters' => [
+                'pointerType' => $this->subType,
+            ],
+        ];
+    }
+
+    /**
+     * Pointer Move
+     *
+     * {@internal action item properties:
+     *   x: int (mandatory)
+     *   y: int (mandatory)
+     *   height: number (0..)
+     *   width: number (0..)
+     *   pressure: float (0..1)
+     *   tangentialPressure: float (-1..1)
+     *   tiltX: int (-90..90)
+     *   tiltY: int (-90..90)
+     *   twist: int (0..359)
+     *   altitudeAngle: float (0..pi/2)
+     *   azimuthAngle: float (0..2pi)
+     *   duration: int (0..)
+     *   origin: string
+     * }}
+     *
+     * @param array $action Action item
+     *
+     * @return array
+     */
+    public function pointerMove($action)
+    {
+        $action['type'] = self::POINTER_MOVE;
+
+        return [
+            'id'      => $this->id,
+            'type'    => static::TYPE,
+            'actions' => [
+                $action,
+            ],
+            'parameters' => [
+                'pointerType' => $this->subType,
+            ],
+        ];
+    }
+
+    /**
+     * Pointer Cancel
+     *
+     * @param array $action Action item
+     *
+     * @return array
+     */
+    public function pointerCancel($action)
+    {
+        $action['type'] = self::POINTER_CANCEL;
+
+        return [
+            'id'      => $this->id,
+            'type'    => static::TYPE,
+            'actions' => [
+                $action,
+            ],
+            'parameters' => [
+                'pointerType' => $this->subType,
+            ],
+        ];
+    }
+}
diff --git a/lib/WebDriver/Proxy.php b/lib/WebDriver/Proxy.php
new file mode 100644
index 00000000..95a73c0e
--- /dev/null
+++ b/lib/WebDriver/Proxy.php
@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver;
+
+/**
+ * WebDriver\Proxy class
+ *
+ * @package WebDriver
+ */
+final class Proxy
+{
+    /**
+     * Proxy configuration
+     *
+     * @see https://w3c.github.io/webdriver/webdriver-spec.html#proxy
+     */
+    const PROXY_TYPE           = 'proxyType';
+    const PROXY_AUTOCONFIG_URL = 'proxyAutoconfigUrl';
+    const FTP_PROXY            = 'ftpProxy';
+    const HTTP_PROXY           = 'httpProxy';
+    const NO_PROXY             = 'noProxy';
+    const SSL_PROXY            = 'sslProxy';
+    const SOCKS_PROXY          = 'socksProxy';
+    const SOCKS_VERSION        = 'socksVersion';
+}
diff --git a/lib/WebDriver/ProxyType.php b/lib/WebDriver/ProxyType.php
new file mode 100644
index 00000000..25369116
--- /dev/null
+++ b/lib/WebDriver/ProxyType.php
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver;
+
+/**
+ * WebDriver\ProxyType class
+ *
+ * @package WebDriver
+ */
+final class ProxyType
+{
+    /**
+     * Proxy types
+     *
+     * @see https://w3c.github.io/webdriver/webdriver-spec.html#proxy
+     */
+    const AUTODETECT = 'autodetect';
+    const DIRECT     = 'direct';
+    const MANUAL     = 'manual';
+    const PAC        = 'pac';
+    const SYSTEM     = 'system';
+}
diff --git a/lib/WebDriver/SauceLabs/Capability.php b/lib/WebDriver/SauceLabs/Capability.php
deleted file mode 100644
index cd487914..00000000
--- a/lib/WebDriver/SauceLabs/Capability.php
+++ /dev/null
@@ -1,113 +0,0 @@
-<?php
-
-/**
- * @copyright 2012 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\SauceLabs;
-
-/**
- * WebDriver\SauceLabs\Capability class
- *
- * @package WebDriver
- */
-final class Capability
-{
-    /**
-     * Desired capabilities - SauceLabs
-     *
-     * @see https://docs.saucelabs.com/dev/test-configuration-options/
-     */
-
-    // Desktop Browser Capabilities - optional
-    const CHROMEDDRIVER_VERSION                    = 'chromedriverVersion';                  // Use a specific Chrome Driver version
-    const EDGEDRIVER_VERSION                       = 'edgedriverVersion';                    // Use a specific Edge Driver version
-    const IEDRIVER_VERSION                         = 'iedriverVersion';                      // Use a specific Internet Explorer version
-    const GECKODRIVER_VERSION                      = 'geckodriverVersion';                   // Use a specific Gecko Driver version
-    const SELENIUM_VERSION                         = 'seleniumVersion';                      // Use a specific Selenium version
-
-    const AVOID_PROXY                              = 'avoidProxy';                           // Avoid proxy
-    const CAPTURE_PERFORMANCE                      = 'capturePerformance';                   // Capture performance
-    const EXTENDED_DEBUGGING                       = 'extendedDebugging';                    // Extended debugging
-    const SCREEN_RESOLUTION                        = 'screenResolution';                     // Use specific screen resolution
-
-    // Mobile App Appium Capabilities - required
-    const APP                                      = 'app';                                  // Path to app you want to test
-    const DEVICE_NAME                              = 'deviceName';                           // Name of simulator, emulator, or real device to use
-    const PLATFORM_VERSION                         = 'platformVersion';                      // Mobile OS platform version
-    const AUTOMATION_NAME                          = 'automationName';                       // Engine: Appium, UiAutomator2, or Selendroid
-    const APP_PACKAGE                              = 'appPackage';                           // Name of Java package to run
-    const APP_ACTIVITY                             = 'appActivity';                          // Name of Android activity to launch
-    const AUTO_ACCEPT_ALERTS                       = 'autoAcceptAlerts';                     // Auto accept alerts (for iOS only)
-
-    // Mobile App Appium Capabilities - optional
-    const APPIUM_VERSION                           = 'appiumVersion';                        // Appium driver version you want to use
-    const DEVICE_ORIENTATION                       = 'deviceOrientation';                    // Device orientation (portrait or landscape)
-    const ORIENTATION                              = 'orientation';                          // (alias)
-    const DEVICE_TYPE                              = 'deviceType';                           // Device type (phone or tablet)
-    const OTHER_APPS                               = 'otherApps';                            // Dependent apps
-    const TABLET_ONLY                              = 'tabletOnly';                           // Select only tablet devices for testing
-    const PHONE_ONLY                               = 'phoneOnly';                            // Select only phone devices
-    const PRIVATE_DEVICES_ONLY                     = 'privateDevicesOnly';                   // Request allocation of private devices
-    const PUBLIC_DEVICES_ONLY                      = 'publicDevicesOnly';                    // Request allocation of public devices
-    const CARRIER_CONNECTIVITY_ONLY                = 'carrierConnectivityOnly';              // Allocate only devices connected to a carrier network
-    const CACHE_ID                                 = 'cacheId';                              // Keeps a device allocated to you between test sessions
-    const SESSION_CREATION_TIMEOUT                 = 'sessionCreationTimeout';               // Number of times the test should attempt to launch a session
-    const NEW_COMMAND_TIMEOUT                      = 'newCommandTimeout';                    // Amount of time (in seconds) that the test should allow to launch a test before failing
-    const NO_RESET                                 = 'noReset';                              // Keep a device allocated to you during the device cleaning proces
-    const CROSSWALK_APPLICATION                    = 'crosswalkApplication';                 // Patched version of ChromeDriver that will work with Crosswalk
-    const AUTO_GRANT_PERMISSIONS                   = 'autoGrantPermissions';                 // To disable auto grant permissions
-    const ENABLE_ANIMATIONS                        = 'enableAnimations';                     // Enable animations
-    const RESIGNING_ENABLED                        = 'resigningEnabled';                     // To allow testing of apps without resigning
-    const SAUCE_LABS_IMAGE_INJECTION_ENABLED       = 'sauceLabsImageInjectionEnabled';       // Enables the camera image injection feature
-    const SAUCE_LABS_BYPASS_SCREENSHOT_RESTRICTION = 'sauceLabsBypassScreenshotRestriction'; // Bypasses the restriction on taking screenshots for secure screen
-    const ALLOW_TOUCH_ID_ENROLL                    = 'allowTouchIdEnroll';                   // Enables the interception of biometric input
-    const GROUP_FOLDER_REDIRECT_ENABLED            = 'groupFolderRedirectEnabled';           // Enables the use of the app's private app container directory
-    const SYSTEM_ALERTS_DELAY_ENABLED              = 'systemAlertsDelayEnabled';             // Delays system alerts,
-
-    // Desktop and Mobile Capabilities - optional
-
-    // Job Annotation
-    const NAME                                     = 'name';                                 // Name the job
-    const BUILD                                    = 'build';                                // Record the build number
-    const TAGS                                     = 'tags';                                 // Tag your jobs
-    const PASSED                                   = 'passed';                               // Record pass/fail status
-    const ACCESS_KEY                               = 'accessKey';                            // Access key
-
-    // Job Sharing
-    const PUBLIC_RESULTS                           = 'public';                               // Make public, private, or share jobs
-
-    // Performance improvements and data collection
-    const CUSTOM_DATA                              = 'custom-data';                          // Record custom data
-    const CAPTURE_HTML                             = 'captureHtml';                          // HTML source capture
-    const QUIET_EXCEPTIONS                         = 'webdriverRemoteQuietExceptions';       // Enable Selenium 2's automatic screenshots
-
-    const TUNNEL_IDENTIFIER                        = 'tunnelIdentifier';                     // Use identified tunnel
-    const PARENT_TUNNEL                            = 'parentTunnel';                         // Shared tunnels
-    const RECORD_LOGS                              = 'recordLogs';                           // Log recording
-    const RECORD_SCREENSHOTS                       = 'recordScreenshots';                    // Record step-by-step screenshots
-    const RECORD_VIDEO                             = 'recordVideo';                          // Video recording
-    const VIDEO_UPLOAD_ON_PASS                     = 'videoUploadOnPass';                    // Video upload on pass
-
-    // Timeouts
-    const MAX_DURATION                             = 'maxDuration';                          // Set maximum test duration
-    const COMMAND_TIMEOUT                          = 'commandTimeout';                       // Set command timeout
-    const IDLE_TIMEOUT                             = 'idleTimeout';                          // Set idle test timeout
-
-    // Virtual Device Capabilities
-    const PRIORITY                                 = 'priority';                             // Prioritize jobs
-    const TIME_ZONE                                = 'timeZone';                             // Time zone
-    const PRERUN                                   = 'prerun';                               // Prerun executables (primary key)
-    const EXECUTABLE                               = 'executable';                           // Executable (secondary key)
-    const ARGS                                     = 'args';                                 // Args (secondary key)
-    const BACKGROUND                               = 'background';                           // Background (secondary key)
-    const TIMEOUT                                  = 'timeout';                              // Timeout (secondary key)
-
-    // obsolete
-    const VERSION                                  = 'version';                              // Browser version
-}
diff --git a/lib/WebDriver/SauceLabs/SauceRest.php b/lib/WebDriver/SauceLabs/SauceRest.php
deleted file mode 100644
index da40ead2..00000000
--- a/lib/WebDriver/SauceLabs/SauceRest.php
+++ /dev/null
@@ -1,356 +0,0 @@
-<?php
-
-/**
- * @copyright 2012 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- * @author Fabrizio Branca <mail@fabrizio-branca.de>
- */
-
-namespace WebDriver\SauceLabs;
-
-use WebDriver\ServiceFactory;
-
-/**
- * WebDriver\SauceLabs\SauceRest class
- *
- * @package WebDriver
- */
-class SauceRest
-{
-    /**
-     * @var string
-     */
-    private $userId;
-
-    /**
-     * @var string
-     */
-    private $accessKey;
-
-    /**
-     * Curl service
-     *
-     * @var \WebDriver\Service\CurlService|null
-     */
-    private $curlService;
-
-    /**
-     * Transient options
-     *
-     * @var array
-     */
-    private $transientOptions;
-
-    /**
-     * Constructor
-     *
-     * @param string $userId    Your Sauce user name
-     * @param string $accessKey Your Sauce API key
-     */
-    public function __construct($userId, $accessKey)
-    {
-        $this->userId      = $userId;
-        $this->accessKey   = $accessKey;
-        $this->curlService = null;
-    }
-
-    /**
-     * Set curl service
-     *
-     * @param \WebDriver\Service\CurlService $curlService
-     */
-    public function setCurlService($curlService)
-    {
-        $this->curlService = $curlService;
-    }
-
-    /**
-     * Get curl service
-     *
-     * @return \WebDriver\Service\CurlService
-     */
-    public function getCurlService()
-    {
-        return $this->curlService ?: ServiceFactory::getInstance()->getService('service.curl');
-    }
-
-    /**
-     * Set transient options
-     *
-     * @param mixed $transientOptions
-     */
-    public function setTransientOptions($transientOptions)
-    {
-        $this->transientOptions = is_array($transientOptions) ? $transientOptions : array();
-    }
-
-    /**
-     * Execute Sauce Labs REST API command
-     *
-     * @param string $requestMethod HTTP request method
-     * @param string $url           URL
-     * @param mixed  $parameters    Parameters
-     * @param array  $extraOptions  key=>value pairs of curl options to pass to curl_setopt()
-     *
-     * @return mixed
-     *
-     * @throws \WebDriver\Exception\CurlExec
-     *
-     * @see https://docs.saucelabs.com/secure-connections/sauce-connect/system-requirements/#rest-api-endpoints
-     */
-    protected function execute($requestMethod, $url, $parameters = null, $extraOptions = array())
-    {
-        $extraOptions = array(
-            CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
-            CURLOPT_USERPWD => $this->userId . ':' . $this->accessKey,
-
-            // don't verify SSL certificates
-            CURLOPT_SSL_VERIFYPEER => false,
-            CURLOPT_SSL_VERIFYHOST => false,
-
-            CURLOPT_HTTPHEADER => array('Expect:'),
-            CURLOPT_FAILONERROR => true,
-        );
-
-        $url = 'https://saucelabs.com/rest/v1/' . $url;
-
-        list($rawResult, $info) = $this->curlService->execute(
-            $requestMethod,
-            $url,
-            $parameters,
-            array_replace($extraOptions, $this->transientOptions)
-        );
-
-        $this->transientOptions = array();
-
-        return json_decode($rawResult, true);
-    }
-
-    /**
-     * Get account details: /rest/v1/users/:userId (GET)
-     *
-     * @param string $userId
-     *
-     * @return array
-     */
-    public function getAccountDetails($userId)
-    {
-        return $this->execute('GET', 'users/' . $userId);
-    }
-
-    /**
-     * Check account limits: /rest/v1/limits (GET)
-     *
-     * @return array
-     */
-    public function getAccountLimits()
-    {
-        return $this->execute('GET', 'limits');
-    }
-
-    /**
-     * Create new sub-account: /rest/v1/users/:userId (POST)
-     *
-     * For "partners", $accountInfo also contains 'plan' => (one of 'free', 'small', 'team', 'com', or 'complus')
-     *
-     * @param array $accountInfo array('username' => ..., 'password' => ..., 'name' => ..., 'email' => ...)
-     *
-     * @return array array('access_key' => ..., 'minutes' => ..., 'id' => ...)
-     */
-    public function createSubAccount($accountInfo)
-    {
-        return $this->execute('POST', 'users/' . $this->userId, $accountInfo);
-    }
-
-    /**
-     * Update sub-account service plan: /rest/v1/users/:userId/subscription (POST)
-     *
-     * @param string $userId User ID
-     * @param string $plan   Plan
-     *
-     * @return array
-     */
-    public function updateSubAccount($userId, $plan)
-    {
-        return $this->execute('POST', 'users/' . $userId . '/subscription', array('plan' => $plan));
-    }
-
-    /**
-     * Unsubscribe a sub-account: /rest/v1/users/:userId/subscription (DELETE)
-     *
-     * @param string $userId User ID
-     *
-     * @return array
-     */
-    public function unsubscribeSubAccount($userId)
-    {
-        return $this->execute('DELETE', 'users/' . $userId . '/subscription');
-    }
-
-    /**
-     * Get current account activity: /rest/v1/:userId/activity (GET)
-     *
-     * @return array
-     */
-    public function getActivity()
-    {
-        return $this->execute('GET', $this->userId . '/activity');
-    }
-
-    /**
-     * Get historical account usage: /rest/v1/:userId/usage (GET)
-     *
-     * @param string $start Optional start date YYYY-MM-DD
-     * @param string $end   Optional end date YYYY-MM-DD
-     *
-     * @return array
-     */
-    public function getUsage($start = null, $end = null)
-    {
-        $query = http_build_query(array(
-            'start' => $start,
-            'end' => $end,
-        ));
-
-        return $this->execute('GET', $this->userId . '/usage' . (strlen($query) ? '?' . $query : ''));
-    }
-
-    /**
-     * Get jobs: /rest/v1/:userId/jobs (GET)
-     *
-     * @param boolean $full
-     *
-     * @return array
-     */
-    public function getJobs($full = null)
-    {
-        $query = http_build_query(array(
-            'full' => (isset($full) && $full) ? 'true' : null,
-        ));
-
-        return $this->execute('GET', $this->userId . '/jobs' . (strlen($query) ? '?' . $query : ''));
-    }
-
-    /**
-     * Get full information for job: /rest/v1/:userId/jobs/:jobId (GET)
-     *
-     * @param string $jobId
-     *
-     * @return array
-     */
-    public function getJob($jobId)
-    {
-        return $this->execute('GET', $this->userId . '/jobs/' . $jobId);
-    }
-
-    /**
-     * Update existing job: /rest/v1/:userId/jobs/:jobId (PUT)
-     *
-     * @param string $jobId   Job ID
-     * @param array  $jobInfo Job information
-     *
-     * @return array
-     */
-    public function updateJob($jobId, $jobInfo)
-    {
-        return $this->execute('PUT', $this->userId . '/jobs/' . $jobId, $jobInfo);
-    }
-
-    /**
-     * Stop job: /rest/v1/:userId/jobs/:jobId/stop (PUT)
-     *
-     * @param string $jobId
-     *
-     * @return array
-     */
-    public function stopJob($jobId)
-    {
-        return $this->execute('PUT', $this->userId . '/jobs/' . $jobId . '/stop');
-    }
-
-    /**
-     * Delete job: /rest/v1/:userId/jobs/:jobId (DELETE)
-     *
-     * @param string $jobId
-     *
-     * @return array
-     */
-    public function deleteJob($jobId)
-    {
-        return $this->execute('DELETE', $this->userId . '/jobs/' . $jobId);
-    }
-
-    /**
-     * Get running tunnels for a given user: /rest/v1/:userId/tunnels (GET)
-     *
-     * @return array
-     */
-    public function getTunnels()
-    {
-        return $this->execute('GET', $this->userId . '/tunnels');
-    }
-
-    /**
-     * Get full information for a tunnel: /rest/v1/:userId/tunnels/:tunnelId (GET)
-     *
-     * @param string $tunnelId
-     *
-     * @return array
-     */
-    public function getTunnel($tunnelId)
-    {
-        return $this->execute('GET', $this->userId . '/tunnels/' . $tunnelId);
-    }
-
-    /**
-     * Shut down a tunnel: /rest/v1/:userId/tunnels/:tunnelId (DELETE)
-     *
-     * @param string $tunnelId
-     *
-     * @return array
-     */
-    public function shutdownTunnel($tunnelId)
-    {
-        return $this->execute('DELETE', $this->userId . '/tunnels/' . $tunnelId);
-    }
-
-    /**
-     * Get current status of Sauce Labs' services: /rest/v1/info/status (GET)
-     *
-     * @return array array('wait_time' => ..., 'service_operational' => ..., 'status_message' => ...)
-     */
-    public function getStatus()
-    {
-        return $this->execute('GET', 'info/status');
-    }
-
-    /**
-     * Get currently supported browsers: /rest/v1/info/browsers (GET)
-     *
-     * @param string $termination Optional termination (one of "all", "selenium-rc", or "webdriver')
-     *
-     * @return array
-     */
-    public function getBrowsers($termination = '')
-    {
-        if ($termination) {
-            return $this->execute('GET', 'info/browsers/' . $termination);
-        }
-
-        return $this->execute('GET', 'info/browsers');
-    }
-
-    /**
-     * Get number of tests executed so far on Sauce Labs: /rest/v1/info/counter (GET)
-     *
-     * @return array
-     */
-    public function getCounter()
-    {
-        return $this->execute('GET', 'info/counter');
-    }
-}
diff --git a/lib/WebDriver/Service/CurlService.php b/lib/WebDriver/Service/CurlService.php
index eac0a5f5..1adeeedc 100755
--- a/lib/WebDriver/Service/CurlService.php
+++ b/lib/WebDriver/Service/CurlService.php
@@ -30,20 +30,20 @@ class CurlService implements CurlServiceInterface
      *
      * @param mixed $defaultOptions
      */
-    public function __construct($defaultOptions = array())
+    public function __construct($defaultOptions = [])
     {
-        $this->defaultOptions = is_array($defaultOptions) ? $defaultOptions : array();
+        $this->defaultOptions = is_array($defaultOptions) ? $defaultOptions : [];
     }
 
     /**
      * {@inheritdoc}
      */
-    public function execute($requestMethod, $url, $parameters = null, $extraOptions = array())
+    public function execute($requestMethod, $url, $parameters = null, $extraOptions = [])
     {
-        $customHeaders = array(
+        $customHeaders = [
             'Content-Type: application/json;charset=utf-8',
             'Accept: application/json',
-        );
+        ];
 
         $curl = curl_init($url);
         curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
@@ -113,6 +113,6 @@ public function execute($requestMethod, $url, $parameters = null, $extraOptions
 
         curl_close($curl);
 
-        return array($rawResult, $info);
+        return [$rawResult, $info];
     }
 }
diff --git a/lib/WebDriver/Service/CurlServiceInterface.php b/lib/WebDriver/Service/CurlServiceInterface.php
index 37d95311..7589a9b9 100755
--- a/lib/WebDriver/Service/CurlServiceInterface.php
+++ b/lib/WebDriver/Service/CurlServiceInterface.php
@@ -23,7 +23,7 @@ interface CurlServiceInterface
      *
      * @param string $requestMethod HTTP request method, e.g., 'GET', 'POST', or 'DELETE'
      * @param string $url           Request URL
-     * @param array  $parameters    If an array(), they will be posted as JSON parameters
+     * @param array  $parameters    If an array, they will be posted as JSON parameters
      *                              If a number or string, "/$params" is appended to url
      * @param array  $extraOptions  key=>value pairs of curl options to pass to curl_setopt()
      *
@@ -31,5 +31,5 @@ interface CurlServiceInterface
      *
      * @throws \WebDriver\Exception\CurlExec only if http error and CURLOPT_FAILONERROR has been set in extraOptions
      */
-    public function execute($requestMethod, $url, $parameters = null, $extraOptions = array());
+    public function execute($requestMethod, $url, $parameters = null, $extraOptions = []);
 }
diff --git a/lib/WebDriver/ServiceFactory.php b/lib/WebDriver/ServiceFactory.php
index fb806bb9..3842bbcd 100755
--- a/lib/WebDriver/ServiceFactory.php
+++ b/lib/WebDriver/ServiceFactory.php
@@ -11,6 +11,8 @@
 
 namespace WebDriver;
 
+use WebDriver\Service\CurlService;
+
 /**
  * WebDriver\ServiceFactory class
  *
@@ -42,11 +44,11 @@ class ServiceFactory
      */
     private function __construct()
     {
-        $this->services = array();
+        $this->services = [];
 
-        $this->serviceClasses = array(
-            'service.curl' => '\\WebDriver\\Service\\CurlService',
-        );
+        $this->serviceClasses = [
+            'service.curl' => CurlService::class,
+        ];
     }
 
     /**
diff --git a/lib/WebDriver/Session.php b/lib/WebDriver/Session.php
index c8825eec..766dad75 100644
--- a/lib/WebDriver/Session.php
+++ b/lib/WebDriver/Session.php
@@ -11,43 +11,44 @@
 
 namespace WebDriver;
 
+use WebDriver\Extension\ChromeDevTools;
+use WebDriver\Extension\FederatedCredentialManagementAPI;
+use WebDriver\Extension\Selenium;
+use WebDriver\Extension\WebAuthenticationAPI;
+
 /**
  * WebDriver\Session class
  *
  * @package WebDriver
  *
- * @method void accept_alert() Accepts the currently displayed alert dialog.
+ * W3C
  * @method array deleteActions() Release actions.
- * @method array postActions($json) Perform actions.
- * @method string getAlert_text() Gets the text of the currently displayed JavaScript alert(), confirm(), or prompt() dialog.
- * @method void postAlert_text($jsonText) Sends keystrokes to a JavaScript prompt() dialog.
+ * @method array postActions($parameters) Perform actions.
  * @method void back() Navigates backward in the browser history, if possible.
- * @method boolean getBrowser_connection() Is browser online?
- * @method void postBrowser_connection($jsonState) Set browser online.
- * @method void buttondown() Click and hold the left mouse button (at the coordinates set by the last moveto command).
- * @method void buttonup() Releases the mouse button previously held (where the mouse is currently at).
- * @method void click($jsonButton) Click any mouse button (at the coordinates set by the last moveto command).
  * @method array getCookie() Retrieve all cookies visible to the current page.
- * @method array postCookie($jsonCookie) Set a cookie.
- * @method void dismiss_alert() Dismisses the currently displayed alert dialog.
- * @method void doubleclick() Double-clicks at the current mouse coordinates (set by moveto).
- * @method array execute_sql($jsonQuery) Execute SQL.
- * @method array file($jsonFile) Upload file.
+ * @method array postCookie($parameters) Set a cookie.
  * @method void forward() Navigates forward in the browser history, if possible.
- * @method void keys($jsonKeys) Send a sequence of key strokes to the active element.
- * @method array getLocation() Get the current geo location.
- * @method void postLocation($jsonCoordinates) Set the current geo location.
- * @method string getOrientation() Get the current browser orientation.
- * @method void postOrientation($jsonOrientation) Set the current browser orientation.
  * @method array print() Print page.
  * @method void refresh() Refresh the current page.
  * @method string screenshot() Take a screenshot of the current page.
  * @method string source() Get the current page source.
  * @method string title() Get the current page title.
  * @method string url() Retrieve the URL of the current page.
- * @method void postUrl($jsonUrl) Navigate to a new URL.
- * @method string window_handle() Retrieve the current window handle.
- * @method array window_handles() Retrieve the list of all window handles available to the session.
+ * @method void postUrl($parameters) Navigate to a new URL.
+ * Selenium
+ * @method boolean getBrowser_connection() Is browser online?
+ * @method void postBrowser_connection($parameters) Set browser online.
+ * @method array context() Get current context handle.
+ * @method void postContext() Switch to context.
+ * @method array contexts() Get context handles.
+ * @method array getLocation() Get the current geo location.
+ * @method void postLocation($parameters) Set the current geo location.
+ * @method string getNetworkConnection() Get the network connection.
+ * @method void postNetworkConnection($parameters) Set the network connection.
+ * @method string getOrientation() Get the current browser orientation.
+ * @method void postOrientation($parameters) Set the current browser orientation.
+ * @method string getRotation() Get screen rotation.
+ * @method void postOrientation($parameters) Set screen rotation.
  */
 class Session extends Container
 {
@@ -57,83 +58,113 @@ class Session extends Container
     private $capabilities = null;
 
     /**
-     * @var boolean
+     * {@inheritdoc}
      */
-    private $w3c = null;
+    protected function methods()
+    {
+        return [
+            'actions'            => ['POST', 'DELETE'],
+            'back'               => ['POST'],
+            'cookie'             => ['GET', 'POST'], // for DELETE, use deleteAllCookies()
+            'forward'            => ['POST'],
+            'print'              => ['POST'],
+            'refresh'            => ['POST'],
+            'screenshot'         => ['GET'],
+            'source'             => ['GET'],
+            'title'              => ['GET'],
+            'url'                => ['GET', 'POST'], // alternate for POST, use open($url)
+
+            // @deprecated
+            'browser_connection' => ['GET', 'POST'],
+            'context'            => ['GET', 'POST'],
+            'contexts'           => ['GET'],
+            'location'           => ['GET', 'POST'],
+            'network_connection' => ['GET', 'POST'],
+            'orientation'        => ['GET', 'POST'],
+            'rotation'           => ['GET', 'POST'],
+        ];
+    }
 
     /**
      * {@inheritdoc}
      */
-    protected function methods()
+    public function aliases()
     {
-        return array(
-            'actions' => array('POST', 'DELETE'),
-            'back' => array('POST'),
-            'cookie' => array('GET', 'POST'), // for DELETE, use deleteAllCookies()
-            'forward' => array('POST'),
-            'print' => array('POST'),
-            'refresh' => array('POST'),
-            'screenshot' => array('GET'),
-            'source' => array('GET'),
-            'title' => array('GET'),
-            'url' => array('GET', 'POST'), // alternate for POST, use open($url)
-
-            // specific to Java SeleniumServer
-            'file' => array('POST'),
-
-            // Legacy JSON Wire Protocol
-            'accept_alert' => array('POST'),
-            'alert_text' => array('GET', 'POST'),
-            'browser_connection' => array('GET', 'POST'),
-            'buttondown' => 'POST',
-            'buttonup' => array('POST'),
-            'click' => array('POST'),
-            'dismiss_alert' => array('POST'),
-            'doubleclick' => array('POST'),
-            'execute_sql' => array('POST'),
-            'keys' => array('POST'),
-            'location' => array('GET', 'POST'),
-            'orientation' => array('GET', 'POST'),
-            'window_handle' => array('GET'), // see also getWindowHandle()
-            'window_handles' => array('GET'),
-        );
+        return [
+            'activeElement'     => 'getActiveElement',
+            'addCookie'         => 'setCookie',
+            'capabilities'      => 'getCapabilities',
+            'closeWindow'       => 'deleteWindow',
+            'focusWindow'       => 'switchToWindow',
+            'get'               => 'open',
+            'getCurrentUrl'     => 'getUrl',
+            'getPageSource'     => 'source',
+            'getPageTitle'      => 'title',
+            'goBack'            => 'back',
+            'goForward'         => 'forward',
+            'goTo'              => 'open',
+            'navigateTo'        => 'open',
+            'printPage'         => 'print',
+            'quit'              => 'close',
+
+            // deprecated
+            'application_cache' => 'applicationCache',
+            'execute_async'     => 'executeAsyncScript',
+            'isBrowserOnline'   => 'getBrowserConnection',
+            'setBrowserOnline'  => 'setBrowserConnection',
+            'window_handle'     => 'getWindowHandle',
+            'window_handles'    => 'getWindowHandles',
+        ];
     }
 
     /**
      * {@inheritdoc}
      */
-    protected function obsoleteMethods()
+    protected function chainable()
     {
-        return array(
-            'alert' => array('GET'),
-            'modifier' => array('POST'),
-            'speed' => array('GET', 'POST'),
-            'visible' => array('GET', 'POST'),
-        );
+        return [
+            'actions'  => 'actions',
+            'alert'    => 'alert',
+            'execute'  => 'execute',
+            'frame'    => 'frame',
+            'timeouts' => 'timeouts',
+            'window'   => 'window',
+        ];
     }
 
     /**
      * Constructor
      *
-     * @param string     $url URL to Selenium server
+     * @param string     $url
      * @param array|null $capabilities
      */
     public function __construct($url, $capabilities)
     {
+        // $url already includes :sessionId
         parent::__construct($url);
 
         $this->capabilities = $capabilities;
-        $this->w3c          = !! $capabilities;
+
+        $this->register('cdp', ChromeDevTools::class, 'goog/cdp');
+        $this->register('fedcm', FederatedCredentialManagementAPI::class, 'fedcm');
+        $this->register('selenium', Selenium::class, 'se');
+        $this->register('webauthn', WebAuthenticationAPI::class, 'webauthn/authenticator');
     }
 
     /**
-     * Is W3C webdriver?
+     * Get browser capabilities: /session/:sessionId (GET)
      *
-     * @return boolean
+     * @return mixed
      */
-    public function isW3C()
+    public function getCapabilities()
     {
-        return $this->w3c;
+        if ($this->capabilities === null) {
+            $result = $this->curl('GET', '');
+
+            $this->capabilities = $result['value'];
+        }
+
+        return $this->capabilities;
     }
 
     /**
@@ -146,27 +177,11 @@ public function isW3C()
      */
     public function open($url)
     {
-        $this->curl('POST', '/url', array('url' => $url));
+        $this->curl('POST', '/url', ['url' => $url]);
 
         return $this;
     }
 
-    /**
-     * Get browser capabilities: /session/:sessionId (GET)
-     *
-     * @return mixed
-     */
-    public function capabilities()
-    {
-        if (! isset($this->capabilities)) {
-            $result = $this->curl('GET', '');
-
-            $this->capabilities = $result['value'];
-        }
-
-        return $this->capabilities;
-    }
-
     /**
      * Close session: /session/:sessionId (DELETE)
      *
@@ -200,15 +215,19 @@ public function getAllCookies()
 
     /**
      * Set cookie: /session/:sessionId/cookie (POST)
-     * Alternative to: $session->cookie($cookie_json);
+     * Alternative to: $session->cookie($parameters);
      *
-     * @param array $cookieJson
+     * @param array $cookieData
      *
      * @return \WebDriver\Session
      */
-    public function setCookie($cookieJson)
+    public function setCookie($cookieData)
     {
-        $this->curl('POST', '/cookie', array('cookie' => $cookieJson));
+        $parameters = array_key_exists('cookie', $cookieData)
+            ? $cookieData
+            : ['cookie' => $cookieData];
+
+        $this->curl('POST', '/cookie', $parameters);
 
         return $this;
     }
@@ -241,7 +260,6 @@ public function deleteCookie($cookieName)
 
     /**
      * Get window handle: /session/:sessionId/window (GET)
-     *                  : /session/:sessionId/window_handle (GET)
      * - $session->getWindowHandle()
      *
      * An alternative to $session->window()->getHandle()
@@ -250,7 +268,20 @@ public function deleteCookie($cookieName)
      */
     public function getWindowHandle()
     {
-        $result = $this->curl('GET', $this->w3c ? '/window' : '/window_handle');
+        $result = $this->curl('GET', '/window');
+
+        return $result['value'];
+    }
+
+    /**
+     * Get window handles: /session/:sessionId/window/handles (GET)
+     * - $session->getWindowHandles()
+     *
+     * @return mixed
+     */
+    public function getWindowHandles()
+    {
+        $result = $this->curl('GET', '/window/handles');
 
         return $result['value'];
     }
@@ -261,24 +292,28 @@ public function getWindowHandle()
      *
      * @internal "new" is a reserved keyword in PHP, so $session->window()->new() isn't possible
      *
+     * @param array|string $type Type or Paramters {type: ...}
+     *
      * @return \WebDriver\Window
      */
     public function newWindow($type)
     {
-        $arg = func_get_arg(0); // json
+        $parameters = is_array($type)
+            ? $type
+            : ['type' => $type];
 
-        $result = $this->curl('POST', '/window/new', $arg);
+        $result = $this->curl('POST', '/window/new', $parameters);
 
         return $result['value'];
     }
 
     /**
-     * window method chaining: /session/:sessionId/window (POST
-     * - $session->window($jsonHandle) - set focus
-     * - $session->window($handle)->method() - chaining
+     * window method chaining: /session/:sessionId/window (POST)
+     * - $session->window($parameters) - set focus
+     * - $session->window($parameters)->method() - chaining
      * - $session->window()->method() - chaining
      *
-     * @return \WebDriver\Session|\WebDriver\Window|\WebDriver\LegacyWindow
+     * @return \WebDriver\Session|\WebDriver\Window
      */
     public function window()
     {
@@ -286,17 +321,11 @@ public function window()
 
         // set window focus / switch to window
         if (func_num_args() === 1) {
-            $arg = func_get_arg(0); // window handle or name attribute
-
-            if (is_array($arg)) {
-                $this->curl('POST', '/window', $arg);
-
-                return $this;
-            }
+            return $this->switchToWindow(func_get_arg(0));
         }
 
         // chaining (with optional handle in $arg)
-        return $this->w3c ? new Window($this->url . '/window', $arg) : new LegacyWindow($this->url . '/window', $arg);
+        return new Window($this->url . '/window', $arg);
     }
 
     /**
@@ -312,32 +341,40 @@ public function deleteWindow()
     }
 
     /**
-     * Set focus to window: /session/:sessionId/window (POST)
+     * Switch to window: /session/:sessionId/window (POST)
      *
      * @param mixed $handle window handle (or legacy name) attribute
      *
      * @return \WebDriver\Session
      */
-    public function focusWindow($handle)
+    public function switchToWindow($handle)
     {
-        $this->curl('POST', '/window', array('handle' => $handle, 'name' => $handle));
+        $parameters = is_array($handle)
+            ? $handle
+            : ['handle' => $handle, 'name' => $handle];
+
+        $this->curl('POST', '/window', $parameters);
 
         return $this;
     }
 
     /**
      * frame methods: /session/:sessionId/frame (POST)
-     * - $session->frame($json) - change focus to another frame on the page
+     * - $session->frame($parameters) - change focus to another frame on the page
      * - $session->frame()->method() - chaining
      *
+     * @param array|string $id
+     *
      * @return \WebDriver\Session|\WebDriver\Frame
      */
-    public function frame()
+    public function frame($id = null)
     {
         if (func_num_args() === 1) {
-            $arg = func_get_arg(0); // json
+            $parameters = is_array($id)
+                ? $id
+                : ['id' => $id];
 
-            $this->curl('POST', '/frame', $arg);
+            $this->curl('POST', '/frame', $parameters);
 
             return $this;
         }
@@ -346,46 +383,20 @@ public function frame()
         return new Frame($this->url . '/frame');
     }
 
-    /**
-     * Get timeouts (W3C): /session/:sessionId/timeouts (GET)
-     * - $session->getTimeouts()
-     *
-     * @return mixed
-     */
-    public function getTimeouts()
-    {
-        $result = $this->curl('GET', '/timeouts');
-
-        return $result['value'];
-    }
-
     /**
      * timeouts methods: /session/:sessionId/timeouts (POST)
-     * - $session->timeouts($json) - set timeout for an operation
+     * - $session->timeouts($parameters) - set timeout for an operation
      * - $session->timeouts()->method() - chaining
      *
      * @return \WebDriver\Session|\WebDriver\Timeouts
      */
     public function timeouts()
     {
-        // set timeouts
-        if (func_num_args() === 1) {
-            $arg = func_get_arg(0); // json
-
-            $this->curl('POST', '/timeouts', $arg);
-
-            return $this;
-        }
-
-        if (func_num_args() === 2) {
-            $type    = func_get_arg(0); // 'script', 'implicit', or 'pageLoad' (legacy: 'pageLoad')
-            $timeout = func_get_arg(1); // timeout in milliseconds
-
-            $arg = $this->w3c ? array($type => $timeout) : array('type' => $type, 'ms' => $timeout);
-
-            $this->curl('POST', '/timeouts', $arg);
+        // @deprecated
+        if (func_num_args() > 0) {
+            // trigger_error(__METHOD__ . ': use "Timeouts::setTimeout()" instead', E_USER_DEPRECATED);
 
-            return $this;
+            return call_user_func_array([$this, 'setTimeout'], func_get_args());
         }
 
         // chaining
@@ -393,14 +404,25 @@ public function timeouts()
     }
 
     /**
-     * ime method chaining, e.g.,
-     * - $session->ime()->method()
+     * Get timeouts: /session/:sessionId/timeouts (GET)
+     * - $session->getTimeouts()
+     *
+     * @return mixed
+     */
+    public function getTimeouts()
+    {
+        return call_user_func([$this->timeouts(), 'getTimeouts']);
+    }
+
+    /**
+     * Set timeout: /session/:sessionId/timeouts (POST)
+     * - $session->setTimeout()
      *
-     * @return \WebDriver\Ime
+     * @return mixed
      */
-    public function ime()
+    public function setTimeout()
     {
-        return new Ime($this->url . '/ime');
+        return call_user_func_array([$this->timeouts(), 'setTimeout'], func_get_args());
     }
 
     /**
@@ -409,7 +431,7 @@ public function ime()
      *
      * @return mixed
      */
-    public function activeElement()
+    public function getActiveElement()
     {
         $result = $this->curl('POST', '/element/active');
 
@@ -417,149 +439,399 @@ public function activeElement()
     }
 
     /**
-     * touch method chaining, e.g.,
-     * - $session->touch()->method()
+     * actions method chaining, e.g.,
+     * - $session->actions()->method() - chaining
+     * - $session->actions->method() - chaining
      *
-     * @return \WebDriver\Touch
+     * @return mixed
+     */
+    protected function actions()
+    {
+        return Actions::getInstance($this->url . '/actions');
+    }
+
+    /**
+     * alert method chaining, e.g.,
+     * - $session->alert()->method() - chaining
+     * - $session->alert->method() - chaining
      *
+     * @return mixed
      */
-    public function touch()
+    protected function alert()
     {
-        return new Touch($this->url . '/touch');
+        return new Alert($this->url . '/alert');
     }
 
     /**
-     * local_storage method chaining, e.g.,
-     * - $session->local_storage()->method()
+     * script execution method chaining, e.g.,
+     * - $session->execute($parameters) - execute script
+     * - $session->execute()->method() - chaining
      *
-     * @return \WebDriver\Storage\Local
+     * @return mixed
      */
-    public function localStorage()
+    public function execute()
     {
-        return new Storage\Local($this->url . '/local_storage');
+        // @deprecated
+        if (func_num_args() > 0) {
+            // trigger_error(__METHOD__ . ': use "Execute::sync()" instead', E_USER_DEPRECATED);
+
+            return call_user_func_array([$this, 'executeScript'], func_get_args());
+        }
+
+        return new Execute($this->url);
     }
 
     /**
-     * session_storage method chaining, e.g.,
-     * - $session->session_storage()->method()
+     * async script execution: /session/:sessionId/execute_async
+     *
+     * @deprecated
      *
-     * @return \WebDriver\Storage\Session
+     * @return mixed
      */
-    public function sessionStorage()
+    public function executeAsyncScript()
     {
-        return new Storage\Session($this->url . '/session_storage');
+        // trigger_error(__METHOD__ . ': use "Execute::async()" instead', E_USER_DEPRECATED);
+
+        return call_user_func_array([$this->execute(), 'async'], func_get_args());
+    }
+
+    /**
+     * sync script execution: /session/:sessionId/execute
+     *
+     * @deprecated
+     *
+     * @return mixed
+     */
+    public function executeScript()
+    {
+        // trigger_error(__METHOD__ . ': use "Execute::sync()" instead', E_USER_DEPRECATED);
+
+        return call_user_func_array([$this->execute(), 'sync'], func_get_args());
     }
 
     /**
      * application cache chaining, e.g.,
      * - $session->application_cache()->method() - chaining
      *
+     * @deprecated
+     *
      * @return \WebDriver\ApplicationCache
      */
     public function applicationCache()
     {
+        // trigger_error(__METHOD__, E_USER_DEPRECATED);
+
         return new ApplicationCache($this->url . '/application_cache');
     }
 
     /**
-     * log methods: /session/:sessionId/log (POST)
-     * - $session->log($type) - get log for given log type
-     * - $session->log()->method() - chaining
+     * Move the mouse: /session/:sessionId/moveto (POST)
+     *
+     * @deprecated
+     *
+     * @param array $parameters
      *
      * @return mixed
      */
-    public function log()
+    public function moveto($parameters)
     {
-        // get log for given log type
-        if (func_num_args() === 1) {
-            $arg = func_get_arg(0);
-
-            if (is_string($arg)) {
-                $arg = array(
-                    'type' => $arg,
-                );
+        // trigger_error(__METHOD__ . ': use actions() API instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('POST', '/moveto', $parameters);
+        } catch (\Exception $e) {
+            if (! array_key_exists('element', $parameters)) {
+                $actionItem = [
+                    'x' => $parameters['xoffset'],
+                    'y' => $parameters['yoffset'],
+                ];
+            } else {
+                $element = new Element($this->url . '/element', $parameters['element']);
+                $rect    = $element->rect();
+
+                $actionItem = [
+                    'x' => $rect['x'] + ($parameters['xoffset'] ?? $rect['width'] / 2),
+                    'y' => $rect['y'] + ($parameters['yoffset'] ?? $rect['height'] / 2),
+                ];
             }
 
-            $result = $this->curl('POST', '/log', $arg);
+            $mouse = $this->actions()->getPointerInput(0, PointerInput::MOUSE);
 
-            return $result['value'];
+            $result = $this->actions()
+                           ->addAction($mouse->pointerMove($actionItem))
+                           ->perform();
         }
 
-        // chaining
-        return new Log($this->url . '/log');
+        return $result['value'];
     }
 
     /**
-     * alert method chaining, e.g.,
-     * - $session->alert()->method() - chaining
+     * Click any mouse button: /session/:sessionId/click (POST)
+     *
+     * @deprecated
+     *
+     * @param array $parameters Parameters {button: ...}
      *
      * @return mixed
      */
-    public function alert()
+    public function click($parameters)
     {
-        return new Alert($this->url . '/alert');
+        // trigger_error(__METHOD__ . ': use actions() API instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('POST', '/click', $parameters);
+        } catch (\Exception $e) {
+            $mouse = $this->actions()->getPointerInput(0, PointerInput::MOUSE);
+
+            $result = $this->actions()
+                           ->addAction($mouse->pointerDown([
+                                'button' => PointerInput::LEFT_BUTTON,
+                             ]))
+                           ->addAction($mouse->pointerUp([
+                                'button' => PointerInput::LEFT_BUTTON,
+                             ]))
+                           ->perform();
+        }
+
+        return $result['value'];
     }
 
     /**
-     * script execution method chaining, e.g.,
-     * - $session->execute($jsonScript) - fallback for legacy JSON Wire Protocol
-     * - $session->execute()->method() - chaining
+     * Double click left mouse button: /session/:sessionId/doubleclick (POST)
+     *
+     * @deprecated
+     *
+     * @param array $parameters Parameters
      *
      * @return mixed
      */
-    public function execute()
+    public function doubleclick($parameters)
     {
-        // execute script
-        if (func_num_args() > 0) {
-            $execute = $this->w3c ? new Execute($this->url . '/execute') : new LegacyExecute($this->url);
-            $result  = $execute->sync(func_get_arg(0));
+        // trigger_error(__METHOD__ . ': use actions() API instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('POST', '/doubleclick', $parameters);
+        } catch (\Exception $e) {
+            $mouse = $this->actions()->getPointerInput(0, PointerInput::MOUSE);
+
+            $result = $this->actions()
+                           ->addAction($mouse->pointerDown([
+                                'button' => PointerInput::LEFT_BUTTON,
+                             ]))
+                           ->addAction($mouse->pointerUp([
+                                'button' => PointerInput::LEFT_BUTTON,
+                             ]))
+                           ->addAction($mouse->pointerDown([
+                                'button' => PointerInput::LEFT_BUTTON,
+                             ]))
+                           ->addAction($mouse->pointerUp([
+                                'button' => PointerInput::LEFT_BUTTON,
+                             ]))
+                           ->perform();
+        }
 
-            return $result;
+        return $result['value'];
+    }
+
+    /**
+     * Click and hold left mouse button down: /session/:sessionId/buttondown (POST)
+     *
+     * @deprecated
+     *
+     * @param array $parameters Parameters {button: ...}
+     *
+     * @return mixed
+     */
+    public function buttondown($parameters)
+    {
+        // trigger_error(__METHOD__ . ': use actions() API instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('POST', '/buttondown', $parameters);
+        } catch (\Exception $e) {
+            $mouse = $this->actions()->getPointerInput(0, PointerInput::MOUSE);
+
+            $result = $this->actions()
+                           ->addAction($mouse->pointerDown([
+                                'button' => PointerInput::LEFT_BUTTON,
+                             ]))
+                           ->perform();
         }
 
-        // W3C method chaining
-        return new Execute($this->url . '/execute');
+        return $result['value'];
     }
 
     /**
-     * async script execution
-     * - $session->execute_async($jsonScript)
+     * Release mouse button: /session/:sessionId/buttonup (POST)
+     *
+     * @deprecated
+     *
+     * @param array $parameters Parameters {button: ...}
      *
      * @return mixed
      */
-    public function executeAsync()
+    public function buttonup($parameters)
     {
-        $execute = $this->w3c ? new Execute($this->url . '/execute') : new LegacyExecute($this->url);
-        $result  = $execute->async(func_get_arg(0));
+        // trigger_error(__METHOD__ . ': use actions() API instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('POST', '/buttonup', $parameters);
+        } catch (\Exception $e) {
+            $mouse = $this->actions()->getPointerInput(0, PointerInput::MOUSE);
+
+            $result = $this->actions()
+                           ->addAction($mouse->pointerUp([
+                                'button' => PointerInput::LEFT_BUTTON,
+                             ]))
+                           ->perform();
+        }
 
-        return $result;
+        return $result['value'];
     }
 
     /**
-     * {@inheritdoc}
+     * Gets the text of the currenty displayed Javascript dialog: /session/:sessionId/alert_text (GET)
+     *
+     * @deprecated
+     *
+     * @return mixed
      */
-    protected function getIdentifierPath($identifier)
+    public function getAlertText()
     {
-        return sprintf('%s/element/%s', $this->url, $identifier);
+        // trigger_error(__METHOD__ . ': use "Alert::getAlertText()" instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('GET', '/alert_text');
+        } catch (\Exception $e) {
+            $result = $this->alert()->getAlertText();
+        }
+
+        return $result['value'];
     }
 
     /**
-     * {@inheritdoc}
+     * Send keystrokes to a Javascript dialog: /session/:sessionId/alert_text (POST)
+     *
+     * @deprecated
+     *
+     * @param array|string $text Parameters {text: ...}
+     *
+     * @return mixed
      */
-    public function __call($name, $arguments)
+    public function postAlertText($text)
     {
-        $map = [
-            'application_cache' => 'applicationCache',
-            'execute_async'     => 'executeAsync',
-            'local_storage'     => 'localStorage',
-            'session_storage'   => 'sessionStorage',
-        ];
+        $parameters = is_array($text)
+            ? $text
+            : ['text' => $text];
+
+        // trigger_error(__METHOD__ . ': use "Alert::setAlertText()" instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('POST', '/alert_text', $parameters);
+        } catch (\Exception $e) {
+            $result = $this->alert()->setAlertText();
+        }
 
-        if (array_key_exists($name, $map)) {
-            return call_user_func_array([$this, $map[$name]], $arguments);
+        return $result['value'];
+    }
+
+    /**
+     * Accepts the currently displayed alert dialog: /session/:sessionId/accept_alert (POST)
+     *
+     * @deprecated
+     *
+     * @return mixed
+     */
+    public function acceptAlert()
+    {
+        // trigger_error(__METHOD__ . ': use "Alert::acceptAlert()" instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('POST', '/accept_alert');
+        } catch (\Exception $e) {
+            $result = $this->alert()->acceptAlert();
         }
 
-        // fallback to executing WebDriver commands
-        return parent::__call($name, $arguments);
+        return $result['value'];
+    }
+
+    /**
+     * Dismisses the currently displayed alert dialog: /session/:sessionId/dismiss_alert (POST)
+     *
+     * @deprecated
+     *
+     * @return mixed
+     */
+    public function dismissAlert()
+    {
+        // trigger_error(__METHOD__ . ': use "Alert::dismissAlert()" instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('POST', '/dismiss_alert');
+        } catch (\Exception $e) {
+            $result = $this->alert()->dismissAlert();
+        }
+
+        return $result['value'];
+    }
+
+    /**
+     * Send a sequence of key strokes to the active element.: /session/:sessionId/keys (POST)
+     *
+     * @deprecated
+     *
+     * @param array|string $value Value or Parameters {value: ...}
+     *
+     * @return mixed
+     */
+    public function keys($value)
+    {
+        $parameters = is_array($value)
+            ? $value
+            : ['value' => $value];
+
+        // trigger_error(__METHOD__ . ': use "Element::sendKeys()" instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('POST', '/keys', $parameters);
+        } catch (\Exception $e) {
+            $result = $this->getActiveElement()->sendKeys($parameters);
+        }
+
+        return $result['value'];
+    }
+
+    /**
+     * Upload file: /session/:sessionId/file (POST)
+     *
+     * @deprecated
+     *
+     * @param array|string $file Parameters {file: ...}
+     *
+     * @return mixed
+     */
+    public function file($file)
+    {
+        $parameters = is_array($file)
+            ? $file
+            : ['file' => $file];
+
+        // trigger_error(__METHOD__ . ': use "Selenium::uploadFile()" instead', E_USER_DEPRECATED);
+
+        try {
+            $result = $this->curl('POST', '/file', $parameters);
+        } catch (\Exception $e) {
+            $result = $this->selenium()->uploadFile($parameters);
+        }
+
+        return $result['value'];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function getNewIdentifierPath($identifier)
+    {
+        return $this->url . "/element/$identifier";
     }
 }
diff --git a/lib/WebDriver/Shadow.php b/lib/WebDriver/Shadow.php
index b106e0d0..b644c7f9 100644
--- a/lib/WebDriver/Shadow.php
+++ b/lib/WebDriver/Shadow.php
@@ -15,8 +15,6 @@
  * WebDriver\Shadow class
  *
  * @package WebDriver
- *
- * @deprecated by W3C WebDriver
  */
 class Shadow extends Container
 {
@@ -29,14 +27,6 @@ class Shadow extends Container
      */
     private $id;
 
-    /**
-     * {@inheritdoc}
-     */
-    protected function methods()
-    {
-        return array();
-    }
-
     /**
      * Constructor
      *
@@ -45,7 +35,7 @@ protected function methods()
      */
     public function __construct($url, $id)
     {
-        parent::__construct($url);
+        parent::__construct($url . "/$id");
 
         $this->id = $id;
     }
@@ -63,8 +53,8 @@ public function getID()
     /**
      * {@inheritdoc}
      */
-    protected function getIdentifierPath($identifier)
+    protected function getNewIdentifierPath($identifier)
     {
-        return sprintf('%s/element/%s', $this->url, $identifier);
+        return preg_replace('~/shadow/' . preg_quote($this->id) . '$~', "/element/$identifier", $this->url);
     }
 }
diff --git a/lib/WebDriver/Storage/AbstractStorage.php b/lib/WebDriver/Storage/AbstractStorage.php
deleted file mode 100644
index cede7d69..00000000
--- a/lib/WebDriver/Storage/AbstractStorage.php
+++ /dev/null
@@ -1,114 +0,0 @@
-<?php
-
-/**
- * @copyright 2011 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Storage;
-
-use WebDriver\AbstractWebDriver;
-use WebDriver\Exception as WebDriverException;
-
-/**
- * WebDriver\AbstractStorage class
- *
- * @package WebDriver
- *
- * @method mixed getKey($key) Get key/value pair.
- * @method void deleteKey($key) Delete a specific key.
- * @method integer size() Get the number of items in the storage.
- */
-abstract class AbstractStorage extends AbstractWebDriver
-{
-    /**
-     * {@inheritdoc}
-     */
-    protected function methods()
-    {
-        return array(
-            'key' => array('GET', 'DELETE'),
-            'size' => array('GET'),
-        );
-    }
-
-    /**
-     * Get all keys from storage or a specific key/value pair
-     *
-     * @return mixed
-     */
-    public function get()
-    {
-        // get all keys
-        if (func_num_args() === 0) {
-            $result = $this->curl('GET', '');
-
-            return $result['value'];
-        }
-
-        // get key/value pair
-        if (func_num_args() === 1) {
-            return $this->getKey(func_get_arg(0));
-        }
-
-        throw WebDriverException::factory(WebDriverException::UNEXPECTED_PARAMETERS);
-    }
-
-    /**
-     * Set specific key/value pair
-     *
-     * @return \WebDriver\Storage\AbstractStorage
-     *
-     * @throw \WebDriver\Exception\UnexpectedParameters if unexpected parameters
-     */
-    public function set()
-    {
-        if (func_num_args() === 1 && is_array($arg = func_get_arg(0))) {
-            $this->curl('POST', '', $arg);
-
-            return $this;
-        }
-
-        if (func_num_args() === 2) {
-            $arg = array(
-                'key' => func_get_arg(0),
-                'value' => func_get_arg(1),
-            );
-            $this->curl('POST', '', $arg);
-
-            return $this;
-        }
-
-        throw WebDriverException::factory(WebDriverException::UNEXPECTED_PARAMETERS);
-    }
-
-    /**
-     * Delete storage or a specific key
-     *
-     * @return \WebDriver\Storage\AbstractStorage
-     *
-     * @throw \WebDriver\Exception\UnexpectedParameters if unexpected parameters
-     */
-    public function delete()
-    {
-        // delete storage
-        if (func_num_args() === 0) {
-            $this->curl('DELETE', '');
-
-            return $this;
-        }
-
-        // delete key from storage
-        if (func_num_args() === 1) {
-            $this->deleteKey(func_get_arg(0));
-
-            return $this;
-        }
-
-        throw WebDriverException::factory(WebDriverException::UNEXPECTED_PARAMETERS);
-    }
-}
diff --git a/lib/WebDriver/Storage/Local.php b/lib/WebDriver/Storage/Local.php
deleted file mode 100644
index f25451e8..00000000
--- a/lib/WebDriver/Storage/Local.php
+++ /dev/null
@@ -1,21 +0,0 @@
-<?php
-
-/**
- * @copyright 2021 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Storage;
-
-/**
- * WebDriver\Storage\Local class
- *
- * @package WebDriver
- */
-class Local extends AbstractStorage
-{
-}
diff --git a/lib/WebDriver/Storage/Session.php b/lib/WebDriver/Storage/Session.php
deleted file mode 100644
index ffcf8189..00000000
--- a/lib/WebDriver/Storage/Session.php
+++ /dev/null
@@ -1,21 +0,0 @@
-<?php
-
-/**
- * @copyright 2021 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver\Storage;
-
-/**
- * WebDriver\Storage\Session class
- *
- * @package WebDriver
- */
-class Session extends AbstractStorage
-{
-}
diff --git a/lib/WebDriver/Timeouts.php b/lib/WebDriver/Timeouts.php
index 57a8de92..4f105ae6 100644
--- a/lib/WebDriver/Timeouts.php
+++ b/lib/WebDriver/Timeouts.php
@@ -18,21 +18,115 @@
  *
  * @package WebDriver
  *
- * @method void async_script($json) Set the amount of time, in milliseconds, that asynchronous scripts (executed by execute_async) are permitted to run before they are aborted and a timeout error is returned to the client.
- * @method void implicit_wait($json) Set the amount of time the driver should wait when searching for elements.
+ * Selenium
+ * @method void async_script($parameters) Set the amount of time, in milliseconds, that asynchronous scripts (executed by execute_async) are permitted to run before they are aborted and a timeout error is returned to the client.
+ * @method void implicit_wait($parameters) Set the amount of time the driver should wait when searching for elements.
  */
 class Timeouts extends AbstractWebDriver
 {
+    // Timeout name constants
+    const IMPLICIT  = 'implicit';
+    const PAGE_LOAD = 'pageLoad';
+    const SCRPT     = 'script';
+
     /**
      * {@inheritdoc}
      */
     protected function methods()
     {
-        return array(
-            // Legacy JSON Wire Protocol
-            'async_script' => array('POST'),
-            'implicit_wait' => array('POST'),
-        );
+        return [
+            // @deprecated
+            'async_script'  => ['POST'],
+            'implicit_wait' => ['POST'],
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function aliases()
+    {
+        return [
+            // @deprecated
+            'async_script'  => 'setScriptTimeout',
+            'implicit_wait' => 'implicitlyWait',
+        ];
+    }
+
+    /**
+     * Get timeouts: /session/:sessionId/timeouts (GET)
+     *
+     * @return mixed
+     */
+    public function getTimeouts()
+    {
+        $result = $this->curl('GET', '');
+
+        return $result['value'];
+    }
+
+    /**
+     * Implicitly wait: /session/:sessionId/timeout/implicit_wait (POST)
+     *
+     * @deprecated
+     *
+     * @param array|integer $ms Parameters {ms: ...}
+     *
+     * @return \WebDriver\Timeouts
+     */
+    public function implicitlyWait($ms)
+    {
+        $parameters = is_array($ms)
+            ? $ms
+            : ['ms' => $ms];
+
+        // trigger_error(__METHOD__ . ': use "setTimeout()" instead', E_USER_DEPRECATED);
+
+        $result = $this->curl('POST', '/implicit_wait', $parameters);
+
+        return $this;
+    }
+
+    /**
+     * Set script timeout: /session/:sessionId/timeout/async_script (POST)
+     *
+     * @deprecated
+     *
+     * @param array|integer $ms Parameters {ms: ...}
+     *
+     * @return \WebDriver\Timeouts
+     */
+    public function setScriptTimeout($ms)
+    {
+        $parameters = is_array($ms)
+            ? $ms
+            : ['ms' => $ms];
+
+        // trigger_error(__METHOD__ . ': use "setTimeout()" instead', E_USER_DEPRECATED);
+
+        $result = $this->curl('POST', '/async_script', $parameters);
+
+        return $this;
+    }
+
+    /**
+     * Set timeout: /session/:sessionId/timeouts (POST)
+     *
+     * @param string|array $type    Timeout name (see constants above)
+     * @param integer      $timeout Duration in milliseconds
+     *
+     * @return \WebDriver\Timeouts
+     */
+    public function setTimeout($type, $timeout = null)
+    {
+        // set timeouts
+        $parameters = func_num_args() === 1 && is_array($type)
+            ? $type
+            : [$type => $timeout];
+
+        $this->curl('POST', '', $parameters);
+
+        return $this;
     }
 
     /**
@@ -47,7 +141,7 @@ protected function methods()
      *
      * @throws \Exception if thrown by callback, or \WebDriver\Exception\Timeout if helper times out
      */
-    public function wait($callback, $maxIterations = 1, $sleep = 0, $args = array())
+    public function wait($callback, $maxIterations = 1, $sleep = 0, $args = [])
     {
         $i = max(1, $maxIterations);
 
diff --git a/lib/WebDriver/Touch.php b/lib/WebDriver/Touch.php
deleted file mode 100644
index 32c66e90..00000000
--- a/lib/WebDriver/Touch.php
+++ /dev/null
@@ -1,46 +0,0 @@
-<?php
-
-/**
- * @copyright 2011 Anthon Pang
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Anthon Pang <apang@softwaredevelopment.ca>
- */
-
-namespace WebDriver;
-
-/**
- * WebDriver\Touch class
- *
- * @package WebDriver
- *
- * @method void click($jsonElement) Single tap on the touch enabled device.
- * @method void doubleclick($jsonElement) Double tap on the touch screen using finger motion events.
- * @method void down($jsonCoordinates) Finger down on the screen.
- * @method void flick($json) Flick on the touch screen using finger motion events.
- * @method void longclick($jsonElement) Long press on the touch screen using finger motion events.
- * @method void move($jsonCoordinates) Finger move on the screen.
- * @method void scroll($jsonCoordinates) Scroll on the touch screen using finger based motion events.  Coordinates are either absolute, or relative to a element (if specified).
- * @method void up($jsonCoordinates) Finger up on the screen.
- */
-class Touch extends AbstractWebDriver
-{
-    /**
-     * {@inheritdoc}
-     */
-    protected function methods()
-    {
-        return array(
-            'click' => array('POST'),
-            'doubleclick' => array('POST'),
-            'down' => array('POST'),
-            'flick' => array('POST'),
-            'longclick' => array('POST'),
-            'move' => array('POST'),
-            'scroll' => array('POST'),
-            'up' => array('POST'),
-        );
-    }
-}
diff --git a/lib/WebDriver/WebDriver.php b/lib/WebDriver/WebDriver.php
index 008a9f47..ce0c074e 100644
--- a/lib/WebDriver/WebDriver.php
+++ b/lib/WebDriver/WebDriver.php
@@ -12,13 +12,18 @@
 namespace WebDriver;
 
 /**
- * WebDriver class
+ * WebDriver client class
  *
  * @package WebDriver
  *
+ * W3C
+ * @method void session($parameters) New session.
  * @method array status() Returns information about whether a remote end is in a state in which it can create new sessions.
+ * Selenium
+ * @method array logs() Returns session logs mapped to session IDs.
+ * @method array sessions() Get all sessions.
  */
-class WebDriver extends AbstractWebDriver implements WebDriverInterface
+class WebDriver extends AbstractWebDriver
 {
     /**
      * @var array
@@ -30,57 +35,60 @@ class WebDriver extends AbstractWebDriver implements WebDriverInterface
      */
     protected function methods()
     {
-        return array(
-            'status' => 'GET',
-        );
+        return [
+            'session'  => ['POST'],
+            'status'   => ['GET'],
+
+            // @deprecated
+            'logs'     => ['POST'],
+            'sessions' => ['GET'],
+        ];
     }
 
     /**
      * {@inheritdoc}
      */
-    public function session($browserName = Browser::FIREFOX, $desiredCapabilities = null, $requiredCapabilities = null)
+    protected function aliases()
     {
-        // default to W3C WebDriver API
-        $firstMatch = $desiredCapabilities ?: array();
-        $firstMatch[] = array('browserName' => Browser::CHROME);
-
-        if ($browserName !== Browser::CHROME) {
-            $firstMatch[] = array('browserName' => $browserName);
-        }
-
-        $parameters = array('capabilities' => array('firstMatch' => $firstMatch));
-
-        if (is_array($requiredCapabilities) && count($requiredCapabilities)) {
-            $parameters['capabilities']['alwaysMatch'] = $requiredCapabilities;
-        }
+        return [
+            'session'  => 'newSession',
+            'status'   => 'getStatus',
+
+            // @deprecated
+            'logs'     => 'getSessionLogs',
+            'sessions' => 'getAllSessions',
+        ];
+    }
 
-        try {
-            $result = $this->curl(
-                'POST',
-                '/session',
-                $parameters,
-                array(CURLOPT_FOLLOWLOCATION => true)
-            );
-        } catch (\Exception $e) {
-            // fallback to legacy JSON Wire Protocol
-            $capabilities = $desiredCapabilities ?: array();
-            $capabilities[Capability::BROWSER_NAME] = $browserName;
+    /**
+     * New Session: /session (POST)
+     * Returns a session object suitable for chaining
+     *
+     * @param array|string $browserName          Preferred browser
+     * @param array        $desiredCapabilities  Optional desired capabilities
+     * @param array        $requiredCapabilities Optional required capabilities
+     *
+     * @return \WebDriver\Session
+     */
+    public function newSession($browserName = Browser::CHROME, $desiredCapabilities = null, $requiredCapabilities = null)
+    {
+        if (func_num_args() === 1 && is_array($browserName)) {
+            $parameters = $browserName;
+        } else {
+            $firstMatch = $desiredCapabilities ?: [];
+            $firstMatch[] = ['browserName' => $browserName];
 
-            $parameters = array('desiredCapabilities' => $capabilities);
+            $parameters = ['capabilities' => ['firstMatch' => $firstMatch]];
 
             if (is_array($requiredCapabilities) && count($requiredCapabilities)) {
-                $parameters['requiredCapabilities'] = $requiredCapabilities;
+                $parameters['capabilities']['alwaysMatch'] = $requiredCapabilities;
             }
-
-            $result = $this->curl(
-                'POST',
-                '/session',
-                $parameters,
-                array(CURLOPT_FOLLOWLOCATION => true)
-            );
         }
 
-        $this->capabilities = isset($result['value']['capabilities']) ? $result['value']['capabilities'] : null;
+        $options = [CURLOPT_FOLLOWLOCATION => true];
+        $result = $this->curl('POST', '/session', $parameters, $options);
+
+        $this->capabilities = $result['value']['capabilities'] ?? $result['value']['capabilities'];
 
         $session = new Session($result['sessionUrl'], $this->capabilities);
 
@@ -88,17 +96,18 @@ public function session($browserName = Browser::FIREFOX, $desiredCapabilities =
     }
 
     /**
-     * Get Sessions: /sessions (GET)
+     * Get all sessions: /sessions (GET)
      * Get list of currently active sessions
      *
+     * @see https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/remote/codec/AbstractHttpCommandCodec.java
      * @deprecated
      *
      * @return array an array of \WebDriver\Session objects
      */
-    public function sessions()
+    public function getAllSessions()
     {
         $result = $this->curl('GET', '/sessions');
-        $sessions = array();
+        $sessions = [];
 
         foreach ($result['value'] as $session) {
             $session = new Session($this->url . '/session/' . $session['id'], $this->capabilities);
@@ -108,4 +117,30 @@ public function sessions()
 
         return $sessions;
     }
+
+    /**
+     * Get session logs: /logs
+     *
+     * @deprecated
+     *
+     * @return mixed
+     */
+    public function getSessionLogs()
+    {
+        $result = $this->curl('POST', '/logs');
+
+        return $result['value'];
+    }
+
+    /**
+     * Get status: /status
+     *
+     * @return mixed
+     */
+    public function getStatus()
+    {
+        $result = $this->curl('GET', '/status');
+
+        return $result['value'];
+    }
 }
diff --git a/lib/WebDriver/WebDriverInterface.php b/lib/WebDriver/WebDriverInterface.php
deleted file mode 100644
index 3456a5dc..00000000
--- a/lib/WebDriver/WebDriverInterface.php
+++ /dev/null
@@ -1,32 +0,0 @@
-<?php
-
-/**
- * @copyright 2016 Gaetano Giunta
- * @license Apache-2.0
- *
- * @package WebDriver
- *
- * @author Gaetano Giunta <giunta.gaetano@gmail.com>
- */
-
-namespace WebDriver;
-
-/**
- * WebDriverInterface interface
- *
- * @package WebDriver
- */
-interface WebDriverInterface
-{
-    /**
-     * New Session: /session (POST)
-     * Get session object for chaining
-     *
-     * @param string $browserName          Preferred browser
-     * @param array  $desiredCapabilities  Optional desired capabilities
-     * @param array  $requiredCapabilities Optional required capabilities
-     *
-     * @return \WebDriver\Session
-     */
-    public function session($browserName = Browser::FIREFOX, $desiredCapabilities = null, $requiredCapabilities = null);
-}
diff --git a/lib/WebDriver/WheelInput.php b/lib/WebDriver/WheelInput.php
new file mode 100644
index 00000000..1af32e04
--- /dev/null
+++ b/lib/WebDriver/WheelInput.php
@@ -0,0 +1,56 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver;
+
+/**
+ * WebDriver\WheelInput class
+ *
+ * @package WebDriver
+ */
+class WheelInput extends NullInput
+{
+    const TYPE = 'wheel';
+
+    // actions
+    const SCROLL = 'scroll';
+
+    /**
+     * Scroll
+     *
+     * {@internal action item properties:
+     *     x: int - mandatory
+     *     y: int - mandatory
+     *     deltaX: int - mandatory
+     *     deltaY: int - mandatory
+     *     duration: int
+     *     origin: string
+     * }}
+     *
+     * @param array $action Action item
+     *
+     * @return array
+     */
+    public function scroll($action)
+    {
+        $action = [
+            'type' => self::SCROLL,
+        ];
+
+        return [
+            'id'      => $this->id,
+            'type'    => static::TYPE,
+            'actions' => [
+                $action,
+            ],
+        ];
+    }
+}
diff --git a/lib/WebDriver/Window.php b/lib/WebDriver/Window.php
index 38efada7..32cd7f7e 100644
--- a/lib/WebDriver/Window.php
+++ b/lib/WebDriver/Window.php
@@ -16,14 +16,17 @@
  *
  * @package WebDriver
  *
- * @method array handles() Get window handles.
+ * W3C
  * @method array fullscreen() Fullscreen window.
  * @method array maximize() Maximize the window if not already maximized.
  * @method array minimize() Minimize window.
- * @method array getPosition() Get position of the window.
- * @method void postPosition($json) Change position of the window.
  * @method array getRect() Get window rect.
- * @method array postRect($json) Set window rect.
+ * @method array postRect($parameters) Set window rect.
+ * Selenium
+ * @method array getPosition() Get window position.
+ * @method array postPosition($parameters) Set window position.
+ * @method array getSize() Get window size.
+ * @method array postSize($parameters) Set window size.
  */
 class Window extends AbstractWebDriver
 {
@@ -39,25 +42,31 @@ class Window extends AbstractWebDriver
      */
     protected function methods()
     {
-        return array(
-            'handles' => array('GET'),
-            'fullscreen' => array('POST'),
-            'maximize' => array('POST'),
-            'minimize' => array('POST'),
-            'position' => array('GET', 'POST'),
-            'rect' => array('GET', 'POST'),
-        );
+        return [
+            'fullscreen' => ['POST'],
+            'maximize'   => ['POST'],
+            'minimize'   => ['POST'],
+            'rect'       => ['GET', 'POST'],
+
+            // @deprecated
+            'position'   => ['GET', 'POST'],
+            'size'       => ['GET', 'POST'],
+        ];
     }
 
     /**
      * {@inheritdoc}
      */
-    protected function obsoleteMethods()
+    protected function aliases()
     {
-        return array(
-            // Legacy JSON Wire Protocol
-            'size' => array('GET', 'POST'),
-        );
+        return [
+            'getPosition'  => 'getRect',
+            'getSize'      => 'getRect',
+            'postPosition' => 'setRect',
+            'postSize'     => 'sesRect',
+            'setPosition'  => 'setRect',
+            'setSize'      => 'setRect',
+        ];
     }
 
     /**
@@ -73,6 +82,20 @@ public function __construct($url, $windowHandle = null)
         $this->windowHandle = $windowHandle;
     }
 
+    /**
+     * Set window rect: /session/:sessionId/window/rect (POST)
+     *
+     * @param array $parameters Parameters {width: ..., height: ..., x: ..., y: ...}
+     *
+     * @return mixed
+     */
+    public function setRect($parameters)
+    {
+        $result = $this->curl('POST', '/rect', $parameters);
+
+        return $result['value'];
+    }
+
     /**
      * Get window handle: /session/:sessionId/window (GET)
      * - $session->window($handle)->getHandle()
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 1c57e1fe..ab990a98 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -1,4 +1,7 @@
 parameters:
-    level: 4
+    level: 5
     paths:
         - lib
+    ignoreErrors:
+    - '#Call to an undefined method WebDriver\\Session::selenium\(\)\.#'
+
diff --git a/test/CI/Travis/setup_apache.sh b/test/CI/Travis/setup_apache.sh
deleted file mode 100644
index c33abe77..00000000
--- a/test/CI/Travis/setup_apache.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/sh
-
-# set up Apache
-# @see https://github.com/travis-ci/travis-ci.github.com/blob/master/docs/user/languages/php.md#apache--php
-
-sudo a2enmod rewrite actions fastcgi alias ssl
-
-# configure apache root dir
-sudo sed -i -e "s,/var/www,$(pwd),g" /etc/apache2/sites-available/default
-sudo service apache2 restart
diff --git a/test/CI/Travis/setup_selenium.sh b/test/CI/Travis/setup_selenium.sh
deleted file mode 100644
index ceb67562..00000000
--- a/test/CI/Travis/setup_selenium.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/sh
-
-# set up Selenium for functional tests
-
-wget --max-redirect=1 https://goo.gl/s4o9Vx -O selenium.jar
-
-java -jar selenium.jar &
diff --git a/test/Test/WebDriver/ChromeDriverTest.php b/test/Test/WebDriver/ChromeDriverTest.php
index 2fd4fa7b..95269212 100644
--- a/test/Test/WebDriver/ChromeDriverTest.php
+++ b/test/Test/WebDriver/ChromeDriverTest.php
@@ -1,19 +1,8 @@
 <?php
 
 /**
- * Copyright 2021-2022 Anthon Pang. All Rights Reserved.
- *
- * Licensed 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.
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
  *
  * @package WebDriver
  *
diff --git a/test/Test/WebDriver/ElementTest.php b/test/Test/WebDriver/ElementTest.php
new file mode 100644
index 00000000..f6d71a52
--- /dev/null
+++ b/test/Test/WebDriver/ElementTest.php
@@ -0,0 +1,75 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace Test\WebDriver;
+
+use PHPUnit\Framework\TestCase;
+use WebDriver\Element;
+
+/**
+ * Test WebDriver\Element class
+ *
+ * @package WebDriver
+ *
+ * @group Unit
+ */
+class ElementTest extends TestCase
+{
+    /**
+     * test getID
+     */
+    public function testGetID()
+    {
+        $element = new Element('http://example.com/session/:sessionId/element', 'element-1234567890');
+
+        $this->assertEquals('element-1234567890', $element->getID());
+    }
+
+    /**
+     * test getNewIdentifierPath
+     */
+    public function testGetNewIdentifierPath()
+    {
+        $element = new Element('http://example.com/session/:sessionId/element', 'element-1234567890');
+
+        $method = $this->makeCallable($element, 'getNewIdentifierPath');
+
+        $this->assertEquals('http://example.com/session/:sessionId/element/element-9876543210', $method->invoke($element, 'element-9876543210'));
+    }
+
+    /**
+     * test getSessionPath
+     */
+    public function testGetSessionPath()
+    {
+        $element = new Element('http://example.com/session/:sessionId/element', 'element-1234567890');
+
+        $method = $this->makeCallable($element, 'getSessionPath');
+
+        $this->assertEquals('http://example.com/session/:sessionId', $method->invoke($element));
+    }
+
+    /**
+     * Make private and protected function callable
+     *
+     * @param mixed  $object   Subject under test
+     * @param string $function Function name
+     *
+     * @return \ReflectionMethod
+     */
+    private function makeCallable($object, $function)
+    {
+        $method = new \ReflectionMethod($object, $function);
+        $method->setAccessible(true);
+
+        return $method;
+    }
+}
diff --git a/test/Test/WebDriver/ExceptionTest.php b/test/Test/WebDriver/ExceptionTest.php
index e9e19b6a..d5e0bc71 100644
--- a/test/Test/WebDriver/ExceptionTest.php
+++ b/test/Test/WebDriver/ExceptionTest.php
@@ -1,19 +1,8 @@
 <?php
 
 /**
- * Copyright 2011-2022 Anthon Pang. All Rights Reserved.
- *
- * Licensed 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.
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
  *
  * @package WebDriver
  *
@@ -43,15 +32,15 @@ public function testFactory()
         $this->assertTrue($out instanceof Exception\UnknownError);
         $this->assertTrue($out->getMessage() === 'wtf');
 
-        $out = Exception::factory(Exception::SUCCESS);
+        $out = Exception::factory(0);
         $this->assertTrue($out instanceof Exception\UnknownError);
-        $this->assertTrue($out->getMessage() === 'An unknown server-side error occurred while processing the command.');
+        $this->assertTrue($out->getMessage() === 'An unknown error occurred in the remote end while processing the command.');
 
         $out = Exception::factory(Exception::SESSION_NOT_CREATED);
         $this->assertTrue($out instanceof Exception\SessionNotCreated);
         $this->assertTrue(strpos($out->getMessage(), 'A new session could not be created') !== false);
 
-        $out = Exception::factory(Exception::CURL_EXEC);
+        $out = Exception::factory(Exception::_CURL_EXEC);
         $this->assertTrue($out instanceof Exception\CurlExec);
         $this->assertTrue($out->getMessage() === 'curl_exec() error.');
     }
diff --git a/test/Test/WebDriver/GeckoDriverTest.php b/test/Test/WebDriver/GeckoDriverTest.php
index 92cef56c..9408b1ba 100644
--- a/test/Test/WebDriver/GeckoDriverTest.php
+++ b/test/Test/WebDriver/GeckoDriverTest.php
@@ -1,19 +1,8 @@
 <?php
 
 /**
- * Copyright 2021-2022 Anthon Pang. All Rights Reserved.
- *
- * Licensed 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.
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
  *
  * @package WebDriver
  *
diff --git a/test/Test/WebDriver/SeleniumWebDriverTest.php b/test/Test/WebDriver/SeleniumWebDriverTest.php
index 29fe784c..405e49fc 100644
--- a/test/Test/WebDriver/SeleniumWebDriverTest.php
+++ b/test/Test/WebDriver/SeleniumWebDriverTest.php
@@ -1,19 +1,8 @@
 <?php
 
 /**
- * Copyright 2021-2022 Anthon Pang. All Rights Reserved.
- *
- * Licensed 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.
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
  *
  * @package WebDriver
  *
diff --git a/test/Test/WebDriver/SessionTest.php b/test/Test/WebDriver/SessionTest.php
new file mode 100644
index 00000000..8bf5ac6d
--- /dev/null
+++ b/test/Test/WebDriver/SessionTest.php
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace Test\WebDriver;
+
+use PHPUnit\Framework\TestCase;
+use WebDriver\Session;
+
+/**
+ * Test WebDriver\Session class
+ *
+ * @package WebDriver
+ *
+ * @group Unit
+ */
+class SessionTest extends TestCase
+{
+    /**
+     * test getNewIdentifierPath
+     */
+    public function testGetNewIdentifierPath()
+    {
+        $session = new Session('http://example.com/session/session-1234567890', []);
+
+        $method = $this->makeCallable($session, 'getNewIdentifierPath');
+
+        $this->assertEquals('http://example.com/session/session-1234567890/element/element-9876543210', $method->invoke($session, 'element-9876543210'));
+    }
+
+    /**
+     * Make private and protected function callable
+     *
+     * @param mixed  $object   Subject under test
+     * @param string $function Function name
+     *
+     * @return \ReflectionMethod
+     */
+    private function makeCallable($object, $function)
+    {
+        $method = new \ReflectionMethod($object, $function);
+        $method->setAccessible(true);
+
+        return $method;
+    }
+}
diff --git a/test/Test/WebDriver/ShadowTest.php b/test/Test/WebDriver/ShadowTest.php
new file mode 100644
index 00000000..0a420eb7
--- /dev/null
+++ b/test/Test/WebDriver/ShadowTest.php
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace Test\WebDriver;
+
+use PHPUnit\Framework\TestCase;
+use WebDriver\Shadow;
+
+/**
+ * Test WebDriver\Shadow class
+ *
+ * @package WebDriver
+ *
+ * @group Unit
+ */
+class ShadowTest extends TestCase
+{
+    /**
+     * test getID
+     */
+    public function testGetID()
+    {
+        $shadow = new Shadow('http://example.com/session/:sessionId/shadow', 'shadow-1234567890');
+
+        $this->assertEquals('shadow-1234567890', $shadow->getID());
+    }
+
+    /**
+     * test getNewIdentifierPath
+     */
+    public function testGetNewIdentifierPath()
+    {
+        $shadow = new Shadow('http://example.com/session/:sessionId/shadow', 'shadow-1234567890');
+
+        $method = $this->makeCallable($shadow, 'getNewIdentifierPath');
+
+        $this->assertEquals('http://example.com/session/:sessionId/element/element-9876543210', $method->invoke($shadow, 'element-9876543210'));
+    }
+
+    /**
+     * Make private and protected function callable
+     *
+     * @param mixed  $object   Subject under test
+     * @param string $function Function name
+     *
+     * @return \ReflectionMethod
+     */
+    private function makeCallable($object, $function)
+    {
+        $method = new \ReflectionMethod($object, $function);
+        $method->setAccessible(true);
+
+        return $method;
+    }
+}
diff --git a/test/Test/WebDriver/WebDriverTestBase.php b/test/Test/WebDriver/WebDriverTestBase.php
index 1d7b9b96..3cebf1a5 100644
--- a/test/Test/WebDriver/WebDriverTestBase.php
+++ b/test/Test/WebDriver/WebDriverTestBase.php
@@ -1,24 +1,12 @@
 <?php
 
 /**
- * Copyright 2014-2022 Anthon Pang. All Rights Reserved.
- *
- * Licensed 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.
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
  *
  * @package WebDriver
  *
  * @author Anthon Pang <apang@softwaredevelopment.ca>
- * @author Damian Mooyman <damian@silverstripe.com>
  */
 
 namespace Test\WebDriver;
diff --git a/test/Assets/index.html b/test/assets/index.html
similarity index 100%
rename from test/Assets/index.html
rename to test/assets/index.html

From 9c7d9bedff98f647b66137f60ff1e6c07904c21b Mon Sep 17 00:00:00 2001
From: Anthon Pang <apang@softwaredevelopment.ca>
Date: Sat, 9 Dec 2023 14:52:18 -0500
Subject: [PATCH 2/7] Add phpdoc link for CDP

---
 lib/WebDriver/Extension/ChromeDevTools.php | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/lib/WebDriver/Extension/ChromeDevTools.php b/lib/WebDriver/Extension/ChromeDevTools.php
index 9d8b523b..e76e211d 100644
--- a/lib/WebDriver/Extension/ChromeDevTools.php
+++ b/lib/WebDriver/Extension/ChromeDevTools.php
@@ -16,6 +16,8 @@
 /**
  * Chrome Dev Tools extension
  *
+ * @see https://chromedevtools.github.io/devtools-protocol/
+ *
  * @package WebDriver
  */
 class ChromeDevTools extends AbstractWebDriver

From 505dc872415e44a4346c2d5dc1ffaa93592d3020 Mon Sep 17 00:00:00 2001
From: Anthon Pang <apang@softwaredevelopment.ca>
Date: Sat, 9 Dec 2023 14:36:46 -0500
Subject: [PATCH 3/7] dispatch before/after events for web driver commands

---
 composer.json                                 |  2 +-
 lib/WebDriver/AbstractWebDriver.php           | 70 +++++++++++++++++--
 lib/WebDriver/Alert.php                       |  8 +--
 lib/WebDriver/Container.php                   |  4 +-
 lib/WebDriver/Element.php                     | 14 ++--
 lib/WebDriver/Execute.php                     |  4 +-
 lib/WebDriver/Extension/ChromeDevTools.php    |  2 +-
 .../FederatedCredentialManagementAPI.php      | 16 ++---
 lib/WebDriver/Extension/Selenium.php          | 12 ++--
 .../VirtualAuthenticator.php                  |  4 +-
 lib/WebDriver/Service/VoidDispatcher.php      | 32 +++++++++
 lib/WebDriver/ServiceFactory.php              |  4 +-
 lib/WebDriver/Session.php                     | 46 ++++++------
 lib/WebDriver/Timeouts.php                    |  4 +-
 lib/WebDriver/WebDriver.php                   |  8 +--
 lib/WebDriver/Window.php                      |  4 +-
 16 files changed, 165 insertions(+), 69 deletions(-)
 create mode 100644 lib/WebDriver/Service/VoidDispatcher.php

diff --git a/composer.json b/composer.json
index ff6d2a8e..3f5a6c47 100644
--- a/composer.json
+++ b/composer.json
@@ -26,10 +26,10 @@
     ],
     "require": {
         "php": ">=7.2",
+        "psr/event-dispatcher": "^1.0.0",
         "ext-curl": "*"
     },
     "require-dev": {
-        "php": ">=7.2",
         "phpunit/phpunit": "^8.5 || ^9.5"
     },
     "minimum-stability": "dev",
diff --git a/lib/WebDriver/AbstractWebDriver.php b/lib/WebDriver/AbstractWebDriver.php
index cd4910de..3d301ec4 100644
--- a/lib/WebDriver/AbstractWebDriver.php
+++ b/lib/WebDriver/AbstractWebDriver.php
@@ -11,7 +11,9 @@
 
 namespace WebDriver;
 
+use Psr\EventDispatcher\EventDispatcherInterface;
 use WebDriver\Exception as WebDriverException;
+use WebDriver\Service\CurlServiceInterface;
 
 /**
  * Abstract WebDriver\AbstractWebDriver class
@@ -34,6 +36,13 @@ abstract class AbstractWebDriver
      */
     private $curlService;
 
+    /**
+     * Event Dispatcher
+     *
+     * @var \Psr\EventDispatcher\EventDispatcherInterface
+     */
+    private $eventDispatcher;
+
     /**
      * Transient options
      *
@@ -77,6 +86,7 @@ public function __construct($url)
         $this->transientOptions = [];
         $this->extensions = [];
         $this->curlService = ServiceFactory::getInstance()->getService('service.curl');
+        $this->eventDispatcher = ServiceFactory::getInstance()->getService('event_dispatcher');
     }
 
     /**
@@ -104,7 +114,7 @@ public function getURL()
      *
      * @param \WebDriver\Service\CurlServiceInterface $curlService
      */
-    public function setCurlService($curlService)
+    public function setCurlService(CurlServiceInterface $curlService)
     {
         $this->curlService = $curlService;
     }
@@ -119,6 +129,26 @@ public function getCurlService()
         return $this->curlService;
     }
 
+    /**
+     * Set event dispatcher
+     *
+     * @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher
+     */
+    public function setEventDispatcher(EventDispatcherInterface $eventDispatcher)
+    {
+        $this->eventDispatcher = $eventDispatcher;
+    }
+
+    /**
+     * Get curl service
+     *
+     * @return \Psr\EventDispatcher\EventDispatcherInterface
+     */
+    public function getEventDispatcher()
+    {
+        return $this->eventDispatcher;
+    }
+
     /**
      * Set transient options
      *
@@ -204,7 +234,7 @@ public function __call($name, $arguments)
         }
 
         $parameters = array_shift($arguments);
-        $result = $this->curl($requestMethod, '/' . $webdriverCommand, $parameters);
+        $result = $this->curl($requestMethod, $webdriverCommand, $parameters);
 
         return $result['value'];
     }
@@ -225,6 +255,32 @@ public function __get($name)
         trigger_error('Undefined property: ' . __CLASS__ . '::$' . $name, E_USER_WARNING);
     }
 
+    /**
+     * Event firing command wrapper
+     */
+    protected function curl($requestMethod, $command, $parameters = null, $extraOptions = [])
+    {
+        $name = strtoupper($requestMethod) . ':'
+              . substr($className = get_class($this), strrpos($className, '\\') + 1) . ':'
+              . ($command ?: substr($this->url, strrpos($this->url, '/') + 1));
+
+        $this->eventDispatcher->dispatch((object) ['event' => 'before', 'command' => $name, 'parameters' => $parameters]);
+
+        try {
+            $result = $this->curlExec($requestMethod, $command, $parameters, $extraOptions);
+        } catch (\Exception $e) {
+            $source = substr($className = get_class($e), strrpos($className, '\\') + 1);
+
+            $this->eventDispatcher->dispatch((object) ['event' => 'error', 'command' => $name, 'error' => $source]);
+
+            throw $e;
+        }
+
+        $this->eventDispatcher->dispatch((object) ['event' => 'after', 'command' => $name, 'result' => $result['value']]);
+
+        return $result;
+    }
+
     /**
      * Curl request to webdriver server.
      *
@@ -238,7 +294,7 @@ public function __get($name)
      *
      * @throws \WebDriver\Exception if error
      */
-    protected function curl($requestMethod, $command, $parameters = null, $extraOptions = [])
+    protected function curlExec($requestMethod, $command, $parameters, $extraOptions)
     {
         if ($parameters && is_array($parameters) && $requestMethod !== 'POST') {
             throw WebDriverException::factory(
@@ -252,7 +308,13 @@ protected function curl($requestMethod, $command, $parameters = null, $extraOpti
             );
         }
 
-        $url = $this->url . $command;
+        $url = $this->url;
+
+        if ($command && substr($this->url, -1) !== '/') {
+            $url .= '/';
+        }
+
+        $url .= $command;
 
         if (($requestMethod === 'GET' || $requestMethod === 'DELETE')
             && $parameters && (is_int($parameters) || is_string($parameters))
diff --git a/lib/WebDriver/Alert.php b/lib/WebDriver/Alert.php
index 2989483e..fa96f9ca 100644
--- a/lib/WebDriver/Alert.php
+++ b/lib/WebDriver/Alert.php
@@ -59,7 +59,7 @@ protected function aliases()
      */
     public function acceptAlert()
     {
-        $result = $this->curl('POST', '/accept');
+        $result = $this->curl('POST', 'accept');
 
         return $result['value'];
     }
@@ -71,7 +71,7 @@ public function acceptAlert()
      */
     public function dismissAlert()
     {
-        $result = $this->curl('POST', '/dismiss');
+        $result = $this->curl('POST', 'dismiss');
 
         return $result['value'];
     }
@@ -83,7 +83,7 @@ public function dismissAlert()
      */
     public function getAlertText()
     {
-        $result = $this->curl('GET', '/text');
+        $result = $this->curl('GET', 'text');
 
         return $result['value'];
     }
@@ -101,7 +101,7 @@ public function setAlertValue($text)
             ? $text
             : ['text' => $text];
 
-        $result = $this->curl('POST', '/text', $parameters);
+        $result = $this->curl('POST', 'text', $parameters);
 
         return $result['value'];
     }
diff --git a/lib/WebDriver/Container.php b/lib/WebDriver/Container.php
index 4f11c292..ac110d83 100644
--- a/lib/WebDriver/Container.php
+++ b/lib/WebDriver/Container.php
@@ -54,7 +54,7 @@ public function findElement($using = null, $value = null)
         $parameters = $this->parseArgs('element', func_get_args());
 
         try {
-            $result = $this->curl('POST', '/element', $parameters);
+            $result = $this->curl('POST', 'element', $parameters);
         } catch (WebDriverException\NoSuchElement $e) {
             throw WebDriverException::factory(
                 WebDriverException::NO_SUCH_ELEMENT,
@@ -100,7 +100,7 @@ public function findElements($using = null, $value = null)
     {
         $parameters = $this->parseArgs('elements', func_get_args());
 
-        $result = $this->curl('POST', '/elements', $parameters);
+        $result = $this->curl('POST', 'elements', $parameters);
 
         if (! is_array($result['value'])) {
             return [];
diff --git a/lib/WebDriver/Element.php b/lib/WebDriver/Element.php
index 4706dcb2..c069c488 100644
--- a/lib/WebDriver/Element.php
+++ b/lib/WebDriver/Element.php
@@ -126,7 +126,7 @@ public function getID()
      */
     public function getAccessibleName()
     {
-        $result = $this->curl('GET', '/computedlabel');
+        $result = $this->curl('GET', 'computedlabel');
 
         return $result['value'];
     }
@@ -138,7 +138,7 @@ public function getAccessibleName()
      */
     public function getAriaRole()
     {
-        $result = $this->curl('GET', '/computedrole');
+        $result = $this->curl('GET', 'computedrole');
 
         return $result['value'];
     }
@@ -155,7 +155,7 @@ public function getAriaRole()
      */
     public function getShadowRoot()
     {
-        $result = $this->curl('POST', '/shadow');
+        $result = $this->curl('POST', 'shadow');
         $value  = $result['value'];
 
         if (! is_array($value)) {
@@ -178,7 +178,7 @@ public function getShadowRoot()
      */
     public function isEnabled()
     {
-        $result = $this->curl('GET', '/enabled');
+        $result = $this->curl('GET', 'enabled');
 
         return $result['value'];
     }
@@ -190,7 +190,7 @@ public function isEnabled()
      */
     public function isSelected()
     {
-        $result = $this->curl('GET', '/selected');
+        $result = $this->curl('GET', 'selected');
 
         return $result['value'];
     }
@@ -218,7 +218,7 @@ public function sendKeys($text)
             $parameters['value'] = [$parameters['text']];
         }
 
-        $result = $this->curl('POST', '/value', $parameters);
+        $result = $this->curl('POST', 'value', $parameters);
 
         return $result['value'];
     }
@@ -235,7 +235,7 @@ public function submit()
         // trigger_error(__METHOD__, E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/submit');
+            $result = $this->curl('POST', 'submit');
 
             return $result['value'];
         } catch (\Exception $e) {
diff --git a/lib/WebDriver/Execute.php b/lib/WebDriver/Execute.php
index ccd1bbe8..5d9f241e 100644
--- a/lib/WebDriver/Execute.php
+++ b/lib/WebDriver/Execute.php
@@ -34,7 +34,7 @@ public function async($script, $args = null)
 
         $parameters['args'] = $this->serializeArguments($parameters['args']);
 
-        $result = $this->curl('POST', '/execute/async', $parameters);
+        $result = $this->curl('POST', 'execute/async', $parameters);
 
         return $this->unserializeResult($result['value']);
     }
@@ -55,7 +55,7 @@ public function sync($script, $args = null)
 
         $parameters['args'] = $this->serializeArguments($parameters['args']);
 
-        $result = $this->curl('POST', '/execute/sync', $parameters);
+        $result = $this->curl('POST', 'execute/sync', $parameters);
 
         return $this->unserializeResult($result['value']);
     }
diff --git a/lib/WebDriver/Extension/ChromeDevTools.php b/lib/WebDriver/Extension/ChromeDevTools.php
index e76e211d..e0998c59 100644
--- a/lib/WebDriver/Extension/ChromeDevTools.php
+++ b/lib/WebDriver/Extension/ChromeDevTools.php
@@ -36,7 +36,7 @@ public function execute($cmd, $params = null)
             ? $cmd
             : ['cmd' => $cmd, 'params' => $params ?? new \stdclass];
 
-        $result = $this->curl('POST', '/execute', $parameters);
+        $result = $this->curl('POST', 'execute', $parameters);
 
         return $result['value'];
     }
diff --git a/lib/WebDriver/Extension/FederatedCredentialManagementAPI.php b/lib/WebDriver/Extension/FederatedCredentialManagementAPI.php
index eb78f741..8cbd8a0c 100644
--- a/lib/WebDriver/Extension/FederatedCredentialManagementAPI.php
+++ b/lib/WebDriver/Extension/FederatedCredentialManagementAPI.php
@@ -53,7 +53,7 @@ protected function methods()
      */
     public function cancelDialog()
     {
-        $result = $this->curl('POST', '/canceldialog');
+        $result = $this->curl('POST', 'canceldialog');
 
         return $result['value'];
     }
@@ -71,7 +71,7 @@ public function selectAccount($parameters)
             $parameters = ['accountIndex' => $parameters];
         }
 
-        $result = $this->curl('POST', '/selectaccount', $parameters);
+        $result = $this->curl('POST', 'selectaccount', $parameters);
 
         return $result['value'];
     }
@@ -85,7 +85,7 @@ public function selectAccount($parameters)
      */
     public function clickDialogButton($parameters)
     {
-        $result = $this->curl('POST', '/clickdialogbutton', $parameters);
+        $result = $this->curl('POST', 'clickdialogbutton', $parameters);
 
         return $result['value'];
     }
@@ -97,7 +97,7 @@ public function clickDialogButton($parameters)
      */
     public function getAccounts()
     {
-        $result = $this->curl('GET', '/accountlist');
+        $result = $this->curl('GET', 'accountlist');
 
         return $result['value'];
     }
@@ -109,7 +109,7 @@ public function getAccounts()
      */
     public function getTitle()
     {
-        $result = $this->curl('GET', '/gettitle');
+        $result = $this->curl('GET', 'gettitle');
 
         return $result['value'];
     }
@@ -121,7 +121,7 @@ public function getTitle()
      */
     public function getDialogType()
     {
-        $result = $this->curl('GET', '/getdialogtype');
+        $result = $this->curl('GET', 'getdialogtype');
 
         return $result['value'];
     }
@@ -139,7 +139,7 @@ public function setDelayEnabled($parameters)
             $parameters = ['enabled' => $parameters];
         }
 
-        $result = $this->curl('POST', '/setdelayenabled', $parameters);
+        $result = $this->curl('POST', 'setdelayenabled', $parameters);
 
         return $result['value'];
     }
@@ -151,7 +151,7 @@ public function setDelayEnabled($parameters)
      */
     public function resetCooldown()
     {
-        $result = $this->curl('POST', '/resetCooldown');
+        $result = $this->curl('POST', 'resetCooldown');
 
         return $result['value'];
     }
diff --git a/lib/WebDriver/Extension/Selenium.php b/lib/WebDriver/Extension/Selenium.php
index b4da88cd..cbb367fc 100644
--- a/lib/WebDriver/Extension/Selenium.php
+++ b/lib/WebDriver/Extension/Selenium.php
@@ -39,7 +39,7 @@ public function getLog($parameters)
             ];
         }
 
-        $result = $this->curl('POST', '/log', $parameters);
+        $result = $this->curl('POST', 'log', $parameters);
 
         return $result['value'];
     }
@@ -51,7 +51,7 @@ public function getLog($parameters)
      */
     public function getAvailableLogTypes()
     {
-        $result = $this->curl('GET', '/log/types');
+        $result = $this->curl('GET', 'log/types');
 
         return $result['value'];
     }
@@ -69,7 +69,7 @@ public function downloadFile($parameters)
             $parameters = ['name' => $parameters];
         }
 
-        $result = $this->curl('POST', '/se/files', $parameters);
+        $result = $this->curl('POST', 'se/files', $parameters);
 
         return $result['value'];
     }
@@ -81,7 +81,7 @@ public function downloadFile($parameters)
      */
     public function getDownloadableFiles()
     {
-        $result = $this->curl('GET', '/se/files');
+        $result = $this->curl('GET', 'se/files');
 
         return $result['value'];
     }
@@ -93,7 +93,7 @@ public function getDownloadableFiles()
      */
     public function deleteDownloadableFiles()
     {
-        $result = $this->curl('DELETE', '/se/files');
+        $result = $this->curl('DELETE', 'se/files');
 
         return $result['value'];
     }
@@ -119,7 +119,7 @@ public function uploadFile($parameters)
             trigger_error('UPLOAD_FILE expected base64 encoded ZIP file', E_USER_NOTICE);
         }
 
-        $result = $this->curl('POST', '/se/file', $parameters);
+        $result = $this->curl('POST', 'se/file', $parameters);
 
         return $result['value'];
     }
diff --git a/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php b/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php
index 74225b6f..78ad28be 100644
--- a/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php
+++ b/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php
@@ -78,7 +78,7 @@ public function addCredential($parameters)
     {
         $credentialId = $parameters['credentialId'];
 
-        $this->curl('POST', '/credential', $parameters);
+        $this->curl('POST', 'credential', $parameters);
 
         return new Credential($this->url . '/credentials', $credentialId);
     }
@@ -96,7 +96,7 @@ public function setUserVerified($parameters)
             $parameters = ['isUserVerified' => $parameters];
         }
 
-        $result = $this->curl('POST', '/uv', $parameters);
+        $result = $this->curl('POST', 'uv', $parameters);
 
         return $result['value'];
     }
diff --git a/lib/WebDriver/Service/VoidDispatcher.php b/lib/WebDriver/Service/VoidDispatcher.php
new file mode 100644
index 00000000..dbd5b543
--- /dev/null
+++ b/lib/WebDriver/Service/VoidDispatcher.php
@@ -0,0 +1,32 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace WebDriver\Service;
+
+use Psr\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * WebDriver\Service\VoidDispatcher class
+ *
+ * @internal A black hole for events.
+ *
+ * @package WebDriver
+ */
+class VoidDispatcher implements EventDispatcherInterface
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function dispatch(object $event)
+    {
+        return (object) [];
+    }
+}
diff --git a/lib/WebDriver/ServiceFactory.php b/lib/WebDriver/ServiceFactory.php
index 3842bbcd..acc5a8b9 100755
--- a/lib/WebDriver/ServiceFactory.php
+++ b/lib/WebDriver/ServiceFactory.php
@@ -12,6 +12,7 @@
 namespace WebDriver;
 
 use WebDriver\Service\CurlService;
+use WebDriver\Service\VoidDispatcher;
 
 /**
  * WebDriver\ServiceFactory class
@@ -48,6 +49,7 @@ private function __construct()
 
         $this->serviceClasses = [
             'service.curl' => CurlService::class,
+            'event_dispatcher' => VoidDispatcher::class,
         ];
     }
 
@@ -74,7 +76,7 @@ public static function getInstance()
      */
     public function getService($serviceName)
     {
-        if (! isset($this->services[$serviceName])) {
+        if (! isset($this->services[$serviceName]) && isset($this->serviceClasses[$serviceName])) {
             $className = $this->serviceClasses[$serviceName];
 
             $this->services[$serviceName] = new $className();
diff --git a/lib/WebDriver/Session.php b/lib/WebDriver/Session.php
index 766dad75..81dc49b7 100644
--- a/lib/WebDriver/Session.php
+++ b/lib/WebDriver/Session.php
@@ -177,7 +177,7 @@ public function getCapabilities()
      */
     public function open($url)
     {
-        $this->curl('POST', '/url', ['url' => $url]);
+        $this->curl('POST', 'url', ['url' => $url]);
 
         return $this;
     }
@@ -208,7 +208,7 @@ public function close()
      */
     public function getAllCookies()
     {
-        $result = $this->curl('GET', '/cookie');
+        $result = $this->curl('GET', 'cookie');
 
         return $result['value'];
     }
@@ -227,7 +227,7 @@ public function setCookie($cookieData)
             ? $cookieData
             : ['cookie' => $cookieData];
 
-        $this->curl('POST', '/cookie', $parameters);
+        $this->curl('POST', 'cookie', $parameters);
 
         return $this;
     }
@@ -239,7 +239,7 @@ public function setCookie($cookieData)
      */
     public function deleteAllCookies()
     {
-        $this->curl('DELETE', '/cookie');
+        $this->curl('DELETE', 'cookie');
 
         return $this;
     }
@@ -253,7 +253,7 @@ public function deleteAllCookies()
      */
     public function deleteCookie($cookieName)
     {
-        $this->curl('DELETE', '/cookie/' . $cookieName);
+        $this->curl('DELETE', 'cookie/' . $cookieName);
 
         return $this;
     }
@@ -268,7 +268,7 @@ public function deleteCookie($cookieName)
      */
     public function getWindowHandle()
     {
-        $result = $this->curl('GET', '/window');
+        $result = $this->curl('GET', 'window');
 
         return $result['value'];
     }
@@ -281,7 +281,7 @@ public function getWindowHandle()
      */
     public function getWindowHandles()
     {
-        $result = $this->curl('GET', '/window/handles');
+        $result = $this->curl('GET', 'window/handles');
 
         return $result['value'];
     }
@@ -302,7 +302,7 @@ public function newWindow($type)
             ? $type
             : ['type' => $type];
 
-        $result = $this->curl('POST', '/window/new', $parameters);
+        $result = $this->curl('POST', 'window/new', $parameters);
 
         return $result['value'];
     }
@@ -335,7 +335,7 @@ public function window()
      */
     public function deleteWindow()
     {
-        $this->curl('DELETE', '/window');
+        $this->curl('DELETE', 'window');
 
         return $this;
     }
@@ -353,7 +353,7 @@ public function switchToWindow($handle)
             ? $handle
             : ['handle' => $handle, 'name' => $handle];
 
-        $this->curl('POST', '/window', $parameters);
+        $this->curl('POST', 'window', $parameters);
 
         return $this;
     }
@@ -374,7 +374,7 @@ public function frame($id = null)
                 ? $id
                 : ['id' => $id];
 
-            $this->curl('POST', '/frame', $parameters);
+            $this->curl('POST', 'frame', $parameters);
 
             return $this;
         }
@@ -433,7 +433,7 @@ public function setTimeout()
      */
     public function getActiveElement()
     {
-        $result = $this->curl('POST', '/element/active');
+        $result = $this->curl('POST', 'element/active');
 
         return $this->makeElement($result['value']);
     }
@@ -538,7 +538,7 @@ public function moveto($parameters)
         // trigger_error(__METHOD__ . ': use actions() API instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/moveto', $parameters);
+            $result = $this->curl('POST', 'moveto', $parameters);
         } catch (\Exception $e) {
             if (! array_key_exists('element', $parameters)) {
                 $actionItem = [
@@ -579,7 +579,7 @@ public function click($parameters)
         // trigger_error(__METHOD__ . ': use actions() API instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/click', $parameters);
+            $result = $this->curl('POST', 'click', $parameters);
         } catch (\Exception $e) {
             $mouse = $this->actions()->getPointerInput(0, PointerInput::MOUSE);
 
@@ -610,7 +610,7 @@ public function doubleclick($parameters)
         // trigger_error(__METHOD__ . ': use actions() API instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/doubleclick', $parameters);
+            $result = $this->curl('POST', 'doubleclick', $parameters);
         } catch (\Exception $e) {
             $mouse = $this->actions()->getPointerInput(0, PointerInput::MOUSE);
 
@@ -647,7 +647,7 @@ public function buttondown($parameters)
         // trigger_error(__METHOD__ . ': use actions() API instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/buttondown', $parameters);
+            $result = $this->curl('POST', 'buttondown', $parameters);
         } catch (\Exception $e) {
             $mouse = $this->actions()->getPointerInput(0, PointerInput::MOUSE);
 
@@ -675,7 +675,7 @@ public function buttonup($parameters)
         // trigger_error(__METHOD__ . ': use actions() API instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/buttonup', $parameters);
+            $result = $this->curl('POST', 'buttonup', $parameters);
         } catch (\Exception $e) {
             $mouse = $this->actions()->getPointerInput(0, PointerInput::MOUSE);
 
@@ -701,7 +701,7 @@ public function getAlertText()
         // trigger_error(__METHOD__ . ': use "Alert::getAlertText()" instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('GET', '/alert_text');
+            $result = $this->curl('GET', 'alert_text');
         } catch (\Exception $e) {
             $result = $this->alert()->getAlertText();
         }
@@ -727,7 +727,7 @@ public function postAlertText($text)
         // trigger_error(__METHOD__ . ': use "Alert::setAlertText()" instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/alert_text', $parameters);
+            $result = $this->curl('POST', 'alert_text', $parameters);
         } catch (\Exception $e) {
             $result = $this->alert()->setAlertText();
         }
@@ -747,7 +747,7 @@ public function acceptAlert()
         // trigger_error(__METHOD__ . ': use "Alert::acceptAlert()" instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/accept_alert');
+            $result = $this->curl('POST', 'accept_alert');
         } catch (\Exception $e) {
             $result = $this->alert()->acceptAlert();
         }
@@ -767,7 +767,7 @@ public function dismissAlert()
         // trigger_error(__METHOD__ . ': use "Alert::dismissAlert()" instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/dismiss_alert');
+            $result = $this->curl('POST', 'dismiss_alert');
         } catch (\Exception $e) {
             $result = $this->alert()->dismissAlert();
         }
@@ -793,7 +793,7 @@ public function keys($value)
         // trigger_error(__METHOD__ . ': use "Element::sendKeys()" instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/keys', $parameters);
+            $result = $this->curl('POST', 'keys', $parameters);
         } catch (\Exception $e) {
             $result = $this->getActiveElement()->sendKeys($parameters);
         }
@@ -819,7 +819,7 @@ public function file($file)
         // trigger_error(__METHOD__ . ': use "Selenium::uploadFile()" instead', E_USER_DEPRECATED);
 
         try {
-            $result = $this->curl('POST', '/file', $parameters);
+            $result = $this->curl('POST', 'file', $parameters);
         } catch (\Exception $e) {
             $result = $this->selenium()->uploadFile($parameters);
         }
diff --git a/lib/WebDriver/Timeouts.php b/lib/WebDriver/Timeouts.php
index 4f105ae6..0aec92cd 100644
--- a/lib/WebDriver/Timeouts.php
+++ b/lib/WebDriver/Timeouts.php
@@ -82,7 +82,7 @@ public function implicitlyWait($ms)
 
         // trigger_error(__METHOD__ . ': use "setTimeout()" instead', E_USER_DEPRECATED);
 
-        $result = $this->curl('POST', '/implicit_wait', $parameters);
+        $result = $this->curl('POST', 'implicit_wait', $parameters);
 
         return $this;
     }
@@ -104,7 +104,7 @@ public function setScriptTimeout($ms)
 
         // trigger_error(__METHOD__ . ': use "setTimeout()" instead', E_USER_DEPRECATED);
 
-        $result = $this->curl('POST', '/async_script', $parameters);
+        $result = $this->curl('POST', 'async_script', $parameters);
 
         return $this;
     }
diff --git a/lib/WebDriver/WebDriver.php b/lib/WebDriver/WebDriver.php
index ce0c074e..c52c10b2 100644
--- a/lib/WebDriver/WebDriver.php
+++ b/lib/WebDriver/WebDriver.php
@@ -86,7 +86,7 @@ public function newSession($browserName = Browser::CHROME, $desiredCapabilities
         }
 
         $options = [CURLOPT_FOLLOWLOCATION => true];
-        $result = $this->curl('POST', '/session', $parameters, $options);
+        $result = $this->curl('POST', 'session', $parameters, $options);
 
         $this->capabilities = $result['value']['capabilities'] ?? $result['value']['capabilities'];
 
@@ -106,7 +106,7 @@ public function newSession($browserName = Browser::CHROME, $desiredCapabilities
      */
     public function getAllSessions()
     {
-        $result = $this->curl('GET', '/sessions');
+        $result = $this->curl('GET', 'sessions');
         $sessions = [];
 
         foreach ($result['value'] as $session) {
@@ -127,7 +127,7 @@ public function getAllSessions()
      */
     public function getSessionLogs()
     {
-        $result = $this->curl('POST', '/logs');
+        $result = $this->curl('POST', 'logs');
 
         return $result['value'];
     }
@@ -139,7 +139,7 @@ public function getSessionLogs()
      */
     public function getStatus()
     {
-        $result = $this->curl('GET', '/status');
+        $result = $this->curl('GET', 'status');
 
         return $result['value'];
     }
diff --git a/lib/WebDriver/Window.php b/lib/WebDriver/Window.php
index 32cd7f7e..81304140 100644
--- a/lib/WebDriver/Window.php
+++ b/lib/WebDriver/Window.php
@@ -91,7 +91,7 @@ public function __construct($url, $windowHandle = null)
      */
     public function setRect($parameters)
     {
-        $result = $this->curl('POST', '/rect', $parameters);
+        $result = $this->curl('POST', 'rect', $parameters);
 
         return $result['value'];
     }
@@ -106,7 +106,7 @@ public function setRect($parameters)
     public function getHandle()
     {
         if (! $this->windowHandle) {
-            $result = $this->curl('GET', $this->url);
+            $result = $this->curl('GET', '');
 
             $this->windowHandle = array_key_exists(self::WEB_WINDOW_ID, $result['value'])
                 ? $result['value'][self::WEB_WINDOW_ID]

From d9c720d2eb3e6974fc38f9f13b0a8549c701d41c Mon Sep 17 00:00:00 2001
From: Anthon Pang <apang@softwaredevelopment.ca>
Date: Sun, 10 Dec 2023 23:42:21 -0500
Subject: [PATCH 4/7] refactor curl options handling

---
 README.md                                     |   7 +
 lib/WebDriver/AbstractWebDriver.php           | 190 ++++++++++--------
 lib/WebDriver/Actions.php                     |  35 +---
 lib/WebDriver/Capability.php                  |  11 +-
 lib/WebDriver/Container.php                   |   6 +-
 lib/WebDriver/Element.php                     |  14 +-
 lib/WebDriver/Execute.php                     |   4 +-
 lib/WebDriver/Extension/ChromeDevTools.php    |   4 +-
 .../Extension/WebAuthenticationAPI.php        |   2 +-
 .../WebAuthenticationAPI/Credential.php       |  10 +-
 .../VirtualAuthenticator.php                  |  14 +-
 lib/WebDriver/Service/CurlService.php         |  57 ++++--
 .../Service/CurlServiceInterface.php          |  13 +-
 lib/WebDriver/Service/VoidDispatcher.php      |   2 +
 lib/WebDriver/ServiceFactory.php              |   7 +-
 lib/WebDriver/Session.php                     |  32 +--
 lib/WebDriver/Shadow.php                      |  10 +-
 lib/WebDriver/WebDriver.php                   |  75 +++++--
 lib/WebDriver/Window.php                      |  10 +-
 19 files changed, 289 insertions(+), 214 deletions(-)

diff --git a/README.md b/README.md
index a180ef7b..f3a30970 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,13 @@ maintained)
 [![Latest Stable Version](https://poser.pugx.org/instaclick/php-webdriver/v/stable.png)](https://packagist.org/packages/instaclick/php-webdriver)
 [![Total Downloads](https://poser.pugx.org/instaclick/php-webdriver/downloads.png)](https://packagist.org/packages/instaclick/php-webdriver)
 
+## WebDrivers
+
+* [ChromeDriver](https://chromedriver.chromium.org/capabilities)
+* [GeckoDriver](https://developer.mozilla.org/en-US/docs/Web/WebDriver)
+* [SafariDriver](https://developer.apple.com/documentation/safari-developer-tools/webdriver/)
+* [Selenium WebDriver](https://www.selenium.dev/documentation/webdriver/)
+
 ## Links
 
 * [Packagist](http://packagist.org/packages/instaclick/php-webdriver)
diff --git a/lib/WebDriver/AbstractWebDriver.php b/lib/WebDriver/AbstractWebDriver.php
index 3d301ec4..f2cae14b 100644
--- a/lib/WebDriver/AbstractWebDriver.php
+++ b/lib/WebDriver/AbstractWebDriver.php
@@ -34,26 +34,24 @@ abstract class AbstractWebDriver
      *
      * @var \WebDriver\Service\CurlServiceInterface
      */
-    private $curlService;
+    protected $curlService;
 
     /**
      * Event Dispatcher
      *
      * @var \Psr\EventDispatcher\EventDispatcherInterface
      */
-    private $eventDispatcher;
+    protected $eventDispatcher;
 
     /**
-     * Transient options
-     *
      * @var array
      */
-    private $transientOptions;
+    private $extensions = [];
 
     /**
      * @var array
      */
-    private $extensions;
+    private static $options;
 
     /**
      * Return array of protocol methods
@@ -75,18 +73,28 @@ protected function chainable()
         return [];
     }
 
+    /**
+     * Return array of aliased web driver commands
+     *
+     * @return array
+     */
+    protected function aliases()
+    {
+        return [];
+    }
+
     /**
      * Constructor
      *
-     * @param string $url
+     * @param string                                        $url
+     * @param \WebDriver\Service\CurlServiceInterface       $curlService
+     * @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher
      */
-    public function __construct($url)
+    public function __construct($url, $curlService, $eventDispatcher)
     {
         $this->url = $url;
-        $this->transientOptions = [];
-        $this->extensions = [];
-        $this->curlService = ServiceFactory::getInstance()->getService('service.curl');
-        $this->eventDispatcher = ServiceFactory::getInstance()->getService('event_dispatcher');
+        $this->curlService = $curlService;
+        $this->eventDispatcher = $eventDispatcher;
     }
 
     /**
@@ -109,64 +117,6 @@ public function getURL()
         return $this->url;
     }
 
-    /**
-     * Set curl service
-     *
-     * @param \WebDriver\Service\CurlServiceInterface $curlService
-     */
-    public function setCurlService(CurlServiceInterface $curlService)
-    {
-        $this->curlService = $curlService;
-    }
-
-    /**
-     * Get curl service
-     *
-     * @return \WebDriver\Service\CurlServiceInterface
-     */
-    public function getCurlService()
-    {
-        return $this->curlService;
-    }
-
-    /**
-     * Set event dispatcher
-     *
-     * @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher
-     */
-    public function setEventDispatcher(EventDispatcherInterface $eventDispatcher)
-    {
-        $this->eventDispatcher = $eventDispatcher;
-    }
-
-    /**
-     * Get curl service
-     *
-     * @return \Psr\EventDispatcher\EventDispatcherInterface
-     */
-    public function getEventDispatcher()
-    {
-        return $this->eventDispatcher;
-    }
-
-    /**
-     * Set transient options
-     *
-     * @param mixed $transientOptions
-     */
-    public function setTransientOptions($transientOptions)
-    {
-        $this->transientOptions = is_array($transientOptions) ? $transientOptions : [];
-    }
-
-    /**
-     * @return array
-     */
-    public function getTransientOptions()
-    {
-        return $this->transientOptions;
-    }
-
     /**
      * Register extension
      *
@@ -203,13 +153,19 @@ public function __call($name, $arguments)
         if (count($arguments) === 0 && is_array($this->extensions) && array_key_exists($name, $this->extensions)) {
             $className = $this->extensions[$name][0];
 
-            return new $className($this->url . '/' . $this->extensions[$name][1]);
+            return new $className($this->url . '/' . $this->extensions[$name][1], $this->curlService, $this->eventDispatcher);
         }
 
         if (count($arguments) === 0 && array_key_exists($name, $this->chainable())) {
             return call_user_func([$this, $name]);
         }
 
+        $aliases = $this->aliases();
+
+        if (array_key_exists($name, $aliases)) {
+            return call_user_func_array([$this, $aliases[$name]], $arguments);
+        }
+
         if (preg_match('/^(get|post|delete)/', $name, $matches)) {
             $requestMethod = strtoupper($matches[0]);
             $webdriverCommand = strtolower(substr($name, strlen($requestMethod)));
@@ -257,8 +213,17 @@ public function __get($name)
 
     /**
      * Event firing command wrapper
+     *
+     * @param string $requestMethod
+     * @param string $command
+     * @param mixed  $parameters
+     * @param array  $options
+     *
+     * @return array
+     *
+     * @throws \WebDriver\Exception if error
      */
-    protected function curl($requestMethod, $command, $parameters = null, $extraOptions = [])
+    protected function curl($requestMethod, $command, $parameters = null, $options = [])
     {
         $name = strtoupper($requestMethod) . ':'
               . substr($className = get_class($this), strrpos($className, '\\') + 1) . ':'
@@ -267,7 +232,9 @@ protected function curl($requestMethod, $command, $parameters = null, $extraOpti
         $this->eventDispatcher->dispatch((object) ['event' => 'before', 'command' => $name, 'parameters' => $parameters]);
 
         try {
-            $result = $this->curlExec($requestMethod, $command, $parameters, $extraOptions);
+            $this->assertSerializable($parameters);
+
+            $result = $this->curlExec($requestMethod, $command, $parameters, $options);
         } catch (\Exception $e) {
             $source = substr($className = get_class($e), strrpos($className, '\\') + 1);
 
@@ -284,17 +251,17 @@ protected function curl($requestMethod, $command, $parameters = null, $extraOpti
     /**
      * Curl request to webdriver server.
      *
-     * @param string               $requestMethod HTTP request method, e.g., 'GET', 'POST', or 'DELETE'
-     * @param string               $command       If not defined in methods() this function will throw.
-     * @param array|integer|string $parameters    If an array, they will be posted as JSON parameters
-     *                                            If a number or string, "/$params" is appended to url
-     * @param array                $extraOptions  key=>value pairs of curl options to pass to curl_setopt()
+     * @param string $requestMethod HTTP request method, e.g., 'GET', 'POST', or 'DELETE'
+     * @param string $command       If not defined in methods() this function will throw.
+     * @param mixed  $parameters    If an array, they will be posted as JSON parameters
+     *                              If a number or string, "/$params" is appended to url
+     * @param array  $options       key=>value pairs of curl options to pass to curl_setopt()
      *
      * @return array array('value' => ..., 'info' => ...)
      *
      * @throws \WebDriver\Exception if error
      */
-    protected function curlExec($requestMethod, $command, $parameters, $extraOptions)
+    protected function curlExec($requestMethod, $command, $parameters, $options)
     {
         if ($parameters && is_array($parameters) && $requestMethod !== 'POST') {
             throw WebDriverException::factory(
@@ -316,23 +283,30 @@ protected function curlExec($requestMethod, $command, $parameters, $extraOptions
 
         $url .= $command;
 
-        if (($requestMethod === 'GET' || $requestMethod === 'DELETE')
-            && $parameters && (is_int($parameters) || is_string($parameters))
-        ) {
-            $url .= '/' . $parameters;
+        if ($requestMethod === 'GET' || $requestMethod === 'DELETE') {
+            if ($parameters && (is_int($parameters) || is_string($parameters))) {
+                $url .= '/' . $parameters;
+
+                $parameters = null;
+            }
+        } elseif ($parameters === null) {
+            $parameters = (object) [];
+        }
+
+        if (! array_key_exists(CURLOPT_HTTPHEADER, $options)) {
+            $optons[CURLOPT_HTTPHEADER] = [];
         }
 
-        $this->assertSerializable($parameters);
+        $options[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json;charset=utf-8';
+        $options[CURLOPT_HTTPHEADER][] = 'Accept: application/json';
 
         list($rawResult, $info) = $this->curlService->execute(
             $requestMethod,
             $url,
-            $parameters,
-            array_replace($extraOptions, $this->transientOptions)
+            json_encode($parameters),
+            $options
         );
 
-        $this->transientOptions = [];
-
         $httpCode = $info['http_code'];
 
         if ($httpCode === 0) {
@@ -422,6 +396,10 @@ private function assertSerializable($parameters)
             return;
         }
 
+        if (is_object($parameters) && json_encode($parameters) === '{}') {
+            return;
+        }
+
         throw WebDriverException::factory(
             WebDriverException::_UNEXPECTED_PARAMETERS,
             sprintf(
@@ -467,4 +445,40 @@ private function getRequestMethod($webdriverCommand)
 
         return array_shift($requestMethods);
     }
+
+    /**
+     * Set curl options
+     *
+     * @param array   $curlOptions
+     * @param boolean $merge
+     * @param boolean $save
+     *
+     * @return \WebDriver\WebDriver
+     */
+    public function setCurlOptions($options, $merge = false, $save = false)
+    {
+        if ($merge) {
+            $options = array_replace($this->curlService->getOptions(), $options);
+        }
+
+        if ($save) {
+            self::$options = $this->curlService->getOptions();
+        }
+
+        $this->curlService->setOptions($options);
+
+        return $this;
+    }
+
+    /**
+     * Restore curl options
+     *
+     * @return \WebDriver\WebDriver
+     */
+    public function restoreCurlOptions()
+    {
+        $this->curlService->setOptions(self::$options);
+
+        return $this;
+    }
 }
diff --git a/lib/WebDriver/Actions.php b/lib/WebDriver/Actions.php
index d7e8cbf0..bc28c8ab 100644
--- a/lib/WebDriver/Actions.php
+++ b/lib/WebDriver/Actions.php
@@ -18,13 +18,6 @@
  */
 class Actions extends AbstractWebDriver
 {
-    /**
-     * singleton
-     *
-     * @var \WebDriver\Actions
-     */
-    private static $instance;
-
     /**
      * @var array
      */
@@ -38,33 +31,7 @@ class Actions extends AbstractWebDriver
     /**
      * @var array
      */
-    private $actions;
-
-    /**
-     * {@inheritDoc}
-     */
-    private function __construct($url)
-    {
-        parent::__construct($url);
-
-        $this->clearAllActions();
-    }
-
-    /**
-     * Get singleton instance
-     *
-     * @param string $url
-     *
-     * @return \WebDriver\Actions
-     */
-    public static function getInstance($url)
-    {
-        if (self::$instance === null) {
-            self::$instance = new self($url);
-        }
-
-        return self::$instance;
-    }
+    private $actions = [];
 
     /**
      * Get Null Input Source
diff --git a/lib/WebDriver/Capability.php b/lib/WebDriver/Capability.php
index 17ac65a2..17b0913d 100644
--- a/lib/WebDriver/Capability.php
+++ b/lib/WebDriver/Capability.php
@@ -22,18 +22,23 @@ final class Capability
      * Desired capabilities
      *
      * @see https://w3c.github.io/webdriver/webdriver-spec.html#capabilities
-     * @see https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/remote/CapabilityType.java
      */
     const ACCEPT_INSECURE_CERTS       = 'acceptInsecureCerts';
     const BROWSER_NAME                = 'browserName';
     const BROWSER_VERSION             = 'browserVersion';
-    const ENABLE_DOWNLOADS            = 'enableDownloads';
     const PLATFORM_NAME               = 'platformName';
-    const PLATFORM_VERSION            = 'platformVersion';
     const PAGE_LOAD_STRATEGY          = 'pageLoadStrategy';
     const PROXY                       = 'proxy';
     const SET_WINDOW_RECT             = 'setWindowRect';
     const STRICT_FILE_INTERACTABILITY = 'strictFileInteractability';
     const TIMEOUTS                    = 'timeouts';
     const UNHANDLED_PROMPT_BEHAVIOR   = 'unhandlePromptBehavior';
+
+    /**
+     * Selenium
+     *
+     * @see https://github.com/SeleniumHQ/selenium/blob/trunk/java/src/org/openqa/selenium/remote/CapabilityType.java
+     */
+    const ENABLE_DOWNLOADS            = 'enableDownloads';
+    const PLATFORM_VERSION            = 'platformVersion';
 }
diff --git a/lib/WebDriver/Container.php b/lib/WebDriver/Container.php
index ac110d83..322407a6 100644
--- a/lib/WebDriver/Container.php
+++ b/lib/WebDriver/Container.php
@@ -28,9 +28,9 @@ abstract class Container extends AbstractWebDriver
     /**
      * {@inheritdoc}
      */
-    public function __construct($url)
+    public function __construct($url, $curlService, $eventDispatcher)
     {
-        parent::__construct($url);
+        parent::__construct($url, $curlService, $eventDispatcher);
 
         $locatorStrategy = new \ReflectionClass('WebDriver\LocatorStrategy');
 
@@ -178,7 +178,7 @@ protected function makeElement($value)
         if (array_key_exists(Element::WEB_ELEMENT_ID, (array) $value)) {
             $identifier = $value[Element::WEB_ELEMENT_ID];
 
-            return new Element($this->getNewIdentifierPath($identifier), $identifier);
+            return new Element($this->getNewIdentifierPath($identifier), $identifier, $this->curlService, $this->eventDispatcher);
         }
 
         return null;
diff --git a/lib/WebDriver/Element.php b/lib/WebDriver/Element.php
index c069c488..9ed96840 100644
--- a/lib/WebDriver/Element.php
+++ b/lib/WebDriver/Element.php
@@ -99,12 +99,14 @@ protected function aliases()
     /**
      * Constructor
      *
-     * @param string $url URL
-     * @param string $id  element ID
+     * @param string                                        $url             URL
+     * @param string                                        $id              Element ID
+     * @param \WebDriver\Service\CurlServiceInterface       $curlService     Curl service
+     * @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher Event dispatcher
      */
-    public function __construct($url, $id)
+    public function __construct($url, $id, $curlService, $eventDispatcher)
     {
-        parent::__construct($url . "/$id");
+        parent::__construct($url . "/$id", $curlService, $eventDispatcher);
 
         $this->id = $id;
     }
@@ -165,7 +167,7 @@ public function getShadowRoot()
         if (array_key_exists(Shadow::SHADOW_ROOT_ID, $value)) {
             $shadowRootReference = $value[Shadow::SHADOW_ROOT_ID];
 
-            return new Shadow($this->getSessionPath() . '/shadow', $shadowRootReference);
+            return new Shadow($this->getSessionPath() . '/shadow', $shadowRootReference, $this->curlService, $this->eventDispatcher);
         }
 
         return null;
@@ -239,7 +241,7 @@ public function submit()
 
             return $result['value'];
         } catch (\Exception $e) {
-            $session = new Session($this->getSessionPath(), []);
+            $session = new Session($this->getSessionPath(), [], $this->curlService, $this->eventDispatcher);
 
             return $session->execute()->sync(
                 ['script' => <<<JS
diff --git a/lib/WebDriver/Execute.php b/lib/WebDriver/Execute.php
index 5d9f241e..53c9a1e5 100644
--- a/lib/WebDriver/Execute.php
+++ b/lib/WebDriver/Execute.php
@@ -120,13 +120,13 @@ protected function makeElement($value)
         if (array_key_exists(Element::WEB_ELEMENT_ID, $value)) {
             $identifier = $value[Element::WEB_ELEMENT_ID];
 
-            return new Element($this->url . '/element', $identifier);
+            return new Element($this->url . '/element', $identifier, $this->curlService, $this->eventDispatcher);
         }
 
         if (array_key_exists(Shadow::SHADOW_ROOT_ID, $value)) {
             $identifier = $value[Shadow::SHADOW_ROOT_ID];
 
-            return new Shadow($this->url . '/shadow', $identifier);
+            return new Shadow($this->url . '/shadow', $identifier, $this->curlService, $this->eventDispatcher);
         }
 
         return null;
diff --git a/lib/WebDriver/Extension/ChromeDevTools.php b/lib/WebDriver/Extension/ChromeDevTools.php
index e0998c59..61723d49 100644
--- a/lib/WebDriver/Extension/ChromeDevTools.php
+++ b/lib/WebDriver/Extension/ChromeDevTools.php
@@ -30,11 +30,11 @@ class ChromeDevTools extends AbstractWebDriver
      *
      * @return mixed
      */
-    public function execute($cmd, $params = null)
+    public function execute($cmd, $params = [])
     {
         $parameters = is_array($cmd)
             ? $cmd
-            : ['cmd' => $cmd, 'params' => $params ?? new \stdclass];
+            : ['cmd' => $cmd, 'params' => (object) $params];
 
         $result = $this->curl('POST', 'execute', $parameters);
 
diff --git a/lib/WebDriver/Extension/WebAuthenticationAPI.php b/lib/WebDriver/Extension/WebAuthenticationAPI.php
index 9bf7b7e6..c0fda0a6 100644
--- a/lib/WebDriver/Extension/WebAuthenticationAPI.php
+++ b/lib/WebDriver/Extension/WebAuthenticationAPI.php
@@ -36,6 +36,6 @@ public function addVirtualAuthenticator($parameters)
 
         $authenticatorId = $result['value'];
 
-        return new VirtualAuthenticator($this->url, $authenticatorId);
+        return new VirtualAuthenticator($this->url, $authenticatorId, $this->curlService, $this->eventDispatcher);
     }
 }
diff --git a/lib/WebDriver/Extension/WebAuthenticationAPI/Credential.php b/lib/WebDriver/Extension/WebAuthenticationAPI/Credential.php
index 8de37ac1..7c89800b 100644
--- a/lib/WebDriver/Extension/WebAuthenticationAPI/Credential.php
+++ b/lib/WebDriver/Extension/WebAuthenticationAPI/Credential.php
@@ -23,12 +23,14 @@ class Credential extends AbstractWebDriver
     /**
      * Constructor
      *
-     * @param string $url URL
-     * @param string $id  Credential ID
+     * @param string                                        $url             URL
+     * @param string                                        $id              Credential ID
+     * @param \WebDriver\Service\CurlServiceInterface       $curlService     Curl service
+     * @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher Event dispatcher
      */
-    public function __construct($url, $id)
+    public function __construct($url, $id, $curlService, $eventDispatcher)
     {
-        parent::__construct($url . "/$id");
+        parent::__construct($url . "/$id", $curlService, $eventDispatcher);
     }
 
     /**
diff --git a/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php b/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php
index 78ad28be..b3c60606 100644
--- a/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php
+++ b/lib/WebDriver/Extension/WebAuthenticationAPI/VirtualAuthenticator.php
@@ -47,12 +47,14 @@ protected function chainable()
     /**
      * Constructor
      *
-     * @param string $url URL
-     * @param string $id  Authenticator ID
+     * @param string                                        $url             URL
+     * @param string                                        $id              Authenticator ID
+     * @param \WebDriver\Service\CurlServiceInterface       $curlService     Curl service
+     * @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher Event dispatcher
      */
-    public function __construct($url, $id)
+    public function __construct($url, $id, $curlService, $eventDispatcher)
     {
-        parent::__construct($url . "/$id");
+        parent::__construct($url . "/$id", $curlService, $eventDispatcher);
     }
 
     /**
@@ -80,7 +82,7 @@ public function addCredential($parameters)
 
         $this->curl('POST', 'credential', $parameters);
 
-        return new Credential($this->url . '/credentials', $credentialId);
+        return new Credential($this->url . '/credentials', $credentialId, $this->curlService, $this->eventDispatcher);
     }
 
     /**
@@ -110,6 +112,6 @@ public function setUserVerified($parameters)
      */
     protected function credentials()
     {
-        return new Credentials($this->url . '/credentials');
+        return new Credentials($this->url . '/credentials', $this->curlService, $this->eventDispatcher);
     }
 }
diff --git a/lib/WebDriver/Service/CurlService.php b/lib/WebDriver/Service/CurlService.php
index 1adeeedc..26434d33 100755
--- a/lib/WebDriver/Service/CurlService.php
+++ b/lib/WebDriver/Service/CurlService.php
@@ -23,27 +23,44 @@ class CurlService implements CurlServiceInterface
     /**
      * @var array
      */
-    private $defaultOptions;
+    private $options;
 
     /**
-     * Constructor
+     * Set default options
      *
-     * @param mixed $defaultOptions
+     * @param array $options
      */
-    public function __construct($defaultOptions = [])
+    public function __construct($options = [])
     {
-        $this->defaultOptions = is_array($defaultOptions) ? $defaultOptions : [];
+        $this->options = $options;
+    }
+
+    /**
+     * Get optiosn
+     *
+     * @return array
+     */
+    public function getOptions()
+    {
+        return $this->options;
+    }
+
+    /**
+     * Set optiosn
+     *
+     * @param array $options
+     */
+    public function setOptions($options = [])
+    {
+        $this->options = $options;
     }
 
     /**
      * {@inheritdoc}
      */
-    public function execute($requestMethod, $url, $parameters = null, $extraOptions = [])
+    public function execute($requestMethod, $url, $parameters = null, $options = [])
     {
-        $customHeaders = [
-            'Content-Type: application/json;charset=utf-8',
-            'Accept: application/json',
-        ];
+        $options = array_replace($this->options, $options);
 
         $curl = curl_init($url);
         curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
@@ -54,15 +71,15 @@ public function execute($requestMethod, $url, $parameters = null, $extraOptions
 
             case 'POST':
             case 'PUT':
-                $parameters = ! $parameters || ! is_array($parameters)
-                    ? '{}' // instead of json_encode(new \stdclass))
-                    : json_encode($parameters);
-
                 curl_setopt($curl, CURLOPT_POSTFIELDS, $parameters);
 
                 // Suppress "Expect: 100-continue" header automatically added by cURL that
                 // causes a 1 second delay if the remote server does not support Expect.
-                $customHeaders[] = 'Expect:';
+                if (! array_key_exists(CURLOPT_HTTPHEADER, $options)) {
+                    $optons[CURLOPT_HTTPHEADER] = [];
+                }
+
+                $options[CURLOPT_HTTPHEADER][] = 'Expect:';
 
                 $requestMethod === 'POST'
                     ? curl_setopt($curl, CURLOPT_POST, true)
@@ -74,11 +91,7 @@ public function execute($requestMethod, $url, $parameters = null, $extraOptions
                 break;
         }
 
-        curl_setopt($curl, CURLOPT_HTTPHEADER, $customHeaders);
-
-        foreach (array_replace($this->defaultOptions, $extraOptions) as $option => $value) {
-            curl_setopt($curl, $option, $value);
-        }
+        curl_setopt_array($curl, $options);
 
         $rawResult = curl_exec($curl);
         $rawResult = is_string($rawResult) ? trim($rawResult) : '';
@@ -88,8 +101,8 @@ public function execute($requestMethod, $url, $parameters = null, $extraOptions
         $info['errno'] = curl_errno($curl);
         $info['error'] = curl_error($curl);
 
-        if (array_key_exists(CURLOPT_FAILONERROR, $extraOptions) &&
-            $extraOptions[CURLOPT_FAILONERROR] &&
+        if (array_key_exists(CURLOPT_FAILONERROR, $options) &&
+            $options[CURLOPT_FAILONERROR] &&
             CURLE_GOT_NOTHING !== ($errno = curl_errno($curl)) &&
             $error = curl_error($curl)
         ) {
diff --git a/lib/WebDriver/Service/CurlServiceInterface.php b/lib/WebDriver/Service/CurlServiceInterface.php
index 7589a9b9..7f401a56 100755
--- a/lib/WebDriver/Service/CurlServiceInterface.php
+++ b/lib/WebDriver/Service/CurlServiceInterface.php
@@ -21,15 +21,14 @@ interface CurlServiceInterface
     /**
      * Send protocol request to WebDriver server using curl extension API.
      *
-     * @param string $requestMethod HTTP request method, e.g., 'GET', 'POST', or 'DELETE'
-     * @param string $url           Request URL
-     * @param array  $parameters    If an array, they will be posted as JSON parameters
-     *                              If a number or string, "/$params" is appended to url
-     * @param array  $extraOptions  key=>value pairs of curl options to pass to curl_setopt()
+     * @param string       $requestMethod
+     * @param string       $url
+     * @param array|string $parameters
+     * @param array        $options
      *
      * @return array
      *
-     * @throws \WebDriver\Exception\CurlExec only if http error and CURLOPT_FAILONERROR has been set in extraOptions
+     * @throws \WebDriver\Exception\CurlExec only if http error and CURLOPT_FAILONERROR has been set in options
      */
-    public function execute($requestMethod, $url, $parameters = null, $extraOptions = []);
+    public function execute($requestMethod, $url, $parameters = null, $options = []);
 }
diff --git a/lib/WebDriver/Service/VoidDispatcher.php b/lib/WebDriver/Service/VoidDispatcher.php
index dbd5b543..1368764a 100644
--- a/lib/WebDriver/Service/VoidDispatcher.php
+++ b/lib/WebDriver/Service/VoidDispatcher.php
@@ -27,6 +27,8 @@ class VoidDispatcher implements EventDispatcherInterface
      */
     public function dispatch(object $event)
     {
+        file_put_contents('/tmp/debug.log', date('Y-m-d H:i:s') . ':' . json_encode($event) . "\n", FILE_APPEND);
+
         return (object) [];
     }
 }
diff --git a/lib/WebDriver/ServiceFactory.php b/lib/WebDriver/ServiceFactory.php
index acc5a8b9..86a7c3d6 100755
--- a/lib/WebDriver/ServiceFactory.php
+++ b/lib/WebDriver/ServiceFactory.php
@@ -23,6 +23,9 @@
  */
 class ServiceFactory
 {
+    const CURL_SERVICE = 'service.curl';
+    const EVENT_DISPATCHER = 'event_dispatcher';
+
     /**
      * singleton
      *
@@ -48,8 +51,8 @@ private function __construct()
         $this->services = [];
 
         $this->serviceClasses = [
-            'service.curl' => CurlService::class,
-            'event_dispatcher' => VoidDispatcher::class,
+            self::CURL_SERVICE     => CurlService::class,
+            self::EVENT_DISPATCHER => VoidDispatcher::class,
         ];
     }
 
diff --git a/lib/WebDriver/Session.php b/lib/WebDriver/Session.php
index 81dc49b7..526d6722 100644
--- a/lib/WebDriver/Session.php
+++ b/lib/WebDriver/Session.php
@@ -135,13 +135,15 @@ protected function chainable()
     /**
      * Constructor
      *
-     * @param string     $url
-     * @param array|null $capabilities
+     * @param string                                        $url             URL
+     * @param array|null                                    $capabilities    Capabilities
+     * @param \WebDriver\Service\CurlServiceInterface       $curlService     Curl service
+     * @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher Event dispatcher
      */
-    public function __construct($url, $capabilities)
+    public function __construct($url, $capabilities, $curlService, $eventDispatcher)
     {
         // $url already includes :sessionId
-        parent::__construct($url);
+        parent::__construct($url, $curlService, $eventDispatcher);
 
         $this->capabilities = $capabilities;
 
@@ -325,7 +327,7 @@ public function window()
         }
 
         // chaining (with optional handle in $arg)
-        return new Window($this->url . '/window', $arg);
+        return new Window($this->url . '/window', $arg, $this->curlService, $this->eventDispatcher);
     }
 
     /**
@@ -380,7 +382,7 @@ public function frame($id = null)
         }
 
         // chaining
-        return new Frame($this->url . '/frame');
+        return new Frame($this->url . '/frame', $this->curlService, $this->eventDispatcher);
     }
 
     /**
@@ -400,7 +402,7 @@ public function timeouts()
         }
 
         // chaining
-        return new Timeouts($this->url . '/timeouts');
+        return new Timeouts($this->url . '/timeouts', $this->curlService, $this->eventDispatcher);
     }
 
     /**
@@ -447,7 +449,13 @@ public function getActiveElement()
      */
     protected function actions()
     {
-        return Actions::getInstance($this->url . '/actions');
+        static $actions = null;
+
+        if (! $actions) {
+            $actions = new Actions($this->url . '/actions', $this->curlService, $this->eventDispatcher);
+        }
+
+        return $actions;
     }
 
     /**
@@ -459,7 +467,7 @@ protected function actions()
      */
     protected function alert()
     {
-        return new Alert($this->url . '/alert');
+        return new Alert($this->url . '/alert', $this->curlService, $this->eventDispatcher);
     }
 
     /**
@@ -478,7 +486,7 @@ public function execute()
             return call_user_func_array([$this, 'executeScript'], func_get_args());
         }
 
-        return new Execute($this->url);
+        return new Execute($this->url, $this->curlService, $this->eventDispatcher);
     }
 
     /**
@@ -521,7 +529,7 @@ public function applicationCache()
     {
         // trigger_error(__METHOD__, E_USER_DEPRECATED);
 
-        return new ApplicationCache($this->url . '/application_cache');
+        return new ApplicationCache($this->url . '/application_cache', $this->curlService, $this->eventDispatcher);
     }
 
     /**
@@ -546,7 +554,7 @@ public function moveto($parameters)
                     'y' => $parameters['yoffset'],
                 ];
             } else {
-                $element = new Element($this->url . '/element', $parameters['element']);
+                $element = new Element($this->url . '/element', $parameters['element'], $this->curlService, $this->eventDispatcher);
                 $rect    = $element->rect();
 
                 $actionItem = [
diff --git a/lib/WebDriver/Shadow.php b/lib/WebDriver/Shadow.php
index b644c7f9..b4e65ed5 100644
--- a/lib/WebDriver/Shadow.php
+++ b/lib/WebDriver/Shadow.php
@@ -30,12 +30,14 @@ class Shadow extends Container
     /**
      * Constructor
      *
-     * @param string $url URL
-     * @param string $id  shadow ID
+     * @param string                                        $url             URL
+     * @param string                                        $id              shadow ID
+     * @param \WebDriver\Service\CurlServiceInterface       $curlService     Curl service
+     * @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher Event dispatcher
      */
-    public function __construct($url, $id)
+    public function __construct($url, $id, $curlService, $eventDispatcher)
     {
-        parent::__construct($url . "/$id");
+        parent::__construct($url . "/$id", $curlService, $eventDispatcher);
 
         $this->id = $id;
     }
diff --git a/lib/WebDriver/WebDriver.php b/lib/WebDriver/WebDriver.php
index c52c10b2..6c01c160 100644
--- a/lib/WebDriver/WebDriver.php
+++ b/lib/WebDriver/WebDriver.php
@@ -11,13 +11,16 @@
 
 namespace WebDriver;
 
+use Psr\EventDispatcher\EventDispatcherInterface;
+use WebDriver\Service\CurlServiceInterface;
+
 /**
  * WebDriver client class
  *
  * @package WebDriver
  *
  * W3C
- * @method void session($parameters) New session.
+ * @method object session($parameters = null) New session.
  * @method array status() Returns information about whether a remote end is in a state in which it can create new sessions.
  * Selenium
  * @method array logs() Returns session logs mapped to session IDs.
@@ -60,29 +63,73 @@ protected function aliases()
         ];
     }
 
+    /**
+     * Constructor
+     *
+     * @param string                                        $url             URL
+     * @param \WebDriver\Service\CurlServiceInterface       $curlService     Curl service
+     * @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher Event dispatcher
+     */
+    public function __construct($url, CurlServiceInterface $curlService = null, EventDispatcherInterface $eventDispatcher = null)
+    {
+        parent::__construct(
+            $url,
+            $curlService ?: ServiceFactory::getInstance()->getService(ServiceFactory::CURL_SERVICE),
+            $eventDispatcher ?: ServiceFactory::getInstance()->getService(ServiceFactory::EVENT_DISPATCHER)
+        );
+    }
+
     /**
      * New Session: /session (POST)
      * Returns a session object suitable for chaining
      *
-     * @param array|string $browserName          Preferred browser
-     * @param array        $desiredCapabilities  Optional desired capabilities
-     * @param array        $requiredCapabilities Optional required capabilities
+     * @param array|string $browserName  Preferred browser (or Capabilities)
+     * @param array        $capabilities Capabilities
      *
      * @return \WebDriver\Session
      */
-    public function newSession($browserName = Browser::CHROME, $desiredCapabilities = null, $requiredCapabilities = null)
+    public function newSession($browserName = Browser::CHROME, $capabilities = [])
     {
         if (func_num_args() === 1 && is_array($browserName)) {
-            $parameters = $browserName;
+            $capabilities = $browserName;
+            $browserName = null;
+        }
+
+        if (array_key_exists('capabilities', $capabilities)) {
+            $firstMatch  = $capabilities['capabilities']['firstMatch'] ?? [];
+            $alwaysMatch = $capabilities['capabilities']['alwaysMatch'] ?? [];
+            $desired     = [];
+        } elseif (array_key_exists('firstMatch', $capabilities) || array_key_exists('alwaysMatch', $capabilities)) {
+            $firstMatch  = $capabilities['firstMatch'] ?? [];
+            $alwaysMatch = $capabilities['alwaysMatch'] ?? [];
+            $desired     = [];
+        } elseif (array_key_exists('desiredCapabilities', $capabilities) || array_key_exists('requiredCapabilities', $capabilities)) {
+            $firstMatch  = [];
+            $alwaysMatch = $capabilities['requiredCapabilities'] ?? [];
+            $desired     = $capabilities['desiredCapabilities'] ?? [];
         } else {
-            $firstMatch = $desiredCapabilities ?: [];
-            $firstMatch[] = ['browserName' => $browserName];
+            $firstMatch  = [];
+            $alwaysMatch = $capabilities;
+            $desired     = [];
+        }
+
+        if ($browserName) {
+            $desired[Capability::BROWSER_NAME] = $browserName;
+        }
 
-            $parameters = ['capabilities' => ['firstMatch' => $firstMatch]];
+        foreach ($desired as $k => $v) {
+            $firstMatch[] = [$k => $v];
+        }
+
+        $parameters = ['capabilities' => ['firstMatch' => $firstMatch]];
+
+        if ($desired) {
+            $parameters['desiredCapabilities']         = $desired;
+        }
 
-            if (is_array($requiredCapabilities) && count($requiredCapabilities)) {
-                $parameters['capabilities']['alwaysMatch'] = $requiredCapabilities;
-            }
+        if ($alwaysMatch) {
+            $parameters['capabilities']['alwaysMatch'] = $alwaysMatch;
+            $parameters['requiredCapabilities']        = $alwaysMatch;
         }
 
         $options = [CURLOPT_FOLLOWLOCATION => true];
@@ -90,7 +137,7 @@ public function newSession($browserName = Browser::CHROME, $desiredCapabilities
 
         $this->capabilities = $result['value']['capabilities'] ?? $result['value']['capabilities'];
 
-        $session = new Session($result['sessionUrl'], $this->capabilities);
+        $session = new Session($result['sessionUrl'], $this->capabilities, $this->curlService, $this->eventDispatcher);
 
         return $session;
     }
@@ -110,7 +157,7 @@ public function getAllSessions()
         $sessions = [];
 
         foreach ($result['value'] as $session) {
-            $session = new Session($this->url . '/session/' . $session['id'], $this->capabilities);
+            $session = new Session($this->url . '/session/' . $session['id'], $this->capabilities, $this->curlService, $this->eventDispatcher);
 
             $sessions[] = $session;
         }
diff --git a/lib/WebDriver/Window.php b/lib/WebDriver/Window.php
index 81304140..ccbfdda7 100644
--- a/lib/WebDriver/Window.php
+++ b/lib/WebDriver/Window.php
@@ -72,12 +72,14 @@ protected function aliases()
     /**
      * Constructor
      *
-     * @param string      $url
-     * @param string|null $windowHandle
+     * @param string                                        $url             URL
+     * @param string|null                                   $windowHandle    Window handle
+     * @param \WebDriver\Service\CurlServiceInterface       $curlService     Curl service
+     * @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher Event dispatcher
      */
-    public function __construct($url, $windowHandle = null)
+    public function __construct($url, $windowHandle, $curlService, $eventDispatcher)
     {
-        parent::__construct($url);
+        parent::__construct($url, $curlService, $eventDispatcher);
 
         $this->windowHandle = $windowHandle;
     }

From da4c9a870378d09ce7c9cbe9c0f4ec34c4c1e469 Mon Sep 17 00:00:00 2001
From: Anthon Pang <apang@softwaredevelopment.ca>
Date: Sun, 10 Dec 2023 23:47:44 -0500
Subject: [PATCH 5/7] start refactoring tests (wip)

---
 .gitignore                                    |   3 +
 test/Test/WebDriver/ChromeDriverTest.php      |  32 ++++--
 test/Test/WebDriver/ElementTest.php           |  17 ++-
 test/Test/WebDriver/GeckoDriverTest.php       |  24 +++--
 test/Test/WebDriver/SafariDriverTest.php      | 100 ++++++++++++++++++
 test/Test/WebDriver/SeleniumWebDriverTest.php |  68 +++++++-----
 test/Test/WebDriver/SessionTest.php           |   7 +-
 test/Test/WebDriver/ShadowTest.php            |  12 ++-
 test/Test/WebDriver/WebDriverTestBase.php     |  61 +++++++----
 test/Test/WebDriver/driver.ini                |  21 ++++
 test/assets/index.html                        |  36 ++++++-
 11 files changed, 311 insertions(+), 70 deletions(-)
 create mode 100644 test/Test/WebDriver/SafariDriverTest.php
 create mode 100644 test/Test/WebDriver/driver.ini

diff --git a/.gitignore b/.gitignore
index a9d0fea3..f6abcbcc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,5 +4,8 @@ nbproject/
 vendor/
 composer.lock
 composer.phar
+nohup.out
+start-servers.sh
+stop-servers.sh
 .idea/
 .phpunit.result.cache
diff --git a/test/Test/WebDriver/ChromeDriverTest.php b/test/Test/WebDriver/ChromeDriverTest.php
index 95269212..81f7d16e 100644
--- a/test/Test/WebDriver/ChromeDriverTest.php
+++ b/test/Test/WebDriver/ChromeDriverTest.php
@@ -12,7 +12,9 @@
 namespace Test\WebDriver;
 
 use Test\WebDriver\WebDriverTestBase;
+use WebDriver\Browser;
 use WebDriver\Session;
+use WebDriver\WebDriver;
 
 /**
  * ChromeDriver
@@ -23,8 +25,12 @@
  */
 class ChromeDriverTest extends WebDriverTestBase
 {
-    protected $testWebDriverRootUrl = 'http://localhost:9515';
-    protected $testWebDriverName    = 'chromedriver';
+    const WEB_DRIVER_NAME      = 'chromedriver';
+    const WEB_DRIVER_ROOT_HOST = 'http://localhost';
+    const WEB_DRIVER_ROOT_PORT = 9515;
+    const WEB_DRIVER_ROOT_PATH = '';
+
+    private $driver;
 
     /**
      * Test driver session
@@ -32,12 +38,19 @@ class ChromeDriverTest extends WebDriverTestBase
     public function testSession()
     {
         try {
-            $this->session = $this->driver->session();
+            $this->driver  = new WebDriver($this->getWebDriverRootUrl());
+            $this->session = $this->driver->newSession(
+                Browser::CHROME,
+                [
+                    'goog:chromeOptions' => [
+                        'binary' => $this->getWebDriverExecutable(),
+                    ],
+                ]
+            );
         } catch (\Exception $e) {
             if ($this->isWebDriverDown($e)) {
-                $this->markTestSkipped("{$this->testWebDriverName} server not running");
-
-                return;
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
             }
 
             throw $e;
@@ -46,19 +59,18 @@ public function testSession()
         $this->assertTrue($this->session instanceof Session);
     }
 
-    /**
     /**
      * Test driver status
      */
     public function testStatus()
     {
         try {
+            $this->driver  = new WebDriver($this->getWebDriverRootUrl());
             $status = $this->driver->status();
         } catch (\Exception $e) {
             if ($this->isWebDriverDown($e)) {
-                $this->markTestSkipped("{$this->testWebDriverName} server not running");
-
-                return;
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
             }
 
             throw $e;
diff --git a/test/Test/WebDriver/ElementTest.php b/test/Test/WebDriver/ElementTest.php
index f6d71a52..f2b8d604 100644
--- a/test/Test/WebDriver/ElementTest.php
+++ b/test/Test/WebDriver/ElementTest.php
@@ -12,7 +12,9 @@
 namespace Test\WebDriver;
 
 use PHPUnit\Framework\TestCase;
+use Psr\EventDispatcher\EventDispatcherInterface;
 use WebDriver\Element;
+use WebDriver\Service\CurlServiceInterface;
 
 /**
  * Test WebDriver\Element class
@@ -28,7 +30,10 @@ class ElementTest extends TestCase
      */
     public function testGetID()
     {
-        $element = new Element('http://example.com/session/:sessionId/element', 'element-1234567890');
+        $curlService = $this->createMock(CurlServiceInterface::class);
+        $eventDispatcher = $this->createMock(EventDispatcherInterface::class);
+
+        $element = new Element('http://example.com/session/:sessionId/element', 'element-1234567890', $curlService, $eventDispatcher);
 
         $this->assertEquals('element-1234567890', $element->getID());
     }
@@ -38,7 +43,10 @@ public function testGetID()
      */
     public function testGetNewIdentifierPath()
     {
-        $element = new Element('http://example.com/session/:sessionId/element', 'element-1234567890');
+        $curlService = $this->createMock(CurlServiceInterface::class);
+        $eventDispatcher = $this->createMock(EventDispatcherInterface::class);
+
+        $element = new Element('http://example.com/session/:sessionId/element', 'element-1234567890', $curlService, $eventDispatcher);
 
         $method = $this->makeCallable($element, 'getNewIdentifierPath');
 
@@ -50,7 +58,10 @@ public function testGetNewIdentifierPath()
      */
     public function testGetSessionPath()
     {
-        $element = new Element('http://example.com/session/:sessionId/element', 'element-1234567890');
+        $curlService = $this->createMock(CurlServiceInterface::class);
+        $eventDispatcher = $this->createMock(EventDispatcherInterface::class);
+
+        $element = new Element('http://example.com/session/:sessionId/element', 'element-1234567890', $curlService, $eventDispatcher);
 
         $method = $this->makeCallable($element, 'getSessionPath');
 
diff --git a/test/Test/WebDriver/GeckoDriverTest.php b/test/Test/WebDriver/GeckoDriverTest.php
index 9408b1ba..a4547188 100644
--- a/test/Test/WebDriver/GeckoDriverTest.php
+++ b/test/Test/WebDriver/GeckoDriverTest.php
@@ -12,7 +12,9 @@
 namespace Test\WebDriver;
 
 use Test\WebDriver\WebDriverTestBase;
+use WebDriver\Browser;
 use WebDriver\Session;
+use WebDriver\WebDriver;
 
 /**
  * GeckoDriver
@@ -23,8 +25,12 @@
  */
 class GeckoDriverTest extends WebDriverTestBase
 {
-    protected $testWebDriverRootUrl = 'http://localhost:4444';
-    protected $testWebDriverName    = 'geckodriver';
+    const WEB_DRIVER_NAME      = 'geckodriver';
+    const WEB_DRIVER_ROOT_HOST = 'http://localhost';
+    const WEB_DRIVER_ROOT_PORT = 4444;
+    const WEB_DRIVER_ROOT_PATH = '';
+
+    private $driver;
 
     /**
      * Test driver session
@@ -32,12 +38,12 @@ class GeckoDriverTest extends WebDriverTestBase
     public function testSession()
     {
         try {
-            $this->session = $this->driver->session();
+            $this->driver  = new WebDriver($this->getWebDriverRootUrl());
+            $this->session = $this->driver->session(Browser::FIREFOX);
         } catch (\Exception $e) {
             if ($this->isWebDriverDown($e)) {
-                $this->markTestSkipped("{$this->testWebDriverName} server not running");
-
-                return;
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
             }
 
             throw $e;
@@ -52,12 +58,12 @@ public function testSession()
     public function testStatus()
     {
         try {
+            $this->driver  = new WebDriver($this->getWebDriverRootUrl());
             $status = $this->driver->status();
         } catch (\Exception $e) {
             if ($this->isWebDriverDown($e)) {
-                $this->markTestSkipped("{$this->testWebDriverName} server not running");
-
-                return;
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
             }
 
             throw $e;
diff --git a/test/Test/WebDriver/SafariDriverTest.php b/test/Test/WebDriver/SafariDriverTest.php
new file mode 100644
index 00000000..f738a338
--- /dev/null
+++ b/test/Test/WebDriver/SafariDriverTest.php
@@ -0,0 +1,100 @@
+<?php
+
+/**
+ * @copyright 2023 Anthon Pang
+ * @license Apache-2.0
+ *
+ * @package WebDriver
+ *
+ * @author Anthon Pang <apang@softwaredevelopment.ca>
+ */
+
+namespace Test\WebDriver;
+
+use Test\WebDriver\WebDriverTestBase;
+use WebDriver\Browser;
+use WebDriver\Session;
+use WebDriver\WebDriver;
+
+/**
+ * SafariDriver
+ *
+ * @package WebDriver
+ *
+ * @group Functional
+ *
+ * @see https://developer.apple.com/documentation/webkit/about_webdriver_for_safari
+ */
+class SafariDriverTest extends WebDriverTestBase
+{
+    const WEB_DRIVER_NAME      = 'safaridriver';
+    const WEB_DRIVER_ROOT_HOST = 'http://localhost';
+    const WEB_DRIVER_ROOT_PORT = 7055;
+    const WEB_DRIVER_ROOT_PATH = '';
+
+    private $driver;
+
+    /**
+     * Test driver session
+     */
+    public function testSession()
+    {
+        try {
+            $this->driver  = new WebDriver($this->getWebDriverRootUrl());
+            $this->session = $this->driver->session(Browser::SAFARI);
+        } catch (\Exception $e) {
+            if ($this->isWebDriverDown($e)) {
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
+            }
+
+            throw $e;
+        }
+
+        $this->assertTrue($this->session instanceof Session);
+    }
+
+    /**
+     * Test driver newSession alias
+     */
+    public function testNewSession()
+    {
+        try {
+            $this->driver  = new WebDriver($this->getWebDriverRootUrl());
+            $this->session = $this->driver->newSession(Browser::SAFARI);
+        } catch (\Exception $e) {
+            if ($this->isWebDriverDown($e)) {
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
+            }
+
+            throw $e;
+        }
+
+        $this->assertTrue($this->session instanceof Session);
+    }
+
+    /**
+     * Test driver status
+     */
+    public function testStatus()
+    {
+        try {
+            $this->driver  = new WebDriver($this->getWebDriverRootUrl());
+            $this->session = $this->driver->session(Browser::SAFARI);
+
+            $status = $this->driver->status();
+        } catch (\Exception $e) {
+            if ($this->isWebDriverDown($e)) {
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
+            }
+
+            throw $e;
+        }
+
+        $this->assertCount(2, $status);
+        $this->assertTrue(isset($status['message']));
+        $this->assertTrue(isset($status['ready']));
+    }
+}
diff --git a/test/Test/WebDriver/SeleniumWebDriverTest.php b/test/Test/WebDriver/SeleniumWebDriverTest.php
index 405e49fc..9ff9171e 100644
--- a/test/Test/WebDriver/SeleniumWebDriverTest.php
+++ b/test/Test/WebDriver/SeleniumWebDriverTest.php
@@ -13,7 +13,9 @@
 
 use Test\WebDriver\WebDriverTestBase;
 use WebDriver\Exception\CurlExec;
+use WebDriver\Exception\InvalidArgument;
 use WebDriver\Exception\NoSuchElement;
+use WebDriver\Exception\UnknownCommand;
 use WebDriver\Service\CurlService;
 use WebDriver\ServiceFactory;
 use WebDriver\WebDriver;
@@ -27,31 +29,40 @@
  */
 class SeleniumWebDriverTest extends WebDriverTestBase
 {
-    protected $testWebDriverRootUrl = 'http://localhost:4444/wd/hub';
-    protected $testWebDriverName    = 'selenium';
+    const WEB_DRIVER_NAME      = 'selenium';
+    const WEB_DRIVER_ROOT_HOST = 'http://localhost';
+    const WEB_DRIVER_ROOT_PORT = 4444;
+    const WEB_DRIVER_ROOT_PATH = '/wd/hub';
+
+    private $driver;
 
     /**
      * Test driver sessions
+     *
+     * @deprecated
      */
     public function testSessions()
     {
         try {
+            $this->driver = new WebDriver($this->getWebDriverRootUrl());
             $sessions = $this->driver->sessions();
             $this->assertCount(0, $sessions);
 
             $this->session = $this->driver->session();
+        } catch (UnknownCommand $e) {
+            // W3C
         } catch (\Exception $e) {
             if ($this->isWebDriverDown($e)) {
-                $this->markTestSkipped("{$this->testWebDriverName} server not running");
-
-                return;
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
             }
 
             throw $e;
         }
 
+        // deprecated
         $this->assertCount(1, $this->driver->sessions());
-        $this->assertEquals($this->getTestWebDriverRootUrl(), $this->driver->getUrl());
+        $this->assertEquals($this->getWebDriverRootUrl(), $this->driver->getUrl());
     }
 
     /**
@@ -60,12 +71,12 @@ public function testSessions()
     public function testStatus()
     {
         try {
+            $this->driver = new WebDriver($this->getWebDriverRootUrl());
             $status = $this->driver->status();
         } catch (\Exception $e) {
             if ($this->isWebDriverDown($e)) {
-                $this->markTestSkipped("{$this->testWebDriverName} server not running");
-
-                return;
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
             }
 
             throw $e;
@@ -73,9 +84,9 @@ public function testStatus()
 
         $this->session = $this->driver->session();
 
-        $this->assertCount(3, $status);
-        $this->assertTrue(isset($status['os']));
-        $this->assertTrue(isset($status['build']));
+        // W3C || JSON Wire Protocol
+        $this->assertTrue(isset($status['ready']) || isset($status['os']));
+        $this->assertTrue(isset($status['message']) || isset($status['build']));
     }
 
     /**
@@ -84,16 +95,15 @@ public function testStatus()
     public function testWebDriverError()
     {
         try {
-            $this->driver = new WebDriver($this->getTestWebDriverRootUrl() . '/../invalidurl');
+            $this->driver = new WebDriver($this->getWebDriverRootUrl() . '/../invalidurl');
 
             $status = $this->driver->status();
 
             $this->fail('Exception not thrown while connecting to invalid WebDriver url');
         } catch (\Exception $e) {
             if ($this->isWebDriverDown($e)) {
-                $this->markTestSkipped("{$this->testWebDriverName} server not running");
-
-                return;
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
             }
 
             $this->assertEquals(CurlExec::class, get_class($e));
@@ -106,12 +116,12 @@ public function testWebDriverError()
     public function testWebDriverErrorResponse()
     {
         try {
+            $this->driver = new WebDriver($this->getWebDriverRootUrl());
             $status = $this->driver->status();
         } catch (\Exception $e) {
             if ($this->isWebDriverDown($e)) {
-                $this->markTestSkipped("{$this->testWebDriverName} server not running");
-
-                return;
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
             }
 
             throw $e;
@@ -119,13 +129,19 @@ public function testWebDriverErrorResponse()
 
         try {
             $this->session = $this->driver->session();
-            $this->session->open($this->getTestDocumentRootUrl() . '/test/Assets/index.html');
+            $this->session->open($this->getTestDocumentUrl() . '/test/assets/index.html');
 
             $element = $this->session->element('id', 'a-quite-unlikely-html-element-id');
 
             $this->fail('Exception not thrown while looking for missing element in page');
+        } catch (InvalidArgument $e) {
+            // W3C - No locator for "id"
+            $this->expectNotToPerformAssertions();
+        } catch (NoSuchElement $e) {
+            // JSON Wire Protocol
+            $this->expectNotToPerformAssertions();
         } catch (\Exception $e) {
-            $this->assertEquals(NoSuchElement::class, get_class($e));
+            $this->fail('Unexpected exception');
         }
     }
 
@@ -135,12 +151,12 @@ public function testWebDriverErrorResponse()
     public function testWebDriverNoResponse()
     {
         try {
+            $this->driver = new WebDriver($this->getWebDriverRootUrl());
             $status = $this->driver->status();
         } catch (\Exception $e) {
             if ($this->isWebDriverDown($e)) {
-                $this->markTestSkipped("{$this->testWebDriverName} server not running");
-
-                return;
+                $this->markTestSkipped(self::WEB_DRIVER_NAME . ' server not running');
+                // never returns
             }
 
             throw $e;
@@ -175,9 +191,9 @@ public function testNonJsonResponse()
                 return array($result, $info);
             }));
 
-        ServiceFactory::getInstance()->setService('service.curl', $mockCurlService);
+        ServiceFactory::getInstance()->setService(ServiceFactory::CURL_SERVICE, $mockCurlService);
 
-        $this->driver  = new WebDriver($this->getTestWebDriverRootUrl());
+        $this->driver = new WebDriver($this->getWebDriverRootUrl());
         $result = $this->driver->status();
 
         $this->assertNull($result);
diff --git a/test/Test/WebDriver/SessionTest.php b/test/Test/WebDriver/SessionTest.php
index 8bf5ac6d..1c3dc8ea 100644
--- a/test/Test/WebDriver/SessionTest.php
+++ b/test/Test/WebDriver/SessionTest.php
@@ -12,6 +12,8 @@
 namespace Test\WebDriver;
 
 use PHPUnit\Framework\TestCase;
+use Psr\EventDispatcher\EventDispatcherInterface;
+use WebDriver\Service\CurlServiceInterface;
 use WebDriver\Session;
 
 /**
@@ -28,7 +30,10 @@ class SessionTest extends TestCase
      */
     public function testGetNewIdentifierPath()
     {
-        $session = new Session('http://example.com/session/session-1234567890', []);
+        $curlService = $this->createMock(CurlServiceInterface::class);
+        $eventDispatcher = $this->createMock(EventDispatcherInterface::class);
+
+        $session = new Session('http://example.com/session/session-1234567890', [], $curlService, $eventDispatcher);
 
         $method = $this->makeCallable($session, 'getNewIdentifierPath');
 
diff --git a/test/Test/WebDriver/ShadowTest.php b/test/Test/WebDriver/ShadowTest.php
index 0a420eb7..3e315b9a 100644
--- a/test/Test/WebDriver/ShadowTest.php
+++ b/test/Test/WebDriver/ShadowTest.php
@@ -12,6 +12,8 @@
 namespace Test\WebDriver;
 
 use PHPUnit\Framework\TestCase;
+use Psr\EventDispatcher\EventDispatcherInterface;
+use WebDriver\Service\CurlServiceInterface;
 use WebDriver\Shadow;
 
 /**
@@ -28,7 +30,10 @@ class ShadowTest extends TestCase
      */
     public function testGetID()
     {
-        $shadow = new Shadow('http://example.com/session/:sessionId/shadow', 'shadow-1234567890');
+        $curlService = $this->createMock(CurlServiceInterface::class);
+        $eventDispatcher = $this->createMock(EventDispatcherInterface::class);
+
+        $shadow = new Shadow('http://example.com/session/:sessionId/shadow', 'shadow-1234567890', $curlService, $eventDispatcher);
 
         $this->assertEquals('shadow-1234567890', $shadow->getID());
     }
@@ -38,7 +43,10 @@ public function testGetID()
      */
     public function testGetNewIdentifierPath()
     {
-        $shadow = new Shadow('http://example.com/session/:sessionId/shadow', 'shadow-1234567890');
+        $curlService = $this->createMock(CurlServiceInterface::class);
+        $eventDispatcher = $this->createMock(EventDispatcherInterface::class);
+
+        $shadow = new Shadow('http://example.com/session/:sessionId/shadow', 'shadow-1234567890', $curlService, $eventDispatcher);
 
         $method = $this->makeCallable($shadow, 'getNewIdentifierPath');
 
diff --git a/test/Test/WebDriver/WebDriverTestBase.php b/test/Test/WebDriver/WebDriverTestBase.php
index 3cebf1a5..82654e76 100644
--- a/test/Test/WebDriver/WebDriverTestBase.php
+++ b/test/Test/WebDriver/WebDriverTestBase.php
@@ -12,9 +12,6 @@
 namespace Test\WebDriver;
 
 use PHPUnit\Framework\TestCase;
-use WebDriver\Service\CurlService;
-use WebDriver\ServiceFactory;
-use WebDriver\WebDriver;
 
 /**
  * Test WebDriver\WebDriver class
@@ -25,21 +22,29 @@
  */
 abstract class WebDriverTestBase extends TestCase
 {
-    protected $driver;
+    const WEB_DRIVER_NAME      = '';
+    const WEB_DRIVER_ROOT_HOST = 'http://localhost';
+    const WEB_DRIVER_ROOT_PORT = 48888;
+    const WEB_DRIVER_ROOT_PATH = '';
+
+    protected $config;
     protected $session;
-    protected $testWebDriverRootUrl;
-    protected $testWebDriverName;
 
-    private $testDocumentRootUrl = 'http://localhost';
+    /**
+     * {@inheritdoc}
+     */
+    public function __construct()
+    {
+        call_user_func_array(['parent', __FUNCTION__], func_get_args());
+
+        $this->config = parse_ini_file(__DIR__ . '/driver.ini', true);
+    }
 
     /**
      * {@inheritdoc}
      */
     protected function setUp(): void
     {
-        ServiceFactory::getInstance()->setServiceClass('service.curl', CurlService::class);
-
-        $this->driver  = new WebDriver($this->getTestWebDriverRootUrl());
         $this->session = null;
     }
 
@@ -54,26 +59,46 @@ protected function tearDown(): void
     }
 
     /**
-     * Returns the full url to the test site (corresponding to the root dir of the library).
-     * You can set this via env var ROOT_URL
+     * Get web driver root URL
      *
      * @return string
      */
-    protected function getTestDocumentRootUrl()
+    protected function getWebDriverRootUrl()
     {
-        return $this->testDocumentRootUrl;
+        $driverName = static::WEB_DRIVER_NAME;
+        $host = $this->config[$driverName]['host'] ?? static::WEB_DRIVER_ROOT_HOST;
+        $port = $this->config[$driverName]['port'] ?? static::WEB_DRIVER_ROOT_PORT;
+        $path = $this->config[$driverName]['path'] ?? static::WEB_DRIVER_ROOT_PATH;
+
+        $url = $host . ($port && $port != 80 && $port != 443 ? ":$port" : '') . $path;
+
+        return $url;
     }
 
     /**
-     * Returns the full url to the WebDriver server used for functional tests
+     * Get web driver executable path
      *
      * @return string
+     */
+    protected function getWebDriverExecutable()
+    {
+        $url = $this->config[static::WEB_DRIVER_NAME]['executable'];
+
+        return $url;
+    }
+
+    /**
+
+    /**
+     * Get test document URL
      *
-     * @todo make this configurable via env var
+     * @return string
      */
-    protected function getTestWebDriverRootUrl()
+    protected function getTestDocumentUrl()
     {
-        return $this->testWebDriverRootUrl;
+        $url = $this->config['test']['url'];
+
+        return $url;
     }
 
     /**
diff --git a/test/Test/WebDriver/driver.ini b/test/Test/WebDriver/driver.ini
new file mode 100644
index 00000000..7de977b0
--- /dev/null
+++ b/test/Test/WebDriver/driver.ini
@@ -0,0 +1,21 @@
+[test]
+url = http://localhost:6800
+
+[chromedriver]
+executable = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+;host = http://localhost
+port = 9515
+
+[geckodriver]
+;host = http://localhost
+port = 4444
+
+[safaridriver]
+;host = http://localhost
+port = 7515
+
+[selenium]
+;host = http://localhost
+port = 49999
+;port = 4444
+url_base = /wd/hub
diff --git a/test/assets/index.html b/test/assets/index.html
index 71ce6922..34020c1b 100644
--- a/test/assets/index.html
+++ b/test/assets/index.html
@@ -6,5 +6,39 @@
 </head>
 <body>
 
+    <form action="https://example.com/webtest.php" method="POST">
+        <ul>
+            <li>
+                 <label for="name">Name:</label>
+                 <input type="text" id="name" name="user_name" />
+            </li>
+            <li>
+                 <label for="mail">Email:</label>
+                 <input type="email" id="mail" name="user_email" />
+            </li>
+            <li>
+                 <label for="msg">Message:</label>
+                 <textarea id="msg" name="user_message"></textarea>
+            </li>
+        </ul>
+    </form>
+
+    <div class="alpha">Lorem</div>
+    <a href="https://example.com/ipsum.html"> dolor </a>
+    <a href="https://example.com/sit.html"> amet </a>
+    <video width="320" height="240" controls preload="none">
+        <source src="consectetur.mp4" type="video/mp4">
+        <source src="adipiscing.ogg" type="video/ogg">
+        Your browser does not support the video tag.
+    </video>
+    <div>
+        <span>
+            <p>elit</p>
+            <p>in ac</p>
+        </span>
+    </div>
+    <div class="beta">commodo</div>
+    <span id="eros">Vivamus</div>
+
 </body>
-</html>
\ No newline at end of file
+</html>

From c3c12d4fb08844487900c0bf2d71946ffee6ba02 Mon Sep 17 00:00:00 2001
From: Anthon Pang <apang@softwaredevelopment.ca>
Date: Tue, 12 Dec 2023 14:46:57 -0500
Subject: [PATCH 6/7] add download links and port notes

---
 README.md | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/README.md b/README.md
index f3a30970..18e13602 100644
--- a/README.md
+++ b/README.md
@@ -29,9 +29,25 @@ maintained)
 ## WebDrivers
 
 * [ChromeDriver](https://chromedriver.chromium.org/capabilities)
+  * [Chromium Downloads Tool](https://chromium.cypress.io/)
 * [GeckoDriver](https://developer.mozilla.org/en-US/docs/Web/WebDriver)
+  * [GeckoDriver Releases](https://github.com/mozilla/geckodriver/releases)
 * [SafariDriver](https://developer.apple.com/documentation/safari-developer-tools/webdriver/)
 * [Selenium WebDriver](https://www.selenium.dev/documentation/webdriver/)
+  * [Selenium Downloads](https://www.selenium.dev/downloads/)
+
+### Port Usage
+
+These are the default ports that these WebDrivers listen on. If running
+multiple WebDrivers concurrently, you can avoid conflict by using the
+`--port` option to listen on a different port.
+
+| WebDriver    | Port |
+| ------------ | ---- |
+| ChromeDriver | 9515 |
+| GeckoDriver  | 4444 |
+| SafariDriver | 7055 |
+| Selenium     | 4444 |
 
 ## Links
 

From 1cc80ec85383041cd3679b11d304e75c785b100f Mon Sep 17 00:00:00 2001
From: Anthon Pang <apang@softwaredevelopment.ca>
Date: Wed, 13 Dec 2023 10:23:33 -0500
Subject: [PATCH 7/7] add start/stop scripts

---
 .gitignore       |  3 +--
 start-servers.sh | 19 +++++++++++++++++++
 stop-servers.sh  | 20 ++++++++++++++++++++
 3 files changed, 40 insertions(+), 2 deletions(-)
 create mode 100755 start-servers.sh
 create mode 100755 stop-servers.sh

diff --git a/.gitignore b/.gitignore
index f6abcbcc..335f6c10 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,10 @@
 build/
+drivers/
 logs/
 nbproject/
 vendor/
 composer.lock
 composer.phar
 nohup.out
-start-servers.sh
-stop-servers.sh
 .idea/
 .phpunit.result.cache
diff --git a/start-servers.sh b/start-servers.sh
new file mode 100755
index 00000000..e180bdaa
--- /dev/null
+++ b/start-servers.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+#
+# These port ranges are currently unassigned (according to IANA)
+# -  6792 -  6800
+# - 48654 - 48999
+#  -49002 - 49149
+
+CHROMEDRIVER_PORT=48666
+GECKODRIVER_PORT=48777
+SAFARIDRIVER_PORT=48888
+SELENIUMDRIVER_PORT=49999
+
+nohup drivers/chromedriver-116.0.5845.0 --port $CHROMEDRIVER_PORT &
+
+nohup drivers/geckodriver-v0.33.0-macos --port $GECKODRIVER_PORT &
+
+nohup drivers/safaridriver-14.1.2 --port $SAFARIDRIVER_PORT &
+
+nohup java -jar drivers/selenium-server-4.16.1.jar standalone --port $SELENIUMDRIVER_PORT --selenium-manager true &
diff --git a/stop-servers.sh b/stop-servers.sh
new file mode 100755
index 00000000..7c1f24d8
--- /dev/null
+++ b/stop-servers.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+
+# regex match the procname
+KILLALL_OPTIONS=-r
+
+if [ `uname` = "Darwin" ]; then
+  KILLALL_OPTIONS=-m
+fi
+
+killall -9 $KILLALL_OPTIONS chromedriver
+
+killall -9 $KILLALL_OPTIONS geckodriver
+
+killall -9 $KILLALL_OPTIONS safaridriver
+
+PID=`/bin/ps -ef | grep 'java -jar drivers/selenium' | grep -v grep | awk '{print $2}' | xargs`
+
+if [ ! -z "$PID" ]; then
+    kill -9 $PID
+fi
