diff options
Diffstat (limited to 'testing/geckodriver')
45 files changed, 11581 insertions, 0 deletions
diff --git a/testing/geckodriver/.cargo/config b/testing/geckodriver/.cargo/config new file mode 100644 index 0000000000..18086c84c5 --- /dev/null +++ b/testing/geckodriver/.cargo/config @@ -0,0 +1,3 @@ +[target.i686-pc-windows-gnu] +linker = "i686-w64-mingw32-gcc" +rustflags = "-C panic=abort" diff --git a/testing/geckodriver/CHANGES.md b/testing/geckodriver/CHANGES.md new file mode 100644 index 0000000000..3fb016a82d --- /dev/null +++ b/testing/geckodriver/CHANGES.md @@ -0,0 +1,1831 @@ +<!-- markdownlint-disable MD024 --> +# Change log + +All notable changes to this program are documented in this file. + +## 0.33.0 (2023-04-03, `a80e5fd61076`) + +### Known problems + +- _Startup hang with Firefox running in a container (e.g. snap, flatpak):_ + + When Firefox is packaged inside a container (like the default Firefox browser + shipped with Ubuntu 22.04), it may see a different filesystem to the host. + This can affect access to the generated profile directory, which may result + in a hang when starting Firefox. Workarounds are listed in the geckodriver + [usage documentation]. + +- _Potential hang with `moz:debuggerAddress` capability set to `true`:_ + + After enabling the site-isolation feature in Firefox with geckodriver 0.32.1 + some WebDriver clients like Selenium that use the Chrome DevTools Protocol (CDP) + by default for logging events could trigger a hang in Firefox's experimental CDP + implementation. The fix for this problem will be shipped with Firefox 112. + Until then the following Firefox preferences should be set: + + - `fission.bfcacheInParent: false` + - `fission.webContentIsolationStrategy: 0` + +### Added + +- Support for [Get Computed Label] and [Get Computed Role] + + The command [Get Computed Label] returns the accessibility label (sometimes + also referred to as Accessible Name), which is a short string that labels the + function of the control (e.g. the string "Comment" or "Sign In" on a button). + + The command [Get Computed Role] returns the reserved token value (in ARIA, + button, heading, etc.) that describes the type of control or content in the + element. + + Note that the minimum required Firefox version is 113.0. + +- Support for [Find Element From Shadow Root] and [Find Elements From Shadow Root] + + The commands allow a lookup of individual elements or collections of elements + within an open or closed Shadow DOM. All location strategies except `Tag name` and + `XPath selector` are currently supported. + + Note that the minimum required Firefox version is 113.0. + +### Changed + +- The Mozilla specific capability `moz:useNonSpecCompliantPointerOrigin` has been + marked as deprecated. Its removal is planned for the Firefox 116.0 release. + +## 0.32.2 (2023-02-08, `602aa16c20d4`) + +### Known problems + +- _Startup hang with Firefox running in a container (e.g. snap, flatpak):_ + + When Firefox is packaged inside a container (like the default Firefox browser + shipped with Ubuntu 22.04), it may see a different filesystem to the host. + This can affect access to the generated profile directory, which may result + in a hang when starting Firefox. Workarounds are listed in the geckodriver + [usage documentation]. + +- _Potential hang with `moz:debuggerAddress` capability set to `true`:_ + + After enabling the site-isolation feature in Firefox with geckodriver 0.32.1 + some WebDriver clients like Selenium that use the Chrome DevTools Protocol (CDP) + by default for logging events could trigger a hang in Firefox's experimental CDP + implementation. The fix for this problem will be shipped with Firefox 112. + Until then the following Firefox preferences should be set: + + - `fission.bfcacheInParent: false` + - `fission.webContentIsolationStrategy: 0` + +### Fixed + +- With the release of geckodriver 0.32.1 the marionette crate was inappropriately + bumped to a semver incompatible version and caused `cargo install geckodriver` + to fail for older releases. + +## 0.32.1 (2023-02-02, `b7f075124503`) + +### Known problems + +- _Startup hang with Firefox running in a container (e.g. snap, flatpak):_ + + When Firefox is packaged inside a container (like the default Firefox browser + shipped with Ubuntu 22.04), it may see a different filesystem to the host. + This can affect access to the generated profile directory, which may result + in a hang when starting Firefox. Workarounds are listed in the geckodriver + [usage documentation]. + +- _Potential hang with `moz:debuggerAddress` capability set to `true`:_ + + After enabling the site-isolation feature in Firefox with geckodriver 0.32.1 + some WebDriver clients like Selenium that use the Chrome DevTools Protocol (CDP) + by default for logging events could trigger a hang in Firefox's experimental CDP + implementation. The fix for this problem will be shipped with Firefox 112. + Until then the following Firefox preferences should be set: + + - `fission.bfcacheInParent: false` + - `fission.webContentIsolationStrategy: 0` + +### Fixed + +- When using the boolean capability `moz:debuggerAddress` with a value of `true` + the site-isolation feature in Firefox will no longer accidentally be turned off. + This behavior affected all users of WebDriver clients especially Selenium, which + set this capability by default, and caused Firefox on desktop systems to be + launched in an unsupported mode. + +## 0.32.0 (2022-10-13, `4563dd583110`) + +### Added + +- Native aarch64 builds of geckodriver for Linux and Windows are now available. + +- Support `wheel` input source for [Actions], which is associated with a + wheel-type input device. This endpoint is supported by geckodriver when + using Firefox version ≥106. + +- Support `touch` as `pointerType` for `pointer` input source for [Actions], + which is associated with a touch input device. This also includes the + addition of all the remaining properties for `pointer` input sources as + specified by WebDriver. This endpoint is supported by geckodriver when using + Firefox version ≥104. + +### Fixed + +- Using geckodriver to launch Firefox inside a sandbox -- for example + a Firefox distribution using Snap or Flatpak -- can fail with a + "Profile not found" error if the sandbox restricts Firefox's ability + to access the system temporary directory. geckodriver uses the + temporary directory to store Firefox profiles created during the run. + + This issue can now be worked around by using the `--profile-root` + command line option or setting the `TMPDIR` environment variable to + a location that both Firefox and geckodriver have read/write access + to e.g.: + + ```bash + % mkdir $HOME/tmp + % geckodriver --profile-root=~/tmp + ``` + + or + + ```bash + % TMPDIR=$HOME/tmp geckodriver + ``` + + Alternatively, geckodriver may be used with a Firefox install that + is not packaged inside a sandbox e.g. from [mozilla.org]. + +- The sandboxed Firefox binary is now automatically detected when geckodriver + is used from within a Snap confinement. + + Implemented by [Olivier Tilloy]. + +- On MacOS the geckodriver binary is now technically both signed and notarized. + + Note: The actual validation can only be performed if the machine that starts + the geckodriver binary for the very first time is online. You can find more + details on how to work around this issue in the [macOS notarization] section + of the documentation. + +- The backup of the original Firefox preferences are now correctly restored + on Android when the WebDriver session ends. + +### Changed + +- Update dependencies + +## 0.31.0 (2022-04-11, `b617178ef491`) + +### Known problems + +- _Firefox running in Linux Sandbox (e.g. Snap package):_ + + Using geckodriver to launch Firefox inside a sandbox -- for example + a Firefox distribution using Snap or Flatpak -- can fail with a + "Profile not found" error if the sandbox restricts Firefox's ability + to access the system temporary directory. geckodriver uses the + temporary directory to store Firefox profiles created during the run. + + As workaround geckodriver may be used with a Firefox install that + is not packaged inside a sandbox e.g. from [mozilla.org]. + +- _macOS 10.15 (Catalina) and later:_ + + Due to the requirement from Apple that all programs must be + notarized, geckodriver will not work on Catalina if you manually + download it through another notarized program, such as Firefox. + + Whilst we are working on a repackaging fix for this problem, you can + find more details on how to work around this issue in the [macOS + notarization] section of the documentation. + +### Added + +- Users with the [Rust] toolchain installed can now build and install + geckodriver from [crates.io] using Cargo: + + % cargo install geckodriver + +- Support for [Get Element Shadow Root] + + Implemented by [David Burns]. + + The standardised WebDriver [Get Element Shadow Root] endpoint provides a way + to retrieve the Shadow Root of a given web element. This endpoint is + supported by geckodriver when using Firefox version ≥96. + +- Support for additional hosts and origins + + Users can now specify a list of allowed `Host` and `Origin` headers for + incoming requests using the [`--allow-hosts`] and [`--allow-origins`] command + line options, respectively. When such a flag is provided, exactly the given + values will be permitted. + + By default any request with an `Origin` header is rejected, and only requests + containing the bound hostname (specified via `--host`), or an IP address, + in the Host header are allowed. These configuration options are + designed to support scenarios where geckodriver is running on a different + network node to the host e.g. some container based setups. + +### Fixed + +- Geckodriver lets Marionette itself select a system allocated port, so that + it's no longer required to specify a fixed port when using a custom Firefox + profile. This is done by reading the `MarionetteActivePort` file of the + Firefox profile in-use. This helps to avoid port collisions when multiple + Firefox instances are run in parallel. + +- It's no longer possible to specify both the `androidPackage` and `binary` + capabilities togther within [`moz:firefoxOptions`] because these capabilites + are mutually exclusive. + +## 0.30.0 (2021-09-16, `d372710b98a6`) + +### Security Fixes + +- CVE-2021-4138 + + Fixed a DNS rebinding issues by enforcing a stricter `Host` header check. + + Reported by Gabriel Corona. + + - Improved `Host` header checks to reject requests not sent to a well-known + local hostname or IP, or the server-specified hostname. + +### Known problems + +- geckodriver restricts connections to local IP addresses. This can interfere + with deployments in which geckodriver is running on a different network node + to the tests e.g. some container or virtual-machine based setups. + +- _macOS 10.15 (Catalina) and later:_ + + Due to the requirement from Apple that all programs must be + notarized, geckodriver will not work on Catalina if you manually + download it through another notarized program, such as Firefox. + + Whilst we are working on a repackaging fix for this problem, you can + find more details on how to work around this issue in the [macOS + notarization] section of the documentation. + +- _Android:_ + + For releases of Firefox 89.0 and earlier Marionette will only be enabled in + GeckoView based applications when the Firefox preference + `devtools.debugger.remote-enabled` is set to `true` via [`moz:firefoxOptions`]. + +### Added + +- Support for WebDriver clients to opt in to WebDriver BiDi. + + Introduced the new boolean capability [`webSocketUrl`] that can be used by + WebDriver clients to opt in to a bidirectional connection. A string capability + with the same name will be returned by [`NewSession`], which contains the + WebSocket URL of the newly created WebDriver session in the form of: + `ws://host:port/session/<session id>`. + + When running on Android a port forward will be set on the host machine, + which is using the exact same port as on the device. + + All the supported WebDriver BiDi commands depend on the version of + Firefox, and not geckodriver. The first commands will be shipped in + Firefox 94. + +- It's now possible to set additional preferences when a custom profile has been + specified. At the end of the session they will be removed. + +### Fixed + +- Added validation that the `--host` argument resolves to a local IP address. + +- Limit the `--foreground` argument of Firefox to MacOS only. + +- Increased Marionette handshake timeout to not fail for slow connections. + +- `Marionette:Quit` is no longer sent twice during session deletion. + +- When deleting a session that was attached to an already running browser + instance, the browser is not getting closed anymore. + +- Android + + - Starting Firefox on Android from a Windows based host will now succeed as + we are using the correct Unix path separator to construct on-device paths. + + - Arguments as specified in [`moz:firefoxOptions`] are now used when starting + Firefox. + + - Port forwards set for Marionette and the WebSocket server (WebDriver BiDi) + are now correctly removed when geckodriver exits. + + - The test root folder is now removed when geckodriver exists. + +## 0.29.1 (2021-04-09, `970ef713fe58`) + +### Known problems + +- _macOS 10.15 (Catalina) and later:_ + + Due to the requirement from Apple that all programs must be + notarized, geckodriver will not work on Catalina if you manually + download it through another notarized program, such as Firefox. + + Whilst we are working on a repackaging fix for this problem, you can + find more details on how to work around this issue in the [macOS + notarization] section of the documentation. + +- _Android:_ + + Marionette will only be enabled in GeckoView based applications when the + Firefox preference `devtools.debugger.remote-enabled` is set to `true` via + [`moz:firefoxOptions`]. This will be fixed in the Firefox 90 release for + Android. + +### Added + +- When testing GeckoView based applications on Android it's now enough to + specify the `androidPackage` capability. The appropriate activity name, + and required intent arguments will now automatically be used for + applications released by Mozilla. + +- Native AArch64 (M1) builds of geckodriver for MacOS are now available. These + are currently shipped as Tier2 due to missing test infrastructure. Please let + us know if you experience issues. + +### Fixed + +- Fixed a stack overflow crash in thread 'webdriver dispatcher' when + handling certain device errors. + +- Fixed an application crash due to missing permissions on unrooted devices + by changing the location of the test related files, e.g the profile folder. + Therefore the deprecated --android-storage command line argument + now defaults to the `sdcard` option, which changed its location to + `$EXTERNAL_STORAGE/Android/data/%androidPackage%/files/`. With this change + proper support for unrooted devices running Android 10+ has been added. + + _Note_: Do not use the --android-storage command line argument + anymore unless there is a strong reason. It will be removed in a future + release. + +## 0.29.0 (2021-01-14, `cf6956a5ec8e`) + +### Known problems + +- _macOS 10.15 (Catalina) and later:_ + + Due to the requirement from Apple that all programs must be + notarized, geckodriver will not work on Catalina if you manually + download it through another notarized program, such as Firefox. + + Whilst we are working on a repackaging fix for this problem, you can + find more details on how to work around this issue in the [macOS + notarization] section of the documentation. + +- _Android:_ + + Marionette will only be enabled in GeckoView based applications when the + Firefox preference `devtools.debugger.remote-enabled` is set to `true` via + [`moz:firefoxOptions`]. This will be fixed in one of the upcoming Firefox + for Android releases. + + In some cases geckodriver could crash due to a stack overflow when handling + certain device errors. + + On unrooted Android 10+ devices startup crashes of the application can be + experienced due to an inappropriate location of test related files, e.g the + profile folder. + +### Added + +- Introduced the new boolean capability [`moz:debuggerAddress`] that can be used + to opt-in to the experimental Chrome DevTools Protocol (CDP) implementation. + A string capability with the same name will be returned by [`NewSession`], + which contains the `host:port` combination of the HTTP server that can be + used to query for websockets of available targets. + + Note: For this experimental feature the site-isolation support of + Firefox aka [Fission] will be not available. + +## 0.28.0 (2020-11-03, `c00d2b6acd3f`) + +### Known problems + +- _macOS 10.15 (Catalina) and later:_ + + Due to the requirement from Apple that all programs must be + notarized, geckodriver will not work on Catalina if you manually + download it through another notarized program, such as Firefox. + + Whilst we are working on a repackaging fix for this problem, you can + find more details on how to work around this issue in the [macOS + notarization] section of the documentation. + +- _Android:_ + + Marionette will only be enabled in GeckoView based applications when the + Firefox preference `devtools.debugger.remote-enabled` is set to `true` via + [`moz:firefoxOptions`]. This will be fixed in one of the upcoming Firefox + for Android releases. + + In some cases geckodriver could crash due to a stack overflow when handling + certain device errors. + + On unrooted Android 10+ devices startup crashes of the application can be + experienced due to an inappropriate location of test related files, e.g the + profile folder. + +### Added + +- The command line flag `--android-storage` has been added, to allow geckodriver + to also control Firefox on root-less Android devices. + See the [documentation][Flags] for available values. + +### Fixed + +- Firefox can be started again via a shell script that is located outside of the + Firefox directory on Linux. + +- If Firefox cannot be started by geckodriver the real underlying error message is + now being reported. + +- Version numbers for minor and extended support releases of Firefox are now parsed correctly. + +### Removed + +- Since Firefox 72 extension commands for finding an element’s anonymous children + and querying its attributes are no longer needed, and have been removed. + +## 0.27.0 (2020-07-27, `7b8c4f32cdde`) + +### Security Fixes + +- CVE-2020-15660 + + Improved validation of incoming requests to prevent remote + requests being treated as local. + + Reported by Gabriel Corona. + + - Added additional checks on the `Content-Type` header for `POST` + requests to disallow `application/x-www-form-urlencoded`, + `multipart/form-data` and `text/plain`. + + - Added checking of the `Origin` header for `POST` requests. + + - The version number of Firefox is now checked when establishing a session. + +### Known problems + +- _macOS 10.15 (Catalina) and later:_ + + Due to the requirement from Apple that all programs must be + notarized, geckodriver will not work on Catalina if you manually + download it through another notarized program, such as Firefox. + + Whilst we are working on a repackaging fix for this problem, you can + find more details on how to work around this issue in the [macOS + notarization] section of the documentation. + +- _Android:_ + + Marionette will only be enabled in GeckoView based applications when the + Firefox preference `devtools.debugger.remote-enabled` is set to `true` via + [`moz:firefoxOptions`]. This will be fixed in one of the upcoming Firefox + for Android releases. + + In some cases geckodriver could crash due to a stack overflow when handling + certain device errors. + +### Added + +- To set environment variables for the launched Firefox for Android, + it is now possible to add an `env` object on [`moz:firefoxOptions`] + (note: this is not supported for Firefox Desktop) + +- Support for print-to-PDF + + The newly standardised WebDriver [Print] endpoint provides a way to + render pages to a paginated PDF representation. This endpoint is + supported by geckodriver when using Firefox version ≥78. + +- Support for same-site cookies + + Cookies can now be set with a `same-site` parameter, and the value + of that parameter will be returned when cookies are + retrieved. Requires Firefox version ≥79. Thanks to [Peter Major] for + the patch. + +### Fixed + +- _Android:_ + + - Firefox running on Android devices can now be controlled from a Windows host. + + - Setups with multiple connected Android devices are now supported. + + - Improved cleanup of configuration files. This prevents crashes if + the application is started manually after launching it through + geckodriver. + +- Windows and Linux binaries are again statically linked. + +## 0.26.0 (2019-10-12, `e9783a644016'`) + +Note that with this release the minimum recommended Firefox version +has changed to Firefox ≥60. + +### Known problems + +- _macOS 10.15 (Catalina) and later:_ + + Due to the recent requirement from Apple that all programs must + be notarized, geckodriver will not work on Catalina if you manually + download it through another notarized program, such as Firefox. + + Whilst we are working on a repackaging fix for this problem, you + can find more details on how to work around this issue in the + [macOS notarization] section of the documentation. + +- _Windows:_ + + You must still have the [Microsoft Visual Studio redistributable + runtime] installed on your system for the binary to run. This + is a known bug which we weren't able fix for this release. + +- _Android:_ + + Marionette will only be enabled in GeckoView based applications when the + Firefox preference `devtools.debugger.remote-enabled` is set to `true` via + [`moz:firefoxOptions`]. This will be fixed in one of the upcoming Firefox + for Android releases. + + In some cases geckodriver could crash due to a stack overflow when handling + certain device errors. + +### Added + +- Support for Firefox on Android + + Starting with this release geckodriver is able to connect to + Firefox on Android systems, and to control packages based on + [GeckoView]. + + Support for Android works by the geckodriver process running on + a host system and Firefox running within either an emulator or + on a physical device connected to the host system. This requires + you to first [enable remote debugging on the Android device]. + + The WebDriver client must set the [`platformName` capability] to + "`android`" and the `androidPackage` capability within + [`moz:firefoxOptions`] to the Android package name of the Firefox + application. + + The full list of new capabilities specific to Android, instructions + how to use them, and examples can be found in the [`moz:firefoxOptions`] + documentation on MDN. + + When the session is created, the `platformName` capability will + return "`android`" instead of reporting the platform of the host + system. + +### Changed + +- Continued Marionette refactoring changes + + 0.25.0 came with a series of internal changes for how geckodriver + communicates with Firefox over the Marionette protocol. This + release contains the second half of the refactoring work. + +### Fixed + +- Connection attempts to Firefox made more reliable + + geckodriver now waits for the Marionette handshake before assuming + the session has been established. This should improve reliability + in creating new WebDriver sessions. + +- Corrected error codes used during session creation + + When a new session was being configured with invalid input data, + the error codes returned was not always consistent. Attempting + to start a session with a malformed capabilities configuration + will now return the [`invalid argument`] error consistently. + +## 0.25.0 (2019-09-09, `bdb64cf16b68`) + +__Note to Windows users!__ +With this release you must have the [Microsoft Visual Studio redistributable runtime] +installed on your system for the binary to run. +This is a [known bug](https://github.com/mozilla/geckodriver/issues/1617) +with this particular release that we intend to release a fix for soon. + +### Added + +- Added support for HTTP `HEAD` requests to the HTTPD + + geckodriver now responds correctly to HTTP `HEAD` requests, + which can be used for probing whether it supports a particular API. + + Thanks to [Bastien Orivel] for this patch. + +- Added support for searching for Nightly’s default path on macOS + + If the location of the Firefox binary is not given, geckodriver + will from now also look for the location of Firefox Nightly in + the default locations. The ordered list of search paths on macOS + is as follows: + + 1. `/Applications/Firefox.app/Contents/MacOS/firefox-bin` + 2. `$HOME/Applications/Firefox.app/Contents/MacOS/firefox-bin` + 3. `/Applications/Firefox Nightly.app/Contents/MacOS/firefox-bin` + 4. `$HOME/Applications/Firefox Nightly.app/Contents/MacOS/firefox-bin` + + Thanks to [Kriti Singh] for this patch. + +- Support for application bundle paths on macOS + + It is now possible to pass an application bundle path, such as + `/Applications/Firefox.app` as argument to the `binary` field in + [`moz:firefoxOptions`]. This will be automatically resolved to + the absolute path of the binary when Firefox is started. + + Thanks to [Nupur Baghel] for this patch. + +- macOS and Windows builds are signed + + With this release of geckodriver, executables for macOS and Windows + are signed using the same certificate key as Firefox. This should + help in cases where geckodriver previously got misidentified as + a virus by antivirus software. + +### Removed + +- Dropped support for legacy Selenium web element references + + The legacy way of serialising web elements, using `{"ELEMENT": <UUID>}`, + has been removed in this release. This may break older Selenium + clients and clients which are otherwise not compatible with the + WebDriver standard. + + Thanks to [Shivam Singhal] for this patch. + +- Removed `--webdriver-port` command-line option + + `--webdriver-port <PORT>` was an undocumented alias for `--port`, + initially used for backwards compatibility with clients + prior to Selenium 3.0.0. + +### Changed + +- Refactored Marionette serialisation + + Much of geckodriver’s internal plumbing for serialising WebDriver + requests to Marionette messages has been refactored to decrease + the amount of manual lifting. + + This work should have no visible side-effects for users. + + Thanks to [Nupur Baghel] for working on this throughout her + Outreachy internship at Mozilla. + +- Improved error messages for incorrect command-line usage + +### Fixed + +- Errors related to incorrect command-line usage no longer hidden + + By mistake, earlier versions of geckodriver failed to print incorrect + flag use. With this release problems are again written to stderr. + +- Search system path for Firefox binary on BSDs + + geckodriver would previously only search the system path for the + `firefox` binary on Linux. Now it supports different BSD flavours + as well. + +## 0.24.0 (2019-01-28, `917474f3473e`) + +### Added + +- Introduces `strictFileInteractability` capability + + The new capability indicates if strict interactability checks + should be applied to `<input type=file>` elements. As strict + interactability checks are off by default, there is a change + in behaviour when using [Element Send Keys] with hidden file + upload controls. + +- Added new endpoint `GET /session/{session id}/moz/screenshot/full` + for taking full document screenshots, thanks to Greg Fraley. + +- Added new `--marionette-host <hostname>` flag for binding to a + particular interface/IP layer on the system. + +- Added new endpoint `POST /session/{session_id}/window/new` + for the [New Window] command to create a new top-level browsing + context, which can be either a window or a tab. The first version + of Firefox supporting this command is Firefox 66.0. + +- When using the preference `devtools.console.stdout.content` set to + `true` logging of console API calls like `info()`, `warn()`, and + `error()` can be routed to stdout. + +- geckodriver now sets the `app.update.disabledForTesting` preference + to prevent Firefox >= 65 from automatically updating whilst under + automation. + +### Removed + +- ARMv7 HF builds have been discontinued + + We announced back in September 2018 that we would stop building for ARM, + but builds can be self-serviced by building from source. + + To cross-compile from another host system, you can use this command: + + % cargo build --target armv7-unknown-linux-gnueabihf + +### Changed + +- Allow file uploads to hidden `<input type=file>` elements + + Through a series of changes to the WebDriver specification, + geckodriver is now aligned with chromedriver’s behaviour that + allows interaction with hidden `<input type=file>` elements. + + This allows WebDriver to be used with various popular web + frameworks that—through indirection—hides the file upload control + and invokes it through other means. + +- Allow use of an indefinite script timeout for the [Set Timeouts] + command, thanks to reimu. + +### Fixed + +- Corrected `Content-Type` of response header to `utf-8` to fix + an HTTP/1.1 compatibility bug. + +- Relaxed the deserialization of timeouts parameters to allow unknown + fields for the [Set Timeouts] command. + +- Fixed a regression in the [Take Element Screenshot] to not screenshot + the viewport, but the requested element. + +## 0.23.0 (2018-10-03) + +This release contains a number of fixes for regressions introduced +in 0.22.0, where we shipped a significant refactoring to the way +geckodriver internally dealt with JSON serialisation. + +### Removed + +- The POST `/session/{session id}/element/{element id}/tap` endpoint + was removed, thanks to Kerem Kat. + +### Changed + +- [webdriver crate] upgraded to 0.38.0. + +### Fixed + +- `desiredCapabilities` and `requiredCapabilities` are again + recognised on session creation + + A regression in 0.22.0 caused geckodriver to recognise `desired` + and `required` instead of the correct `desiredCapabilities` + and `requiredCapabilities`. This will have caused significant + problems for users who relied on this legacy Selenium-style + session creation pattern. + + Do however note that support for Selenium-styled new session + requests is temporary and that this will be removed sometime + before the 1.0 release. + +- `duration` field made optional on pause actions + + A regression in 0.22.0 caused the pause action primitive to + require a `duration` field. This has now been fixed so that + pauses in action chains can be achieved with the default duration. + +- Log level formatted to expected Marionette input + + A regression in 0.22.0 caused the log level to be improperly + formatted when using Firefox pre-releases. This is now fixed so + that the requested log level is correctly interpreted by Marionette. + +- `temporary` field on addon installation made optional + + A regression in 0.22.0 caused the `temporary` field for POST + `/session/{session id}/moz/addon/install` to be mandatory. This has + now been fixed so that an addon is installed permanently by default. + +- SHA1s in version information uses limited number of characters + + The SHA1 used in `--version` when building geckodriver from a + git repository is now limited to 12 characters, as it is when + building from an hg checkout. This ensures reproducible builds. + +## 0.22.0 (2018-09-15) + +This release marks an important milestone on the path towards +a stable release of geckodriver. Large portions of geckodriver +and the [webdriver] library it is based on has been refactored to +accommodate using [serde] for JSON serialization. + +We have also made great strides to improving [WebDriver conformance], +to the extent that geckodriver is now _almost_ entirely conforming +to the standard. + +### Added + +- Support for WebDriver web element-, web frame-, and web window + identifiers from Firefox. + +- Added support for the non-configurable `setWindowRect` capability + from WebDriver. + + This capability informs whether the attached browser supports + manipulating the window dimensions and position. + +- A new extension capability `moz:geckodriverVersion` is returned + upon session creation. + +### Changed + +- All JSON serialization and deserialisation has moved from + rustc_serialize to [serde]. + +- The HTTP status codes used for [script timeout] and [timeout] + errors has changed from Request Timeout (408) to Internal Server + Error (500) in order to not break HTTP/1.1 `Keep-Alive` support, + as HTTP clients interpret the old status code to mean they should + duplicate the request. + +- The HTTP/1.1 `Keep-Alive` timeout for persistent connections has + been increased to 90 seconds. + +- An [invalid session ID] error is now returned when there is no + active session. + +- An [invalid argument] error is now returned when [Add Cookie] + is given invalid parameters. + +- The handshake when geckodriver connects to Marionette has been + hardened by killing the Firefox process if it fails. + +- The handshake read timeout has been reduced to 10 seconds instead + of waiting forever. + +- The HTTP server geckodriver uses, [hyper], has been upgraded to + version 0.12, thanks to [Bastien Orivel]. + +- geckodriver version number is no longer logged on startup, as + the log level is not configured until a session is created. + + The version number is available through `--version`, and now + also through a new `moz:geckodriverVersion` field in the matched + capabilities. + +- [webdriver crate] upgraded to 0.37.0. + +### Fixed + +- Parsing [timeout object] values has been made WebDriver conforming, + by allowing floats as input. + +- Implicit downloads of OpenH264 and Widevine plugins has been disabled. + +- The commit hash and date displayed when invoking `--version` + is now well-formatted when built from an hg repository, thanks to + [Jeremy Lempereur]. + +- Many documentation improvements, now published on + <https://firefox-source-docs.mozilla.org/testing/geckodriver/>. + +## 0.21.0 (2018-06-15) + +Note that with this release of geckodriver the minimum recommended +Firefox and Selenium versions have changed: + +- Firefox 57 (and greater) +- Selenium 3.11 (and greater) + +### Added + +- Support for the chrome element identifier from Firefox. + +- The `unhandledPromptBehavior` capability now accepts `accept and + notify`, `dismiss and notify`, and `ignore` options. + + Note that the unhandled prompt handler is not fully supported in + Firefox at the time of writing. + +### Changed + +- Firefox will now be started with the `-foreground` and `-no-remote` + flags if they have not already been specified by the user in + [`moz:firefoxOptions`]. + + `-foreground` will ensure the application window gets focus when + Firefox is started, and `-no-remote` will prevent remote commands + to this instance of Firefox and also ensure we always start a new + instance. + +- WebDriver commands that do not have a return value now correctly + return `{value: null}` instead of an empty dictionary. + +- The HTTP server now accepts `Keep-Alive` connections. + +- Firefox remote protocol command mappings updated. + + All Marionette commands changed to make use of the `WebDriver:` + prefixes introduced with Firefox 56. + +- Overhaul of Firefox preferences. + + Already deprecated preferences in Firefox versions earlier than + 57 got removed. + +- [webdriver crate] upgraded to 0.36.0. + +### Fixed + +- Force use of IPv4 network stack. + + On certain system configurations, where `localhost` resolves to + an IPv6 address, geckodriver would attempt to connect to Firefox + on the wrong IP stack, causing the connection attempt to time out + after 60 seconds. We now ensure that geckodriver uses IPv4 + consistently to both connect to Firefox and for allocating a free + port. + +- geckodriver failed to locate the correct Firefox binary if it was + found under a _firefox_ or _firefox-bin_ directory, depending on + the system, because it thought the parent directory was the + executable. + +- On Unix systems (macOS, Linux), geckodriver falsely reported + non-executable files as valid binaries. + +- When stdout and stderr is redirected by geckodriver, a bug prevented + the redirections from taking effect. + +## 0.20.1 (2018-04-06) + +### Fixed + +- Avoid attempting to kill Firefox process that has stopped. + + With the change to allow Firefox enough time to shut down in + 0.20.0, geckodriver started unconditionally killing the process + to reap its exit status. This caused geckodriver to inaccurately + report a successful Firefox shutdown as a failure. + + The regression should not have caused any functional problems, but + the termination cause and the exit status are now reported correctly. + +## 0.20.0 (2018-03-08) + +### Added + +- New `--jsdebugger` flag to open the [Browser Toolbox] when Firefox + launches. This is useful for debugging Marionette internals. + +- Introduced the temporary, boolean capability + `moz:useNonSpecCompliantPointerOrigin` to disable the WebDriver + conforming behavior of calculating the Pointer Origin. + +### Changed + +- HTTP status code for the [`StaleElementReference`] error changed + from 400 (Bad Request) to 404 (Not Found). + +- Backtraces from geckodriver no longer substitute for missing + Marionette stacktraces. + +- [webdriver crate] upgraded to 0.35.0. + +### Fixed + +- The Firefox process is now given ample time to shut down, allowing + enough time for the Firefox shutdown hang monitor to kick in. + + Firefox has an integrated background monitor that observes + long-running threads during shutdown. These threads will be + killed after 63 seconds in the event of a hang. To allow Firefox + to shut down these threads on its own, geckodriver has to wait + that time and some additional seconds. + +- Grapheme clusters are now accepted as input for keyboard input + to actions. + + Input to the `value` field of the `keyDown` and `keyUp` action + primitives used to only accept single characters, which means + geckodriver would error when a valid grapheme cluster was sent in, + for example with the tamil nadu character U+0BA8 U+0BBF. + + Thanks to Greg Fraley for fixing this bug. + +- Improved error messages for malformed capability values. + +## 0.19.1 (2017-10-30) + +### Changed + +- Search suggestions in the location bar turned off as not to + trigger network connections + +- Block addons incompatible with E10s + +### Fixed + +- Marionette stacktraces are now correctly propagated + +- Some error messages have been clarified + +### Removed + +- Removed obsolete `socksUsername` and `socksPassword` proxy + configuration keys because neither were picked up or recognised + +## 0.19.0 (2017-09-16) + +Note that with geckodriver 0.19.0 the following versions are recommended: + +- Firefox 55.0 (and greater) +- Selenium 3.5 (and greater) + +### Added + +- Added endpoint: + - POST `/session/{session id}/window/minimize` for the [Minimize Window] + command + +- Added preference `extensions.shield-recipe-client.api_url` to disable + shield studies which could unexpectedly change the behavior of Firefox + +- Introduced the temporary, boolean capability `moz:webdriverClick` to + enable the WebDriver conforming behavior of the [Element Click] command + +- Added crashreporter environment variables to better control the browser + in case of crashes + +- Added preference `dom.file.createInChild` set to true to allow file + object creation in content processes + +### Changed + +- Log all used application arguments and not only `-marionette` + +- Early abort connection attempts to Marionette if the Firefox process + closed unexpectetly + +- Removed deprecated `socksProxyVersion` in favor of `socksVersion` + +- Removed `ftpProxyPort`, `httpProxyPort`, `sslProxyPort`, and + `socksProxyPort` because _ports_ have to be set for `ftpProxy`, + `httpProxy`, `sslProxy`, and `socksProxy` using ":<PORT>" + +- The `proxyType` `noproxy` has been replaced with `direct` in accordance + with recent WebDriver specification changes + +- The [`WindowRectParameters`] have been updated to return signed 32-bit + integers in accordance with the CSS and WebDriver specifications, and + to be more liberal with the input types + +- Mapped the [`FullscreenWindow`] to the correct Marionette command + +- To make sure no browser process is left behind when the [`NewSession`] + fails, the process is closed immediately now + +- `/moz/addon/install` command accepts an `addon` parameter, in lieu of + `path`, containing an addon as a Base64 string (fixed by [Jason Juang]) + +- [webdriver crate] upgraded to version 0.31.0 + +- [mozrunner crate] upgraded to version 0.5.0 + +### Removed + +- Removed the following obsolete preferences for Firefox: + - `browser.safebrowsing.enabled` + - `browser.safebrowsing.forbiddenURIs.enabled` + - `marionette.defaultPrefs.port` + - `marionette.logging` + +## 0.18.0 (2017-07-10) + +### Changed + +- [`RectResponse`] permits returning floats for `width` and `height` + fields + +- New type [`CookieResponse`] for the [`GetNamedCookie`] command returns + a single cookie, as opposed to an array of a single cookie + +- To pick up a prepared profile from the filesystem, it is now possible + to pass `["-profile", "/path/to/profile"]` in the `args` array on + [`moz:firefoxOptions`] + +- geckodriver now recommends Firefox 53 and greater + +- Version information (`--version`) contains the hash from from the + commit used to build geckodriver + +- geckodriver version logged on startup + +- [webdriver crate] upgraded to version 0.27.0 + +- [mozrunner crate] upgraded to version 0.4.1 + +### Fixed + +- The [`SetTimeouts`] command maps to the Marionette `setTimeouts` + command, which makes geckodriver compatible with Firefox 56 and greater + +- Linux x86 (i686-unknown-linux-musl) builds are fixed + +## 0.17.0 (2017-06-09) + +### Added + +- Added endpoints: + - POST `/session/{session id}/window/fullscreen` to invoke the window + manager-specific `full screen` operation + - POST `/session/{session id}/moz/addon/install` to install an extension + (Gecko only) + - POST `/session/{session id}/moz/addon/uninstall` to uninstall an + extension (Gecko only) + +### Changed + +- Increasing the length of the `network.http.phishy-userpass-length` + preference will cause Firefox to not prompt when navigating to a + website with a username or password in the URL + +- Library dependencies upgraded to mozrunner 0.4 and mozprofile 0.3 + to allow overriding of preferences via capabilities if those have been + already set in the profile + +- Library dependencies upgraded to mozversion 0.1.2 to only use the + normalized path of the Firefox binary for version checks but not to + actually start the browser, which broke several components in Firefox + on Windows + +### Fixed + +- The [SetWindowRect] command now returns the [WindowRectResponse] + when it is done + +- Use ASCII versions of array symbols to properly display them in the + Windows command prompt + +- Use [`SessionNotCreated`] error instead of [`UnknownError`] if there + is no current session + +## 0.16.1 (2017-04-26) + +### Fixed + +- Read Firefox version number from stdout when failing + to look for the application .ini file (fixes [Selenium + #3884](https://github.com/SeleniumHQ/selenium/issues/3884)) + +- Session is now ended when closing the last Firefox window (fixes + [#613](https://github.com/mozilla/geckodriver/issues/613)) + +## 0.16.0 (2017-04-21) + +Note that geckodriver v0.16.0 is only compatible with Selenium 3.4 +and greater. + +### Added + +- Support for WebDriver-conforming [New Session] negotiation, with + `desiredCapabilities`/`requiredCapabilities` negotiation as fallback + +- Added two new endpoints: + - GET `/session/{session id}/window/rect` for [Get Window Rect] + - POST `/session/{session id}/window/rect` for [Set Window Rect] + +- Align errors with the [WebDriver errors]: + - Introduces new errors [`ElementClickIntercepted`], + [`ElementNotInteractable`], [`InvalidCoordinates`], [`NoSuchCookie`], + [`UnableToCaptureScreen`], and [`UnknownCommand`] + - Removes `ElementNotVisible` and `InvalidElementCoordinates` errors + +### Removed + +- Removed following list of unused endpoints: + - GET `/session/{session id}/alert_text` + - POST `/session/{session id}/alert_text` + - POST `/session/{session id}/accept_alert` + - POST `/session/{session id}/dismiss_alert` + - GET `/session/{session id}/window_handle` + - DELETE `/session/{session id}/window_handle` + - POST `/session/{session id}/execute_async` + - POST `/session/{session id}/execute` + +### Changed + +- [`SendKeysParameters`], which is used for the [Element Send Keys] and + [Send Alert Text] commands, has been updated to take a string `text` + field + +- [`CookieResponse`] and [`CloseWindowResponse`] fixed to be properly + wrapped in a `value` field, like other responses + +- Allow negative numbers for `x` and `y` fields in `pointerMove` action + +- Disable Flash and the plugin container in Firefox by + default, which should help mitigate the “Plugin Container + for Firefox has stopped working” problems [many users were + reporting](https://github.com/mozilla/geckodriver/issues/225) when + deleting a session + +- Preferences passed in a profile now take precedence over + set of default preferences defined by geckodriver (fixed by + [Marc Fisher](https://github.com/DrMarcII)) + - The exceptions are the `marionette.port` and `marionette.log.level` + preferences and their fallbacks, which are set unconditionally and + cannot be overridden + +- Remove default preference that disables unsafe CPOW checks + +- WebDriver library updated to 0.25.2 + +### Fixed + +- Fix for the “corrupt deflate stream” exception that + sometimes occurred when trying to write an empty profile by + [@kirhgoph](https://github.com/kirhgoph) + +- Recognise `sslProxy` and `sslProxyPort` entries in the proxy + configuration object (fixed by [Jason Juang]) + +- Fix “`httpProxyPort` was not an integer” error (fixed by [Jason + Juang]) + +- Fix broken unmarshaling of _Get Timeouts_ response format from Firefox + 52 and earlier (fixed by [Jason Juang]) + +- Allow preferences in [`moz:firefoxOptions`] to be both positive- and + negative integers (fixed by [Jason Juang]) + +- Allow IPv6 hostnames in the proxy configuration object + +- i686-unknown-linux-musl (Linux 32-bit) build fixed + +- Log messages from other Rust modules are now ignored + +- Improved log messages to the HTTPD + +## 0.15.0 (2017-03-08) + +### Added + +- Added routing and parsing for the [Get Timeouts] command + +### Changed + +- All HTTP responses are now wrapped in `{value: …}` objects per the + WebDriver specification; this may likely require you to update your + client library + +- Pointer move action’s `element` key changed to `origin`, which + lets pointer actions originate within the context of the viewport, + the pointer’s current position, or from an element + +- Now uses about:blank as the new tab document; this was previously + disabled due to [bug 1333736](https://bugzil.la/1333736) in Marionette + +- WebDriver library updated to 0.23.0 + +### Fixed + +- Aligned the data structure accepted by the [Set Timeouts] command with + the WebDriver specification + +## 0.14.0 (2017-01-31) + +### Changed + +- Firefox process is now terminated and session ended when the last + window is closed + +- WebDriver library updated to version 0.20.0 + +### Fixed + +- Stacktraces are now included when the error originates from within + the Rust stack + +- HTTPD now returns correct response headers for `Content-Type` and + `Cache-Control` thanks to [Mike Pennisi] + +## 0.13.0 (2017-01-06) + +### Changed + +- When navigating to a document with an insecure- or otherwise invalid + TLS certificate, an [insecure certificate] error will be returned + +- On macOS, deducing Firefox’ location on the system will look for + _firefox-bin_ on the system path (`PATH` environmental variable) before + looking in the applications folder + +- Window position coordinates are allowed to be negative numbers, to + cater for maximised window positioning on Windows + +- WebDriver library updated to version 0.18.0 + +### Fixed + +- Check for single-character key codes in action sequences now counts + characters instead of bytes + +## 0.12.0 (2017-01-03) + +### Added + +- Added [Take Element Screenshot] command + +- Added new [Status] command + +- Added routing for the [Get Timeouts] command, but it is not yet + implemented in Marionette, and will return an _unsupported operation_ + error until it is + +- Implemented routing for [new actions API](Actions), but it too is not + yet fully implemented in Marionette + +### Changed + +- [Synced Firefox + preferences](https://github.com/mozilla/geckodriver/commit/2bfdc3ec8151c427a6a75a6ba3ad203459540495) + with those used in Mozilla automation + +- Default log level for debug builds of Firefox, which used to be `DEBUG`, + changed to `INFO`-level + +- WebDriver library dependency upgraded to 0.17.1 + +- Using _session not created_ error when failing to start session + +- geckodriver will exit with exit code 69 to indicate that the port + is unavailable + +### Fixed + +- Improved logging when starting Firefox + +- Reverted to synchronous logging, which should address cases of + inconsistent output when failing to bind to port + +- Clarified in README that geckodriver is not supported on Windows XP + +- Added documentation of supported capabilities to [README] + +- Included capabilities example in the [README] + +## 0.11.1 (2016-10-10) + +### Fixed + +- Version number in binary now reflects the release version + +## 0.11.0 (2016-10-10) + +### Added + +- Introduced continuous integration builds for Linux- and Windows 32-bit + binaries + +- Added commands for setting- and getting the window position + +- Added new extension commands for finding an element’s anonymous + children and querying its attributes; accessible through the + `/session/{sessionId}/moz/xbl/{elementId}/anonymous_children` + to return all anonymous children and + `/session/{sessionId}/moz/xbl/{elementId}/anonymous_by_attribute` to + return an anonymous element by a name and attribute query + +- Introduced a [`moz:firefoxOptions`] capability to customise a Firefox + session: + + - The `binary`, `args`, and `profile` entries on this dictionary + is equivalent to the old `firefox_binary`, `firefox_args`, and + `firefox_profile` capabilities, which have now all been removed + + - The `log` capability takes a dictionary such as `{log: "trace"}` + to enable trace level verbosity in Gecko + + - The `prefs` capability lets you define Firefox preferences through + capabilities + +- Re-introduced the `--webdriver-port` argument as a hidden alias to + `--port` + +### Changed + +- `firefox_binary`, `firefox_args`, and `firefox_profile` capabilities + removed in favour of the [`moz:firefoxOptions`] dictionary detailed above + and in the [README] + +- Removed `--no-e10s` flag, and geckodriver will from now rely on the + Firefox default multiprocessing settings (override using preferences) + +- Disable pop-up blocker in the default profile by @juangj + +- Changed Rust compiler version to 1.12 (beta) + temporarily because of [trouble linking Musl + binaries](https://github.com/rust-lang/rust/issues/34978) + +- Replaced _env_logger_ logging facility with the _slog_ package, + causing the `RUST_LOG` environment variable to no longer have any affect + +- Updated the WebDriver Rust library to version 0.15 + +### Fixed + +- Corrected link to repository in Cargo metadata + +- Verbosity shorthand flag `-v[v]` now works again, following the + replacement of the argument parsing library in the previous release + +- When the HTTPD fails to start, errors are propagated to the user + +- Disabled the additional welcome URL + (`startup.homepage_welcome_url.additional`) so that officially branded + Firefox builds do not start with two open tabs in fresh profiles + +- Disabled homepage override URL redirection on milestone upgrades, + which means a tab with an upgrade notice is not displayed when launching + a new Firefox version + +## 0.10.0 (2016-08-02) + +### Changed + +- Use multi-process Firefox (e10s) by default, added flag `--no-e10s` + to disable it and removed `--e10s` flag + +- Disable autofilling of forms by default by [Sven Jost] + +- Replace _argparse_ with _clap_ for arguments parsing + +### Fixed + +- Attempt to deploy a single file from Travis when making a release + +- Grammar fix in [README] + +## 0.9.0 (2016-06-30) + +### Added + +- Add ability to use `firefox_binary` capability to define location of + Firefox to use + +- Automatically detect the default Firefox path if one is not given + +- Cross-compile to Windows and ARMv7 (HF) in CI + +- Add Musl C library-backed static binaries in CI + +- Add `-v`, `-vv`, and `--log LEVEL` flags to increase Gecko verbosity + +- Add Get Element Property endpoint + +- Add new `--version` flag showing copying information and a link to + the repository + +### Changed + +- Now connects to a Marionette on a random port by default + +- Update webdriver-rust library dependency + +- Migrated to use Travis to deploy new releases + +- Reduced amount of logging + +- Introduced a changelog (this) + +## 0.8.0 (2016-06-07) + +### Added + +- Allow specifying array of arguments to the Firefox binary through the + `firefox_args` capability + +- Pass parameters with [New Session] command + +### Changed + +- Change product name to _geckodriver_ + +- Make README more exhaustive + +- Quit Firefox when deleting a session + +- Update webdriver-rust library + +- Update dependencies + +### Fixed + +- Fix tests + +- FIx typo in error message for parsing errors + +## 0.7.1 (2016-04-27) + +### Added + +- Add command line flag for using e10s enabled Firefox by [Kalpesh + Krishna] + +- Allow providing custom profiles + +### Changed + +- Allow binding to an IPv6 address by [Jason Juang] + +- By default, connect to host-agnostic localhost by [Jason Juang] + +- Make `GeckoContextParameters` public + +- Update dependencies + +### Fixed + +- Squash rustc 1.6 warnings by using `std::thread::sleep(dur: Duration)` + +## 0.6.2 (2016-01-20) + +### Added + +- Add LICENSE file from [Joshua Burning] + +- Schedule builds in CI on pushes and pull requests + +### Changed + +- Enable CPOWs in Marionette + +## 0.6.0 (2016-01-12) + +### Added + +- Add Get Page Source endpoint + +### Changed + +- Handle arrays being sent from Marionette + +- Correct build steps in [README] + +- Update what properties are read from errors sent by Marionette + +- Update dependencies + +## 0.5.0 (2015-12-10) + +### Changed + +- Update argparse dependency to use Cargo + +- Update to the latest version of the Marionette wire protocol + +- Update to latest webdriver-rust library + +- Update dependencies + +## 0.4.2 (2015-10-02) + +### Changed + +- Skip compiling optional items in hyper + +## 0.4.1 (2015-10-02) + +### Changed + +- Update webdriver-rust library + +- Update dependencies + +## 0.4.0 (2015-09-28) + +### Added + +- Add command extensions for switching between content- and chrome + contexts + +- Add more documentation from [Vlad Filippov] + +### Changed + +- Update Cargo.lock with new dependencies for building + +- Update for protocol updates that flatten commands + +- Update to new protocol error handling + +- Update for Marionette protocol version 3 changes + +- Strip any leading and trailing `{}` from the `sessionId` Marionette + returns + +- Update dependencies + +### Fixed + +- Fix `GetCSSValue` message to send correct key `propertyName` + +- Fix example in documentation from @vladikoff + +## 0.3.0 (2015-08-17) + +### Added + +- Add support for finding elements in subtrees + +## 0.2.0 (2015-05-20) + +### Added + +- Extra debug messages + +- Add ability to set WebDriver port + +- Add support for getting the active element + +- Add support for `GetCookies` and `DeleteCookie`/`DeleteCookies` + +- Add preferences that switch off certain features not required for + WebDriver tests + +### Changed + +- Make failing to communicate with Firefox a fatal error that closes + the session + +- Shut down session only when losing connection + +- Better handling of missing command line flags + +- Poll for connection every 100ms rather than every 100s + +- Switch to string-based error codes + +- Switch webdriver-rust library dependency to be pulled from git + +- Update dependencies + +### Fixed + +- Handle null id for switching to frame more correctly + +## 0.1.0 (2015-04-09) + +### Added + +- Add proxy for converting WebDriver HTTP protocol to Marionette protocol + +- Add endpoints for modal dialogue support + +- Allow connecting to a running Firefox instance + +- Add explicit Cargo.lock file + +- Start Firefox when we get a [NewSession] command + +- Add flag parsing and address parsing + +- Add basic error handling + +### Changed + +- Update for Rust beta + +- Switch to new IO libraries + +- Pin webdriver-rust commit so we can upgrade rustc versions independently + +- Set preferences when starting Firefox + +- Improve some error messages + +- Re-enable environment variable based logging + +### Fixed + +- Fix Get Element Rect command to return floats instead of integers + +- Fix passing of web elements to Switch To Frame command + +- Fix serialisation of script commands + +- Fix assorted bugs found by the Selenium test suite + +- Fix conversion of Find Element/Find Elements responses from Marionette + to WebDriver + +- Fixed build by updating Cargo.lock with new dependencies for building + +- Squash compile warnings + +[README]: https://github.com/mozilla/geckodriver/blob/master/README.md +[usage documentation]: <https://firefox-source-docs.mozilla.org/testing/geckodriver/Usage.html#Running-Firefox-in-an-container-based-package> +[Browser Toolbox]: https://developer.mozilla.org/en-US/docs/Tools/Browser_Toolbox +[WebDriver conformance]: https://wpt.fyi/results/webdriver/tests?label=experimental +[`webSocketUrl`]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/webSocketUrl +[`moz:firefoxOptions`]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions +[`moz:debuggerAddress`]: https://firefox-source-docs.mozilla.org/testing/geckodriver/Capabilities.html#moz-debuggeraddress +[Microsoft Visual Studio redistributable runtime]: https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads +[GeckoView]: https://wiki.mozilla.org/Mobile/GeckoView +[Fission]: https://wiki.mozilla.org/Project_Fission +[Capabilities]: https://firefox-source-docs.mozilla.org/testing/geckodriver/Capabilities.html +[Flags]: https://firefox-source-docs.mozilla.org/testing/geckodriver/Flags.html +[`--allow-hosts`]: https://firefox-source-docs.mozilla.org/testing/geckodriver/Flags.html#code-allow-hosts-var-allow-hosts-var-code +[`--allow-origins`]: https://firefox-source-docs.mozilla.org/testing/geckodriver/Flags.html#code-allow-origins-var-allow-origins-var-code +[enable remote debugging on the Android device]: https://developers.google.com/web/tools/chrome-devtools/remote-debugging +[macOS notarization]: https://firefox-source-docs.mozilla.org/testing/geckodriver/Notarization.html +[Rust]: https://rustup.rs/ +[mozilla.org] https://www.mozilla.org/firefox/ + +[`CloseWindowResponse`]: https://docs.rs/webdriver/newest/webdriver/response/struct.CloseWindowResponse.html +[`CookieResponse`]: https://docs.rs/webdriver/newest/webdriver/response/struct.CookieResponse.html +[`DeleteSession`]: https://docs.rs/webdriver/newest/webdriver/command/enum.WebDriverCommand.html#variant.DeleteSession +[`ElementClickIntercepted`]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html#variant.ElementClickIntercepted +[`ElementNotInteractable`]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html#variant.ElementNotInteractable +[`FullscreenWindow`]: https://docs.rs/webdriver/newest/webdriver/command/enum.WebDriverCommand.html#variant.FullscreenWindow +[`GetNamedCookie`]: https://docs.rs/webdriver/newest/webdriver/command/enum.WebDriverCommand.html#variant.GetNamedCookie +[`GetWindowRect`]: https://docs.rs/webdriver/newest/webdriver/command/enum.WebDriverCommand.html#variant.GetWindowRect +[`InvalidCoordinates`]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html#variant.InvalidCoordinates +[`MaximizeWindow`]: https://docs.rs/webdriver/newest/webdriver/command/enum.WebDriverCommand.html#variant.MaximizeWindow +[`MinimizeWindow`]: https://docs.rs/webdriver/newest/webdriver/command/enum.WebDriverCommand.html#variant.MinimizeWindow +[`NewSession`]: https://docs.rs/webdriver/newest/webdriver/command/enum.WebDriverCommand.html#variant.NewSession +[`NoSuchCookie`]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html#variant.NoSuchCookie +[`RectResponse`]: https://docs.rs/webdriver/0.27.0/webdriver/response/struct.RectResponse.html +[`SendKeysParameters`]: https://docs.rs/webdriver/newest/webdriver/command/struct.SendKeysParameters.html +[`SessionNotCreated`]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html#variant.SessionNotCreated +[`SetTimeouts`]: https://docs.rs/webdriver/newest/webdriver/command/enum.WebDriverCommand.html#variant.SetTimeouts +[`SetWindowRect`]: https://docs.rs/webdriver/newest/webdriver/command/enum.WebDriverCommand.html#variant.SetWindowRect +[`StaleElementReference`]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html#variant.StaleElementReference +[`UnableToCaptureScreen`]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html#variant.UnableToCaptureScreen +[`UnknownCommand`]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html#variant.UnknownCommand +[`UnknownError`]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html#variant.UnknownError +[`WindowRectParameters`]: https://docs.rs/webdriver/newest/webdriver/command/struct.WindowRectParameters.html + +[Add Cookie]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Commands/AddCookie +[invalid argument]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Errors/InvalidArgument +[invalid session id]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Errors/InvalidSessionID +[script timeout]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Errors/ScriptTimeout +[timeout]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Errors/Timeout +[timeout object]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Timeouts +[`platformName` capability]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities#platformName + +[hyper]: https://hyper.rs/ +[mozrunner crate]: https://crates.io/crates/mozrunner +[serde]: https://serde.rs/ +[webdriver crate]: https://crates.io/crates/webdriver + +[Actions]: https://w3c.github.io/webdriver/webdriver-spec.html#actions +[Element Click]: https://w3c.github.io/webdriver/webdriver-spec.html#element-click +[Find Element From Shadow Root]: https://w3c.github.io/webdriver/#dfn-find-element-from-shadow-root +[Find Elements From Shadow Root]: https://w3c.github.io/webdriver/#dfn-find-elements-from-shadow-root +[Get Computed Label]: https://w3c.github.io/webdriver/#get-computed-label +[Get Computed Role]: https://w3c.github.io/webdriver/#get-computed-role +[Get Element Shadow Root]: https://w3c.github.io/webdriver/#get-element-shadow-root +[Get Timeouts]: https://w3c.github.io/webdriver/webdriver-spec.html#get-timeouts +[Get Window Rect]: https://w3c.github.io/webdriver/webdriver-spec.html#get-window-rect +[insecure certificate]: https://w3c.github.io/webdriver/webdriver-spec.html#dfn-insecure-certificate +[Minimize Window]: https://w3c.github.io/webdriver/webdriver-spec.html#minimize-window +[New Session]: https://w3c.github.io/webdriver/webdriver-spec.html#new-session +[New Window]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Commands/New_Window +[Print]: https://w3c.github.io/webdriver/webdriver-spec.html#print +[Send Alert Text]: https://w3c.github.io/webdriver/webdriver-spec.html#send-alert-text +[Set Timeouts]: https://w3c.github.io/webdriver/webdriver-spec.html#set-timeouts +[Set Window Rect]: https://w3c.github.io/webdriver/webdriver-spec.html#set-window-rect +[Status]: https://w3c.github.io/webdriver/webdriver-spec.html#status +[Take Element Screenshot]: https://w3c.github.io/webdriver/webdriver-spec.html#take-element-screenshot +[WebDriver errors]: https://w3c.github.io/webdriver/webdriver-spec.html#handling-errors + +[Bastien Orivel]: https://github.com/Eijebong +[David Burns]: https://github.com/AutomatedTester +[Jason Juang]: https://github.com/juangj +[Jeremy Lempereur]: https://github.com/o0Ignition0o +[Kalpesh Krishna]: https://github.com/martiansideofthemoon +[Kriti Singh]: https://github.com/kritisingh1 +[Mike Pennisi]: https://github.com/jugglinmike +[Nupur Baghel]: https://github.com/nupurbaghel +[Peter Major]: https://github.com/aldaris +[Shivam Singhal]: https://github.com/championshuttler +[Sven Jost]: https://github/mythsunwind +[Vlad Filippov]: https://github.com/vladikoff +[Olivier Tilloy]: https://github.com/oSoMoN diff --git a/testing/geckodriver/CONTRIBUTING.md b/testing/geckodriver/CONTRIBUTING.md new file mode 100644 index 0000000000..9f3e5df604 --- /dev/null +++ b/testing/geckodriver/CONTRIBUTING.md @@ -0,0 +1,2 @@ +Please see our contributor documentation at +https://firefox-source-docs.mozilla.org/testing/geckodriver/#for-developers. diff --git a/testing/geckodriver/Cargo.toml b/testing/geckodriver/Cargo.toml new file mode 100644 index 0000000000..4f2b60ce1b --- /dev/null +++ b/testing/geckodriver/Cargo.toml @@ -0,0 +1,51 @@ +[package] +edition = "2018" +name = "geckodriver" +version = "0.33.0" +authors = ["Mozilla"] +include = [ + "/.cargo", + "/build.rs", + "/src" + ] +description = "Proxy for using WebDriver clients to interact with Gecko-based browsers." +readme = "README.md" +keywords = [ + "firefox", + "httpd", + "mozilla", + "w3c", + "webdriver", + ] +license = "MPL-2.0" +repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/geckodriver" + +[dependencies] +base64 = "0.21" +chrono = "0.4.6" +clap = { version = "~3.1", default-features = false, features = ["cargo", "std", "suggestions", "wrap_help"] } +hyper = "0.14" +lazy_static = "1.0" +log = { version = "0.4", features = ["std"] } +marionette = { path = "./marionette", version="0.4.0" } +mozdevice = { path = "../mozbase/rust/mozdevice", version="0.5.0" } +mozprofile = { path = "../mozbase/rust/mozprofile", version="0.9.0" } +mozrunner = { path = "../mozbase/rust/mozrunner", version="0.15.0" } +mozversion = { path = "../mozbase/rust/mozversion", version="0.5.0" } +regex = { version="1.0", default-features = false, features = ["perf", "std"] } +serde = "1.0" +serde_derive = "1.0" +serde_json = "1.0" +serde_yaml = "0.8" +tempfile = "3" +unicode-segmentation = "1.9" +url = "2.0" +uuid = { version = "1.0", features = ["v4"] } +webdriver = { path = "../webdriver", version="0.48.0" } +zip = { version = "0.6", default-features = false, features = ["deflate"] } + +[dev-dependencies] +tempfile = "3" + +[[bin]] +name = "geckodriver" diff --git a/testing/geckodriver/ISSUE_TEMPLATE.md b/testing/geckodriver/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..2b547946a5 --- /dev/null +++ b/testing/geckodriver/ISSUE_TEMPLATE.md @@ -0,0 +1,31 @@ +## System + +* Version: <!-- geckodriver version --> +* Platform: <!-- e.g. Linux/macOS/Windows + version --> +* Firefox: <!-- from the about dialogue --> +* Selenium: <!-- client + version --> + + +## Testcase + +<!-- +Please provide a minimal HTML document which permits the problem +to be reproduced. +--> + + +## Stacktrace + +<!-- +Error and stacktrace produced by client. +--> + + +## Trace-level log + +<!-- +See https://searchfox.org/mozilla-central/source/testing/geckodriver/doc/TraceLogs.md +for how to produce a trace-level log. + +For trace logs with more than 20 lines please add its contents as attachment. +--> diff --git a/testing/geckodriver/LICENSE b/testing/geckodriver/LICENSE new file mode 100644 index 0000000000..9c3289a4a7 --- /dev/null +++ b/testing/geckodriver/LICENSE @@ -0,0 +1,385 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions + +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions + +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities + +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation + +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination + +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ + +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * + +************************************************************************ + +************************************************************************ + +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * + +************************************************************************ + +8. Litigation + +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous + +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License + +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at <http://mozilla.org/MPL/2.0/>. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/testing/geckodriver/README.md b/testing/geckodriver/README.md new file mode 100644 index 0000000000..6329e48736 --- /dev/null +++ b/testing/geckodriver/README.md @@ -0,0 +1,85 @@ +geckodriver +=========== + +Proxy for using W3C [WebDriver] compatible clients to interact with +Gecko-based browsers. + +This program provides the HTTP API described by the [WebDriver +protocol] to communicate with Gecko browsers, such as Firefox. It +translates calls into the [Marionette remote protocol] by acting +as a proxy between the local- and remote ends. + +[WebDriver protocol]: https://w3c.github.io/webdriver/#protocol +[Marionette remote protocol]: https://firefox-source-docs.mozilla.org/testing/marionette/ +[WebDriver]: https://developer.mozilla.org/en-US/docs/Web/WebDriver + + +Downloads +--------- + +* [Releases](https://github.com/mozilla/geckodriver/releases/latest) +* [Change log](https://searchfox.org/mozilla-central/source/testing/geckodriver/CHANGES.md) + + +Documentation +------------- + +* [WebDriver] (work in progress) + * [Commands](https://developer.mozilla.org/en-US/docs/Web/WebDriver/Commands) + * [Errors](https://developer.mozilla.org/en-US/docs/Web/WebDriver/Errors) + * [Types](https://developer.mozilla.org/en-US/docs/Web/WebDriver/Types) + +* [Cross browser testing](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing) + +* [Selenium](https://seleniumhq.github.io/docs/) (work in progress) + * [C# API](https://seleniumhq.github.io/selenium/docs/api/dotnet/) + * [JavaScript API](https://seleniumhq.github.io/selenium/docs/api/javascript/) + * [Java API](https://seleniumhq.github.io/selenium/docs/api/java/) + * [Perl API](https://metacpan.org/pod/Selenium::Remote::Driver) + * [Python API](https://seleniumhq.github.io/selenium/docs/api/py/) + * [Ruby API](https://seleniumhq.github.io/selenium/docs/api/rb/) + +* [geckodriver usage](https://firefox-source-docs.mozilla.org/testing/geckodriver/Usage.html) + * [Supported platforms](https://firefox-source-docs.mozilla.org/testing/geckodriver/Support.html) + * [Firefox capabilities](https://firefox-source-docs.mozilla.org/testing/geckodriver/Capabilities.html) + * [Capabilities example](https://firefox-source-docs.mozilla.org/testing/geckodriver/Capabilities.html#capabilities-example) + * [Enabling trace logs](https://firefox-source-docs.mozilla.org/testing/geckodriver/TraceLogs.html) + * [Analyzing crash data from Firefox](https://firefox-source-docs.mozilla.org/testing/geckodriver/CrashReports.html) + +* [Contributing](https://firefox-source-docs.mozilla.org/testing/geckodriver/#for-developers) + * [Building](https://firefox-source-docs.mozilla.org/testing/geckodriver/Building.html) + * [Testing](https://firefox-source-docs.mozilla.org/testing/geckodriver/Testing.html) + * [Releasing](https://firefox-source-docs.mozilla.org/testing/geckodriver/Releasing.html) + * [Self-serving an ARM build](https://firefox-source-docs.mozilla.org/testing/geckodriver/ARM.html) + + +Source code +----------- + +geckodriver is made available under the [Mozilla Public License]. + +Its source code can be found in [mozilla-central] under testing/geckodriver. +This GitHub repository is only used for issue tracking and making releases. + +[source code]: https://hg.mozilla.org/mozilla-unified/file/tip/testing/geckodriver +[Mozilla Public License]: https://www.mozilla.org/en-US/MPL/2.0/ +[mozilla-central]: https://hg.mozilla.org/mozilla-central/file/tip/testing/geckodriver + +Custom release builds +--------------------- + +If a binary is not available for your platform, it's possibe to create a custom +build using the [Rust] toolchain. To do this, checkout the release tag for the +version of interest and run `cargo build`. Alternatively the latest version may +be built and installed from `crates.io` using `cargo install geckodriver`. + +[Rust]: https://rustup.rs/ + +Contact +------- + +The mailing list for geckodriver discussion is +https://groups.google.com/a/mozilla.org/g/dev-webdriver. + +There is also an Element channel to talk about using and developing +geckodriver on `#webdriver:mozilla.org <https://chat.mozilla.org/#/room/#webdriver:mozilla.org>`__ diff --git a/testing/geckodriver/build.rs b/testing/geckodriver/build.rs new file mode 100644 index 0000000000..eb590476b7 --- /dev/null +++ b/testing/geckodriver/build.rs @@ -0,0 +1,136 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Writes build information to ${OUT_DIR}/build-info.rs which is included in +// the program during compilation: +// +// ```no_run +// const COMMIT_HASH: Option<&'static str> = Some("c31a366"); +// const COMMIT_DATE: Option<&'static str> = Some("1988-05-10"); +// ``` +// +// The values are `None` if running hg failed, e.g. if it is not installed or +// if we are not in an hg repo. + +use std::env; +use std::ffi::OsStr; +use std::fs::File; +use std::io; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() -> io::Result<()> { + let cur_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let build_info = get_build_info(&cur_dir); + + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let mut fh = File::create(out_dir.join("build-info.rs"))?; + writeln!( + fh, + "const COMMIT_HASH: Option<&'static str> = {:?};", + build_info.hash() + )?; + writeln!( + fh, + "const COMMIT_DATE: Option<&'static str> = {:?};", + build_info.date() + )?; + + Ok(()) +} + +fn get_build_info(dir: &Path) -> Box<dyn BuildInfo> { + if Path::exists(&dir.join(".hg")) { + Box::new(Hg {}) + } else if Path::exists(&dir.join(".git")) { + Box::new(Git {}) + } else if let Some(parent) = dir.parent() { + get_build_info(parent) + } else { + eprintln!("unable to detect vcs"); + Box::new(Noop {}) + } +} + +trait BuildInfo { + fn hash(&self) -> Option<String>; + fn date(&self) -> Option<String>; +} + +struct Hg; + +impl Hg { + fn exec<I, S>(&self, args: I) -> Option<String> + where + I: IntoIterator<Item = S>, + S: AsRef<OsStr>, + { + Command::new("hg") + .env("HGPLAIN", "1") + .args(args) + .output() + .ok() + .and_then(|r| String::from_utf8(r.stdout).ok()) + .map(|s| s.trim_end().into()) + } +} + +impl BuildInfo for Hg { + fn hash(&self) -> Option<String> { + self.exec(["log", "-r.", "-T{node|short}"]) + } + + fn date(&self) -> Option<String> { + self.exec(["log", "-r.", "-T{date|isodate}"]) + } +} + +struct Git; + +impl Git { + fn exec<I, S>(&self, args: I) -> Option<String> + where + I: IntoIterator<Item = S>, + S: AsRef<OsStr>, + { + Command::new("git") + .env("GIT_CONFIG_NOSYSTEM", "1") + .args(args) + .output() + .ok() + .and_then(|r| String::from_utf8(r.stdout).ok()) + .map(|s| s.trim_end().into()) + } + + fn to_hg_sha(&self, git_sha: String) -> Option<String> { + self.exec(["cinnabar", "git2hg", &git_sha]) + } +} + +impl BuildInfo for Git { + fn hash(&self) -> Option<String> { + self.exec(["rev-parse", "HEAD"]) + .and_then(|sha| self.to_hg_sha(sha)) + .map(|mut s| { + s.truncate(12); + s + }) + } + + fn date(&self) -> Option<String> { + self.exec(["log", "-1", "--date=short", "--pretty=format:%cd"]) + } +} + +struct Noop; + +impl BuildInfo for Noop { + fn hash(&self) -> Option<String> { + None + } + fn date(&self) -> Option<String> { + None + } +} diff --git a/testing/geckodriver/doc/ARM.md b/testing/geckodriver/doc/ARM.md new file mode 100644 index 0000000000..8ae9afae58 --- /dev/null +++ b/testing/geckodriver/doc/ARM.md @@ -0,0 +1,50 @@ +# Self-serving an ARM build + +Mozilla announced the intent to deprecate ARMv7 HF builds of +geckodriver in September 2018. This does not mean you can no longer +use geckodriver on ARM systems, and this document explains how you +can self-service a build for ARMv7 HF. + +Assuming you have already checked out [central], the steps to +cross-compile ARMv7 from a Linux host system is as follows: + + 1. If you don’t have Rust installed: + + ```shell + % curl https://sh.rustup.rs -sSf | sh + ``` + + 2. Install cross-compiler toolchain: + + ```shell + % apt install gcc-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross + ``` + + 3. Create a new shell, or to reuse the existing shell: + + ```shell + % source $HOME/.cargo/env + ``` + + 4. Install rustc target toolchain: + + ```shell + % rustup target install armv7-unknown-linux-gnueabihf + ``` + + 5. Put this in [testing/geckodriver/.cargo/config]: + + ```rust + [target.armv7-unknown-linux-gnueabihf] + linker = "arm-linux-gnueabihf-gcc" + ``` + + 6. Build geckodriver from testing/geckodriver: + + ```shell + % cd testing/geckodriver + % cargo build --release --target armv7-unknown-linux-gnueabihf + ``` + +[central]: https://hg.mozilla.org/mozilla-central/ +[testing/geckodriver/.cargo/config]: https://searchfox.org/mozilla-central/source/testing/geckodriver/.cargo/config diff --git a/testing/geckodriver/doc/Bugs.md b/testing/geckodriver/doc/Bugs.md new file mode 100644 index 0000000000..b561bd41b5 --- /dev/null +++ b/testing/geckodriver/doc/Bugs.md @@ -0,0 +1,45 @@ +# Reporting bugs + +When opening a new issue or commenting on existing issues, please +make sure discussions are related to concrete technical issues +with geckodriver or Marionette. Questions or discussions are more +appropriate for the [mailing list]. + +For issue reports to be actionable, it must be clear exactly +what the observed and expected behaviours are, and how to set up +the state required to observe the erroneous behaviour. The most +useful thing to provide is a minimal HTML document which permits +the problem to be reproduced along with a [trace-level log] from +geckodriver showing the exact wire protocol calls made. + +Because of the wide variety and different charateristics of clients +used with geckodriver, their stacktraces, logs, and code examples are +typically not very useful as they distract from the actual underlying +cause. **For this reason, we cannot overstate the importance of +always providing the [trace-level log] from geckodriver.** Bugs +relating to a specific client should be filed with that project. + +We welcome you to file issues in the [GitHub issue tracker] once you are +confident it has not already been reported. The [ISSUE_TEMPLATE.md] +contains a helpful checklist for things we will want to know about +the affected system, reproduction steps, and logs. + +geckodriver development follows a rolling release model as +we don’t release patches for older versions. It is therefore +useful to use the tip-of-tree geckodriver binary, or failing this, +the latest release when verifying the problem. geckodriver is only +compatible with the current release channel versions of Firefox, and +it consequently does not help to report bugs that affect outdated +and unsupported Firefoxen. Please always try to verify the issue +in the latest Firefox Nightly before you file your bug. + +Once we are satisfied the issue raised is of sufficiently actionable +character, we will continue with triaging it and file a bug where it +is appropriate. Bugs specific to geckodriver will be filed in the +[`Testing :: geckodriver`] component in Bugzilla. + +[mailing list]: index.rst/#communication +[trace-level log]: TraceLogs.md +[GitHub issue tracker]: https://github.com/mozilla/geckodriver/issues +[ISSUE_TEMPLATE.md]: https://raw.githubusercontent.com/mozilla/geckodriver/master/ISSUE_TEMPLATE.md +[`Testing :: geckodriver`]: https://bugzilla.mozilla.org/buglist.cgi?component=geckodriver diff --git a/testing/geckodriver/doc/Building.md b/testing/geckodriver/doc/Building.md new file mode 100644 index 0000000000..49a5a51200 --- /dev/null +++ b/testing/geckodriver/doc/Building.md @@ -0,0 +1,46 @@ +# Building geckodriver + +geckodriver is written in [Rust], a systems programming language +from Mozilla. Crucially, it relies on the [webdriver crate] to +provide the HTTPD and do most of the heavy lifting of marshalling +the WebDriver protocol. geckodriver translates WebDriver [commands], +[responses], and [errors] to the [Marionette protocol], and acts +as a proxy between [WebDriver] and [Marionette]. + +To build geckodriver: + +```shell +% ./mach build testing/geckodriver +``` + +If you use artifact builds you may build geckodriver using cargo, +since mach in this case does not have a compile environment: + +```shell +% cd testing/geckodriver +% cargo build +… +Compiling geckodriver v0.21.0 (file:///code/gecko/testing/geckodriver) +… +Finished dev [optimized + debuginfo] target(s) in 7.83s +``` + +Because all Rust code in central shares the same cargo workspace, +the binary will be put in the `$(topsrcdir)/target` directory. + +You can run your freshly built geckodriver this way: + +```shell +% ./mach geckodriver -- --other --flags +``` + +See [Testing](Testing.md) for how to run tests. + +[Rust]: https://www.rust-lang.org/ +[webdriver crate]: https://crates.io/crates/webdriver +[commands]: https://docs.rs/webdriver/newest/webdriver/command/ +[responses]: https://docs.rs/webdriver/newest/webdriver/response/ +[errors]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html +[Marionette protocol]: /testing/marionette/Protocol.md +[WebDriver]: https://w3c.github.io/webdriver/ +[Marionette]: /testing/marionette/index.rst diff --git a/testing/geckodriver/doc/Capabilities.md b/testing/geckodriver/doc/Capabilities.md new file mode 100644 index 0000000000..c9eb7a5f97 --- /dev/null +++ b/testing/geckodriver/doc/Capabilities.md @@ -0,0 +1,98 @@ +# Firefox capabilities + +geckodriver has a few capabilities that are specific to Firefox. +Most of these [are documented on MDN](https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions). + +We additionally have some capabilities that largely are implementation +concerns that normal users should not care about: + +## `moz:debuggerAddress` + +A boolean value to indicate if Firefox has to be started with the +[Remote Protocol] enabled, which is a low-level debugging interface that +implements a subset of the [Chrome DevTools Protocol] (CDP). + +When enabled the returned `moz:debuggerAddress` capability of the `New Session` +command is the `host:port` combination of a server that supports the following +HTTP endpoints: + +### GET /json/version + +The browser version metadata: + +```json +{ + "Browser": "Firefox/84.0a1", + "Protocol-Version": "1.0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:84.0) Gecko/20100101 Firefox/84.0", + "V8-Version": "1.0", + "WebKit-Version": "1.0", + "webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/fe507083-2960-a442-bbd7-7dfe1f111c05" +} +``` + +### GET /json/list + +A list of all available websocket targets: + +```json +[ { + "description": "", + "devtoolsFrontendUrl": null, + "faviconUrl": "", + "id": "ecbf9028-676a-1b40-8596-a5edc0e2875b", + "type": "page", + "url": "https://www.mozilla.org/en-US/", + "browsingContextId": 29, + "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/ecbf9028-676a-1b40-8596-a5edc0e2875b" +} ] +``` + +The contained `webSocketDebuggerUrl` entries can be used to connect to the +websocket and interact with the browser by using the CDP protocol. + +[Remote Protocol]: /remote/index.rst +[Chrome DevTools Protocol]: https://chromedevtools.github.io/devtools-protocol/ + +## `moz:useNonSpecCompliantPointerOrigin` + +A boolean value to indicate how the pointer origin for an action +command will be calculated. + +With Firefox 59 the calculation will be based on the requirements +by the [WebDriver] specification. This means that the pointer origin +is no longer computed based on the top and left position of the +referenced element, but on the in-view center point. + +To temporarily disable the WebDriver conformant behavior use `false` +as value for this capability. + +Please note that this capability exists only temporarily, and that +it will be removed once all Selenium bindings can handle the new +behavior. + +## `moz:webdriverClick` + +A boolean value to indicate which kind of interactability checks +to run when performing a click or sending keys to an elements. For +Firefoxen prior to version 58.0 some legacy code as imported from +an older version of FirefoxDriver was in use. + +With Firefox 58 the interactability checks as required by the +[WebDriver] specification are enabled by default. This means +geckodriver will additionally check if an element is obscured by +another when clicking, and if an element is focusable for sending +keys. + +Because of this change in behaviour, we are aware that some extra +errors could be returned. In most cases the test in question might +have to be updated so it's conform with the new checks. But if the +problem is located in geckodriver, then please raise an issue in +the [issue tracker]. + +To temporarily disable the WebDriver conformant checks use `false` +as value for this capability. + +Please note that this capability exists only temporarily, and that +it will be removed once the interactability checks have been +stabilized. diff --git a/testing/geckodriver/doc/CrashReports.md b/testing/geckodriver/doc/CrashReports.md new file mode 100644 index 0000000000..576bdda7a9 --- /dev/null +++ b/testing/geckodriver/doc/CrashReports.md @@ -0,0 +1,67 @@ +# Analyzing crash data of Firefox + +It's not uncommon that under some special platform configurations and while +running automated tests via Selenium and geckodriver Firefox could crash. In +those cases it is very helpful to retrieve the generated crash data aka +minidump files, and report these to us. + +## Retrieve the crash data + +Because geckodriver creates a temporary user profile for Firefox, it also +automatically removes all its folders once the tests have been finished. That +also means that if Firefox crashed the created minidump files are lost. To +prevent that a custom profile has to be used instead. The following code +shows an example by using the Python Selenium bindings on Mac OS: + +```python +import tempfile + +from selenium import webdriver +from selenium.webdriver.firefox.options import Options + +# Custom profile folder to keep the minidump files +profile = tempfile.mkdtemp(".selenium") +print("*** Using profile: {}".format(profile)) + +# Use the above folder as custom profile +opts = Options() +opts.add_argument("-profile") +opts.add_argument(profile) +opts.binary = "/Applications/Firefox.app/Contents/MacOS/firefox" + +driver = webdriver.Firefox( + options=opts, + # hard-code the Marionette port so geckodriver can connect + service_args=["--marionette-port", "2828"] +) + +# Your test code which crashes Firefox +``` + +Executing the test with Selenium now, which triggers the crash of Firefox +will leave all the files from the user profile around in the above path. + +To retrieve the minidump files navigate to that folder and look for a sub +folder with the name `minidumps`. It should contain at least one series of +files. One file with the `.dmp` extension and another one with `.extra`. +Both of those files are needed. If more crash files are present grab them all. + +Attach the files as best archived as zip file to the created [geckodriver issue] +on Github. + +[geckodriver issue]: https://github.com/mozilla/geckodriver/issues/new + +## Getting details of the crash + +More advanced users can upload the generated minidump files themselves and +receive details information about the crash. Therefore find the [crash reporter] +folder and copy all the generated minidump files into the `pending` sub directory. +Make sure that both the `.dmp` and `.extra` files are present. + +Once done you can also [view the crash reports]. + +If you submitted a crash please do not forget to also add the link of the +crash report to the geckodriver issue. + +[crash reporter]: https://support.mozilla.org/kb/mozillacrashreporter#w_viewing-reports-outside-of-firefox +[view the crash reports]: https://support.mozilla.orgkb/mozillacrashreporter#w_viewing-crash-reports diff --git a/testing/geckodriver/doc/Flags.md b/testing/geckodriver/doc/Flags.md new file mode 100644 index 0000000000..3db6e27c06 --- /dev/null +++ b/testing/geckodriver/doc/Flags.md @@ -0,0 +1,216 @@ +<!-- markdownlint-disable MD033 --> +# Flags + +## <code>-\\-allow-hosts <var>ALLOW_HOSTS</var>...</code> + +Values of the `Host` header to allow for incoming requests. + +By default the value of <var>HOST</var> is allowed. If `--allow-hosts` +is provided, exactly the given values will be permitted. For example +`--allow-host geckodriver.test webdriver.local` will allow requests +with `Host` set to `geckodriver.test` or `webdriver.local`. + +Requests with `Host` set to an IP address are always allowed. + +## <code>-\\-allow-origins <var>ALLOW_ORIGINS</var>...</code> + +Values of the `Origin` header to allow for incoming requests. + +`Origin` is set by web browsers for all `POST` requests, and most +other cross-origin requests. By default any request with an `Origin` +header is rejected to protect against malicious websites trying to +access geckodriver running on the local machine. + +If `--allow-origins` is provided, web services running on the given +origin will be able to make requests to geckodriver. For example +`--allow-origins https://webdriver.test:8080` will allow a web-based +service on the origin with scheme `https`, hostname `webdriver.test`, +and port `8080` to access the geckodriver instance. + +## <code>-\\-android-storage <var>ANDROID_STORAGE</var></code> + +**Deprecation warning**: This argument is deprecated and planned to be removed +with the 0.31.0 release of geckodriver. As such it shouldn't be used with version +0.30.0 or later anymore. By default the automatic detection will now use the +external storage location, which is always readable and writeable. + +Selects the test data location on the Android device, eg. the Firefox profile. +By default `auto` is used. + +<style type="text/css"> + table { width: 100%; margin-bottom: 2em; } + table, th, td { border: solid gray 1px; } + td, th { padding: 10px; text-align: left; vertical-align: middle; } + td:nth-child(1), th:nth-child(1) { width: 10em; text-align: center; } +</style> + +<table> + <thead> + <tr> + <th>Value + <th>Description + </tr> + </thead> + + <tr> + <td>auto + <td>Best suitable location based on whether the device is rooted.<br/> + If the device is rooted `internal` is used, otherwise `app`. + <tr> + <td>app + <td><p>Location: `/data/data/%androidPackage%/test_root`</p> + Based on the `androidPackage` capability that is passed as part of + `moz:firefoxOptions` when creating a new session. Commands that + change data in the app's directory are executed using run-as. This requires + that the installed app is debuggable. + <tr> + <td>internal + <td><p>Location: `/data/local/tmp/test_root`</p> + The device must be rooted since when the app runs, files that are created + in the profile, which is owned by the app user, cannot be changed by the + shell user. Commands will be executed via `su`. + <tr> + <td>sdcard + <td><p>Location: `$EXTERNAL_STORAGE/Android/data/%androidPackage%/files/test_root`</p> + This location is supported by all versions of Android whether if the device + is rooted or not. +</table> + +## <code>-b <var>BINARY</var></code> / <code>-\\-binary <var>BINARY</var></code> + +Path to the Firefox binary to use. By default geckodriver tries to +find and use the system installation of Firefox, but that behaviour +can be changed by using this option. Note that the `binary` +capability of the `moz:firefoxOptions` object that is passed when +[creating a new session] will override this option. + +On Linux systems it will use the first _firefox_ binary found +by searching the `PATH` environmental variable, which is roughly +equivalent to calling [whereis(1)] and extracting the second column: + +```shell +% whereis firefox +firefox: /usr/bin/firefox /usr/local/firefox +``` + +On macOS, the binary is found by looking for the first _firefox-bin_ +binary in the same fashion as on Linux systems. This means it is +possible to also use `PATH` to control where geckodriver should +find Firefox on macOS. It will then look for _/Applications/Firefox.app_. + +On Windows systems, geckodriver looks for the system Firefox by +scanning the Windows registry. + +[creating a new session]: https://w3c.github.io/webdriver/#new-session +[whereis(1)]: http://www.manpagez.com/man/1/whereis/ + +## <code>-\\-connect-existing</code> + +Connect geckodriver to an existing Firefox instance. This means +geckodriver will abstain from the default of starting a new Firefox +session. + +The existing Firefox instance must have [Marionette] enabled. +To enable the remote protocol in Firefox, you can pass the +`--marionette` flag. Unless the `marionette.port` preference +has been user-set, Marionette will listen on port 2828. So when +using `--connect-existing` it is likely you will also have to use +`--marionette-port` to set the correct port. + +## <code>-\\-host <var>HOST</var></code> + +Host to use for the WebDriver server. Defaults to 127.0.0.1. + +## <code>-\\-jsdebugger</code> + +Attach [browser toolbox] debugger when Firefox starts. This is +useful for debugging [Marionette] internals. + +To be prompted at the start of the test run or between tests, +you can set the `marionette.debugging.clicktostart` preference to +`true`. + +For reference, below is the list of preferences that enables the +chrome debugger. These are all set implicitly when the +argument is passed to geckodriver. + +* `devtools.browsertoolbox.panel` -> `jsdebugger` + + Selects the Debugger panel by default. + +* `devtools.chrome.enabled` → true + + Enables debugging of chrome code. + +* `devtools.debugger.prompt-connection` → false + + Controls the remote connection prompt. Note that this will + automatically expose your Firefox instance to localhost. + +* `devtools.debugger.remote-enabled` → true + + Allows a remote debugger to connect, which is necessary for + debugging chrome code. + +[browser toolbox]: https://developer.mozilla.org/en-US/docs/Tools/Browser_Toolbox + +## <code>-\\-log <var>LEVEL</var></code> + +Set the Gecko and geckodriver log level. Possible values are `fatal`, +`error`, `warn`, `info`, `config`, `debug`, and `trace`. + +## <code>-\\-log-no-truncate</code> + +Disables truncation of long log lines. + +## <code>-\\-marionette-host <var>HOST</var></code> + +Selects the host for geckodriver’s connection to the [Marionette] +remote protocol. Defaults to 127.0.0.1. + +## <code>-\\-marionette-port <var>PORT</var></code> + +Selects the port for geckodriver’s connection to the [Marionette] +remote protocol. + +In the default mode where geckodriver starts and manages the Firefox +process, it will pick a free port assigned by the system and set the +`marionette.port` preference in the profile. + +When `--connect-existing` is used and the Firefox process is not +under geckodriver’s control, it will simply connect to <var>PORT</var>. + +`--connect-existing`: #connect-existing + +## <code>-p <var>PORT</var></code> / <code>-\\-port <var>PORT</var></code> + +Port to use for the WebDriver server. Defaults to 4444. + +A helpful trick is that it is possible to bind to 0 to get the +system to atomically assign a free port. + +## <code>-\\-profile-root <var>PROFILE_ROOT</var></code> + +Path to the directory to use when creating temporary profiles. By +default this is the system temporary directory. Both geckodriver and +Firefox must have read-write access to this path. + +This setting can be useful when Firefox is sandboxed from the host +filesystem such that it doesn't share the same system temporary +directory as geckodriver (e.g. when running Firefox inside a container +or packaged as a snap). + +## <code>-v[v]</code> + +Increases the logging verbosity by to debug level when passing +a single `-v`, or to trace level if `-vv` is passed. This is +analogous to passing `--log debug` and `--log trace`, respectively. + +## <code>-\\-websocket-port<var>PORT</var></code> + +Port to use to connect to WebDriver BiDi. Defaults to 9222. + +A helpful trick is that it is possible to bind to 0 to get the +system to atomically assign a free port. + +[Marionette]: /testing/marionette/index.rst diff --git a/testing/geckodriver/doc/Notarization.md b/testing/geckodriver/doc/Notarization.md new file mode 100644 index 0000000000..ba1ba08d64 --- /dev/null +++ b/testing/geckodriver/doc/Notarization.md @@ -0,0 +1,44 @@ +# MacOS notarization + +With the introduction of macOS 10.15 “Catalina” Apple introduced +[new notarization requirements] that all software must be signed +and notarized centrally. + +Whilst the geckodriver binary is technically both signed and notarized, the +actual validation can only be performed by MacOS if the machine that starts +the geckodriver binary for the very first time is online. Offline validation +would require shipping geckodriver as a DMG/PKG. You can track the relevant +progress in [bug 1783943]. + +Note: geckodriver releases between 0.26.0 and 0.31.0 don't have the +notarization applied and always require the manual steps below to +bypass the notarization requirement of the binary during the very first start. + +[new notarization requirements]: https://developer.apple.com/news/?id=04102019a +[bug 1783943]: https://bugzilla.mozilla.org/show_bug.cgi?id=1783943 + +## Offline mode + +There are some mitigating circumstances: + +* Verification problems only occur when other notarized programs, + such as a web browser, downloads the software from the internet. + +* Arbitrary software downloaded through other means, such as + curl(1) is _not_ affected by this change. + +In other words, if your method for fetching geckodriver on macOS +is through the GitHub web UI using a web browser, the program will +not be able to run unless you manually disable the quarantine check +(explained below). If downloading geckodriver via other means +than a macOS notarized program, you should not be affected. + +To bypass the notarization requirement on macOS if you have downloaded +the geckodriver .tar.gz via a web browser, you can run the following +command in a terminal: + + % xattr -r -d com.apple.quarantine geckodriver + +A problem with notarization will manifest itself through a security +dialogue appearing, explaining that the source of the program is +not trusted. diff --git a/testing/geckodriver/doc/Patches.md b/testing/geckodriver/doc/Patches.md new file mode 100644 index 0000000000..e559da075d --- /dev/null +++ b/testing/geckodriver/doc/Patches.md @@ -0,0 +1,31 @@ +# Submitting patches + +You can submit patches by using [Phabricator]. Walk through its documentation +in how to set it up, and uploading patches for review. Don't worry about which +person to select for reviewing your code. It will be done automatically. + +Please also make sure to follow the [commit creation guidelines]. + +Once you have contributed a couple of patches, we are happy to sponsor you in +[becoming a Mozilla committer]. When you have been granted commit access +level 1, you will have permission to use the [Firefox CI] to trigger your own +“try runs” to test your changes. You can use the following [try preset] to run +the most relevant tests: + +```shell +% ./mach try --preset geckodriver +``` + +This preset will schedule geckodriver-related tests on various platforms. You can +reduce the number of tasks by filtering on platforms (e.g. linux) or build type +(e.g. opt): + +```shell +% ./mach try --preset geckodriver -xq "'linux 'opt" +``` + +[Phabricator]: https://moz-conduit.readthedocs.io/en/latest/phabricator-user.html +[commit creation guidelines]: https://mozilla-version-control-tools.readthedocs.io/en/latest/devguide/contributing.html?highlight=phabricator#submitting-patches-for-review +[becoming a Mozilla committer]: https://www.mozilla.org/en-US/about/governance/policies/commit/ +[Firefox CI]: https://treeherder.mozilla.org/ +[try preset]: https://firefox-source-docs.mozilla.org/tools/try/presets.html diff --git a/testing/geckodriver/doc/Profiles.md b/testing/geckodriver/doc/Profiles.md new file mode 100644 index 0000000000..abad9af730 --- /dev/null +++ b/testing/geckodriver/doc/Profiles.md @@ -0,0 +1,103 @@ +# Profiles + +geckodriver uses [profiles] to instrument Firefox’ behaviour. The +user will usually rely on geckodriver to generate a temporary, +throwaway profile. These profiles are deleted when the WebDriver +session expires. + +In cases where the user needs to use custom, prepared profiles, +geckodriver will make modifications to the profile that ensures +correct behaviour. See [_Automation preferences_] below on the +precedence of user-defined preferences in this case. + +Custom profiles can be provided two different ways: + + 1. by appending `--profile /some/location` to the [`args` capability], + which will instruct geckodriver to use the profile _in-place_; + + 2. or by setting the [`profile` capability] to a Base64-encoded + ZIP of the profile directory. + +Note that geckodriver has a [known bug concerning `--profile`] that +prevents the randomised Marionette port from being passed to +geckodriver. To circumvent this issue, make sure you specify the +port manually using `--marionette-port <port>`. + +The second way is compatible with shipping Firefox profiles across +a network, when for example the geckodriver instance is running on +a remote system. This is the case when using Selenium’s `RemoteWebDriver` +concept, where the WebDriver client and the server are running on +two distinct systems. + +[profiles]: https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data +[_Automation preferences_]: #automation-preferences +[`args` capability]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions#args_array_of_strings +[`profile` capability]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions#profile_string +[known bug concerning `--profile`]: https://github.com/mozilla/geckodriver/issues/1058 + +## Default locations for temporary profiles + +When a custom user profile is not provided with the `-profile` +command-line argument geckodriver generates a temporary, throwaway +profile. This is written to the default system temporary folder +and subsequently removed when the WebDriver session expires. + +The default location for temporary profiles depends on the system. +On Unix systems it uses /tmp, and on Windows it uses the Windows +directory. + +The default location can be overridden. On Unix you set the `TMPDIR` +environment variable. On Windows, the following environment variables +are respected, in order: + + 1. `TMP` + 2. `TEMP` + 3. `USERPROFILE` + +It is not necessary to change the temporary directory system-wide. +All you have to do is make sure it gets set for the environment of +the geckodriver process: + + TMPDIR=/some/location ./geckodriver + +## Automation preferences + +As indicated in the introduction, geckodriver configures Firefox +so it is well-behaved in automation environments. It uses a +combination of preferences written to the profile prior to launching +Firefox (1), and a set of recommended preferences set on startup (2). + +These can be perused here: + + 1. [testing/geckodriver/src/prefs.rs](https://searchfox.org/mozilla-central/source/testing/geckodriver/src/prefs.rs) + 2. [remote/components/marionette.js](https://searchfox.org/mozilla-central/source/remote/components/marionette.js) + +As mentioned, these are _recommended_ preferences, and any user-defined +preferences in the [user.js file] or as part of the [`prefs` capability] +take precedence. This means for example that the user can tweak +`browser.startup.page` to override the recommended preference for +starting the browser with a blank page. + +The recommended preferences set at runtime (see 2 above) may also +be disabled entirely by setting `remote.prefs.recommended` starting with Firefox +91. For older versions of Firefox, the preference to use was +`marionette.prefs.recommended`. +This may however cause geckodriver to not behave correctly according +to the WebDriver standard, so it should be used with caution. + +Users should take note that the `marionette.port` preference is +special, and will always be overridden when using geckodriver unless +the `--marionette-port <port>` flag is used specifically to instruct +the Marionette server in Firefox which port to use. + +[user.js file]: http://kb.mozillazine.org/User.js_file +[`prefs` capability]: https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions#prefs_preferences_object + +## Temporary profiles not being removed + +It is a known bug that geckodriver in some instances fails to remove +the temporary profile, particularly when the session is not explicitly +deleted or the process gets interrupted. See [geckodriver issue +299] for more information. + +[geckodriver issue 299]: https://github.com/mozilla/geckodriver/issues/299 diff --git a/testing/geckodriver/doc/Releasing.md b/testing/geckodriver/doc/Releasing.md new file mode 100644 index 0000000000..79574f8a0e --- /dev/null +++ b/testing/geckodriver/doc/Releasing.md @@ -0,0 +1,292 @@ +# Releasing geckodriver + +Releasing geckodriver is not as easy as it once used to be when the +project’s canonical home was on GitHub. Today geckodriver is hosted +in [mozilla-central], and whilst we do want to make future releases +from [Mozilla’s CI infrastructure], we are currently in between two +worlds: development happens in m-c, but releases continue to be made +from GitHub. + +In any case, the steps to release geckodriver are as follows: + +[mozilla-central]: https://hg.mozilla.org/mozilla-central/ +[Mozilla’s CI infrastructure]: https://treeherder.mozilla.org/ + +## Update in-tree dependency crates + +geckodriver depends on a number of Rust crates that also live in +central by using relative paths. Here an excerpt from its `Cargo.toml`: + +```ini +[dependencies] +… +marionette = { path = "./marionette" } +… +mozdevice = { path = "../mozbase/rust/mozdevice" } +mozprofile = { path = "../mozbase/rust/mozprofile" } +mozrunner = { path = "../mozbase/rust/mozrunner" } +mozversion = { path = "../mozbase/rust/mozversion" } +… +webdriver = { path = "../webdriver" } +``` + +Because we need to export the geckodriver source code to the old +GitHub repository when we release, we first need to publish these +crates in the specified order if they have had any changes in the +interim since the last release. If they have received no changes, +you can skip them: + +- `testing/mozbase/rust/mozdevice` +- `testing/mozbase/rust/mozprofile` +- `testing/mozbase/rust/mozrunner` +- `testing/mozbase/rust/mozversion` +- `testing/webdriver` +- `testing/geckodriver/marionette` + +For each crate: + +1. Change into the crates folder. +2. Bump the version number in `Cargo.toml` based on [semantic versioning rules], + and also update the version dependency for other in-tree crates using the + currently modified crate. Note that running `cargo update` will fail if you + missed updating a crate's dependency. + +3. Use the [cargo-semver-checks] command to validate the version change: + + ```shell + % cargo semver-checks check-release + ``` + +4. Update the crate: + + ```shell + % cargo update -p <crate name> + ``` + +5. We also publish audit information for the crates based on Mozilla's + [audit criteria]. Because we use [wildcard audit entries] make sure that the + latest day of publication is still within the `end` date. The related entry + of the crate can be found at the top of [audits.toml]. If the date is over, + then update its value to at most 1 year in the future. + +6. Commit the changes for the modified [Cargo.toml] files, [Cargo.lock] and + [audits.toml]. + + ```shell + % git add Cargo.toml Cargo.lock audits.toml testing + % git commit -m "Bug XYZ - [rust-<name>] Release version <version>" + ``` + +[semantic versioning rules]: https://semver.org/ +[cargo-semver-checks]: https://crates.io/crates/cargo-semver-checks +[audit criteria]: https://mozilla.github.io/cargo-vet/audit-criteria.html +[wildcard audit entries]: https://mozilla.github.io/cargo-vet/wildcard-audit-entries.html +[Cargo.toml]: https://searchfox.org/mozilla-central/source/testing/geckodriver/Cargo.toml +[Cargo.lock]: https://searchfox.org/mozilla-central/source/Cargo.lock +[audits.toml]: https://searchfox.org/mozilla-central/source/supply-chain/audits.toml + +## Update the change log + +Notable changes to geckodriver are mentioned in [CHANGES.md]. Many +users rely on this, so it’s important that you make it **relevant +to end-users**. For example, we only mention changes that are visible +to users. The change log is not a complete anthology of commits, +as these often will not convey the essence of a change to end-users. +If a feature was added but removed before release, there is no reason +to list it as a change. + +It is good practice to also include relevant information from the +[webdriver], [marionette], [rust-mozrunner], and [rust-mozdevice] crates, +since these are the most important dependencies of geckodriver and a lot +of its functionality is implemented there. + +To get a list of all the changes for one of the above crates one of the following +commands can be used: + +```shell +% hg log -M -r <revision>::central --template "{node|short}\t{desc|firstline}\n" <path> +% git log --reverse $(git cinnabar hg2git <revision>)..HEAD --pretty="%s" <path> +``` + +where `<revision>` is the changeset of the last geckodriver release and `<path>` +the location of the crate in the repository. + +Add the list of changes to the related release bug on Bugzilla, and also check the +dependency list of the bug for other fixes that are worth mentioning. + +We follow the writing style of the existing change log, with +one section per version (with a release date), with subsections +‘Added’, ‘Changed’, 'Fixed' and ‘Removed’. If the targeted +Firefox or Selenium versions have changed, it is good to make a +mention of this. Lines are optimally formatted at roughly 72 columns +to make the file readable in a text editor as well as rendered HTML. +fmt(1) does a splendid job at text formatting. + +[CHANGES.md]: https://searchfox.org/mozilla-central/source/testing/geckodriver/CHANGES.md +[webdriver]: https://searchfox.org/mozilla-central/source/testing/webdriver +[marionette]: https://searchfox.org/mozilla-central/source/testing/geckodriver/marionette +[rust-mozrunner]: https://searchfox.org/mozilla-central/source/testing/mozbase/rust/mozrunner +[rust-mozdevice]: https://searchfox.org/mozilla-central/source/testing/mozbase/rust/mozdevice + +## Bump the version number and update the support page + +Bump the version number in [Cargo.toml] to the next version. +geckodriver follows [semantic versioning] so it’s a good idea to +familiarise yourself with that before deciding on the version number. + +After you’ve changed the version number, run + +```shell +% ./mach build testing/geckodriver +``` + +again to update [Cargo.lock]. + +Now update the [support page] by adding a new row to the versions table, +including the required versions of Selenium, and Firefox. + +Finally commit all those changes. + +[semantic versioning]: http://semver.org/ +[support page]: https://searchfox.org/mozilla-central/source/testing/geckodriver/doc/Support.md + +## Add the changeset id + +To easily allow a release build of geckodriver after cloning the +repository, the changeset id for the release has to be added to the +change log. Therefore add a final place-holder commit to the patch +series, to already get review for. + +Once all previous revisions of the patch series have been landed, and got merged +to `mozilla-central`, the changeset id from the merge commit has to picked for +finalizing the change log. This specific id is needed because Taskcluster creates +the final signed builds based on that merge. + +## Release new in-tree dependency crates + +Make sure to wait until the complete patch series from above has been +merged to mozilla-central. Then continue with the following steps. + +Before releasing geckodriver all dependency crates as +[updated earlier](#update-in-tree-dependency-crates) have to be +released first. + +Therefore change into each of the directories for crates with an update +and run the following command to publish the crate: + +```shell +% cargo publish +``` + +Note that if a crate has an in-tree dependency make sure to first +change the dependency information. + +Do not release the geckodriver crate yet! + +Once all crates have been published observe the `/target/package/` folder under +the root of the mozilla-central repository and remove all the folders related +to the above published packages (it will save ~1GB disk space). + +## Export to GitHub + +The canonical GitHub repository is <https://github.com/mozilla/geckodriver.git> +so make sure you have a local clone of that. It has three branches: +_master_ which only contains the [README.md]; _old_ which was the +state of the project when it was exported to mozilla-central; and +_release_, from where releases are made. + +Before we copy the code over to the GitHub repository we need to +check out the [release commit that bumped the version number](#add-the-changeset-id) +on mozilla-central: + +```shell +% hg update $RELEASE_REVISION +``` + +Or: + +```shell +% git checkout $(git cinnabar hg2git $RELEASE_REVISION) +``` + +We will now export the contents of [testing/geckodriver] to a new branch that +is based on the _release_ branch, which will be used to create a pull request: + +```shell +% cd $SRC/geckodriver +% git checkout release +% git pull +% git checkout -b do_release_X.Y.Z +% git rm -rf . +% git clean -fxd +% cp -rt $SRC/gecko/testing/geckodriver . +``` + +Now verify that geckodriver builds correctly by running: + +```shell +% cargo build +``` + +[README.md]: https://searchfox.org/mozilla-central/source/testing/geckodriver/README.md +[testing/geckodriver]: https://searchfox.org/mozilla-central/source/testing/geckodriver + +## Commit local changes + +Now commit all the changes you have made locally to the _release_ branch. +It is recommended to setup a [GPG key] for signing the commit, so +that the release commit is marked as `verified`. + +```shell +% git add . -- ':!mach_commands.py :!moz.build :!target/*' +% git commit -S -am "Import of vX.Y.Z" (signed) +``` + +or if you cannot use signing use: + +```shell +% git add . -- ':!mach_commands.py :!moz.build :!target/*' +% git commit -am "Import of vX.Y.Z" (unsigned) +``` + +Then push the changes, and create a pull request: + +```shell +% git push origin do_release_X.Y.Z +``` + +As indicated above, the changes you make to this branch will not +be upstreamed back into mozilla-central. It is merely used as a +place for external consumers to build their own version of geckodriver. + +[GPG key]: https://help.github.com/articles/signing-commits/ + +## Make the release + +geckodriver needs to be manually released on github.com. Therefore start to +[draft a new release], and make the following changes: + +1. Specify the "Tag version", and select "Release" as target. + +2. Leave the release title empty + +3. Paste the raw Markdown source from [CHANGES.md] into the description field. + This will highlight for end-users what changes were made in that particular + package when they visit the GitHub downloads section. Make sure to check that + all references can be resolved, and if not make sure to add those too. + +4. Find the signed geckodriver archives in the [taskcluster index] by + replacing %changeset% with the full release changeset id. Rename the + individual files so the basename looks like 'geckodriver-v%version%-%platform%'. + Upload them all, including the checksum files for the Linux platforms. + +5. Before announcing the release on GitHub publish the geckodriver crate as well + on crates.io by running `cargo publish` from the release branch. + +6. Send the release announcement to the [dev-webdriver] mailing list. + +[draft a new release]: https://github.com/mozilla/geckodriver/releases/new +[taskcluster index]: https://firefox-ci-tc.services.mozilla.com/tasks/index/gecko.v2.mozilla-central.revision.%changeset%.geckodriver +[dev-webdriver]: https://groups.google.com/a/mozilla.org/g/dev-webdriver + +Congratulations! You’ve released geckodriver! diff --git a/testing/geckodriver/doc/Support.md b/testing/geckodriver/doc/Support.md new file mode 100644 index 0000000000..0c06ee1bc0 --- /dev/null +++ b/testing/geckodriver/doc/Support.md @@ -0,0 +1,183 @@ +<!-- markdownlint-disable MD033 --> +# Supported platforms + +The following table shows a mapping between [geckodriver releases], +and required versions of Selenium and Firefox: + +<style type="text/css"> + table { width: 100%; margin-bottom: 2em; } + table, th, td { border: solid gray 1px; } + td, th { padding: 5px 10px; text-align: center; } +</style> + +<table> + <thead> + <tr> + <th rowspan="2">geckodriver + <th rowspan="2">Selenium + <th colspan="2">Firefox + </tr> + <tr> + <th>min + <th>max + </tr> + </thead> + </thead> + <tr> + <td>0.33.0 + <td>≥ 3.11 (3.14 Python) + <td>102 ESR + <td>n/a + <tr> + <td>0.32.2 + <td>≥ 3.11 (3.14 Python) + <td>102 ESR + <td>n/a + <tr> + <td>0.32.1 + <td>≥ 3.11 (3.14 Python) + <td>102 ESR + <td>n/a + <tr> + <td>0.32.0 + <td>≥ 3.11 (3.14 Python) + <td>102 ESR + <td>n/a + <tr> + <td>0.31.0 + <td>≥ 3.11 (3.14 Python) + <td>91 ESR + <td>n/a + <tr> + <tr> + <td>0.30.0 + <td>≥ 3.11 (3.14 Python) + <td>78 ESR + <td>90 + <tr> + <td>0.29.1 + <td>≥ 3.11 (3.14 Python) + <td>60 + <td>90 + <tr> + <td>0.29.0 + <td>≥ 3.11 (3.14 Python) + <td>60 + <td>90 + <tr> + <td>0.28.0 + <td>≥ 3.11 (3.14 Python) + <td>60 + <td>90 + <tr> + <td>0.27.0 + <td>≥ 3.11 (3.14 Python) + <td>60 + <td>90 + <tr> + <td>0.26.0 + <td>≥ 3.11 (3.14 Python) + <td>60 + <td>90 + <tr> + <td>0.25.0 + <td>≥ 3.11 (3.14 Python) + <td>57 + <td>90 + <tr> + <td>0.24.0 + <td>≥ 3.11 (3.14 Python) + <td>57 + <td>79 + <tr> + <td>0.23.0 + <td>≥ 3.11 (3.14 Python) + <td>57 + <td>79 + <tr> + <td>0.22.0 + <td>≥ 3.11 (3.14 Python) + <td>57 + <td>79 + <tr> + <td>0.21.0 + <td>≥ 3.11 (3.14 Python) + <td>57 + <td>79 + <tr> + <td>0.20.1 + <td>≥ 3.5 + <td>55 + <td>62 + <tr> + <td>0.20.0 + <td>≥ 3.5 + <td>55 + <td>62 + <tr> + <td>0.19.1 + <td>≥ 3.5 + <td>55 + <td>62 + <tr> + <td>0.19.0 + <td>≥ 3.5 + <td>55 + <td>62 + <tr> + <td>0.18.0 + <td>≥ 3.4 + <td>53 + <td>62 + <tr> + <td>0.17.0 + <td>≥ 3.4 + <td>52 + <td>62 +</table> + +## Clients + +[Selenium] users must update to version 3.11 or later to use geckodriver. +Other clients that follow the [W3C WebDriver specification][WebDriver] +are also supported. + +## Firefoxen + +geckodriver is not yet feature complete. This means that it does +not yet offer full conformance with the [WebDriver] standard +or complete compatibility with [Selenium]. You can track the +[implementation status] of the latest [Firefox Nightly] on MDN. +We also keep track of known [Selenium], [remote protocol], and +[specification] problems in our [issue tracker]. + +Support is best in Firefox 57 and greater, although generally the more +recent the Firefox version, the better the experience as they have +more bug fixes and features. Some features will only be available +in the most recent Firefox versions, and we strongly advise using the +latest [Firefox Nightly] with geckodriver. Since Windows XP support +in Firefox was dropped with Firefox 53, we do not support this platform. + +## Android + +Starting with the 0.26.0 release geckodriver is able to connect +to Android devices, and to control packages which are based on [GeckoView] +(eg. [Firefox Preview] aka Fenix, or [Firefox Reality]). But it also still +supports versions of Fennec up to 68 ESR, which is the last officially +supported release from Mozilla. + +To run tests on Android specific capabilities under `moz:firefoxOptions` +have to be set when requesting a new session. See the Android section under +[Firefox Capabilities](Capabilities.md#android) for more details. + +[geckodriver releases]: https://github.com/mozilla/geckodriver/releases +[Selenium]: https://github.com/seleniumhq/selenium +[WebDriver]: https://w3c.github.io/webdriver/ +[implementation status]: https://bugzilla.mozilla.org/showdependencytree.cgi?id=721859&hide_resolved=1 +[remote protocol]: https://github.com/mozilla/geckodriver/issues?q=is%3Aissue+is%3Aopen+label%3Amarionette +[specification]: https://github.com/mozilla/geckodriver/issues?q=is%3Aissue+is%3Aopen+label%3Aspec +[issue tracker]: https://github.com/mozilla/geckodriver/issues +[Firefox Nightly]: https://nightly.mozilla.org/ +[GeckoView]: https://wiki.mozilla.org/Mobile/GeckoView +[Firefox Preview]: https://play.google.com/store/apps/details?id=org.mozilla.fenix +[Firefox Reality]: https://play.google.com/store/apps/details?id=org.mozilla.vrbrowser diff --git a/testing/geckodriver/doc/Testing.md b/testing/geckodriver/doc/Testing.md new file mode 100644 index 0000000000..2f8f5c9ef6 --- /dev/null +++ b/testing/geckodriver/doc/Testing.md @@ -0,0 +1,69 @@ +# Testing geckodriver + +We verify and test geckodriver in a couple of different ways. +Since it is an implementation of the WebDriver web standard, we share +a set of conformance tests with other browser vendors through the +[Web Platform Tests] (WPT) initiative. This lets us ensure web +compatibility between _different_ WebDriver implementations for +different browsers. + +In addition to the WPT tests, geckodriver and webdriver have +unit tests. These are written in Rust, but you must explicitly +tell mach to build these by adding the following line to your [mozconfig]: + +```make +ac_add_options --enable-rust-tests +``` + +Tests can then be run by using the `test` sub command for [cargo] in the +specific source folder: + +```shell +% cd testing/geckodriver/src +% cargo test +``` + +To run the more extensive WPT tests you can use mach, but first +make sure you have built Firefox: + +```shell +% ./mach build +% ./mach wpt testing/web-platform/tests/webdriver +``` + +As these are functional integration tests and pop up Firefox windows +sporadically, a helpful tip is to suppress the window whilst you +are running them by using Firefox’ [headless mode]: + +```shell +% ./mach wpt --headless testing/web-platform/tests/webdriver +``` + +The `--headless` flag is equivalent to setting the `MOZ_HEADLESS` +output variable. In addition to `MOZ_HEADLESS` there is also +`MOZ_HEADLESS_WIDTH` and `MOZ_HEADLESS_HEIGHT` for controlling the +dimensions of the no-op virtual display. This is similar to using +Xvfb(1) which you may know from the X windowing system, but has +the additional benefit of also working on macOS and Windows. + +As you get in to development of geckodriver and Marionette you will +increasingly grow to understand our love for [trace-level logs]. +They provide us with the input—the HTTP requests—from the client +(in WPT’s case from the tests’ use of a custom WebDriver client), +the translation geckodriver makes to the [Marionette protocol], +the log output from Marionette, its responses back to geckodriver, +and finally the output—or the HTTP response—back to the client. + +The [trace-level logs] can be surfaced by passing on the `-vv` +flag to geckodriver through WPT: + +```shell +% ./mach wpt --webdriver-arg=-vv testing/web-platform/tests/webdriver +``` + +[Web Platform Tests]: http://web-platform-tests.org/ +[cargo]: http://doc.crates.io/guide.html +[headless mode]: https://developer.mozilla.org/en-US/Firefox/Headless_mode +[mozconfig]: https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Configuring_Build_Options +[trace-level logs]: TraceLogs.md +[Marionette protocol]: /testing/marionette/Protocol.md diff --git a/testing/geckodriver/doc/TraceLogs.md b/testing/geckodriver/doc/TraceLogs.md new file mode 100644 index 0000000000..fb4e298c44 --- /dev/null +++ b/testing/geckodriver/doc/TraceLogs.md @@ -0,0 +1,206 @@ +# Enabling trace logs + +geckodriver provides different bands of logs for different audiences. +The most important log entries are shown to everyone by default, +and these include which port geckodriver provides the WebDriver +API on, as well as informative warnings, errors, and fatal exceptions. + +The different log bands are, in ascending bandwidth: + +1. `fatal` is reserved for exceptional circumstances when geckodriver + or Firefox cannot recover. This usually entails that either + one or both of the processes will exit. + +2. `error` messages are mistakes in the program code which it is + possible to recover from. + +3. `warn` shows warnings of more informative nature that are not + necessarily problems in geckodriver. This could for example happen + if you use the legacy `desiredCapabilities`/`requiredCapabilities` + objects instead of the new `alwaysMatch`/`firstMatch` structures. + +4. `info` (default) contains information about which port geckodriver + binds to, but also all messages from the lower-bandwidth levels + listed above. + +5. `config` additionally shows the negotiated capabilities after + matching the `alwaysMatch` capabilities with the sequence of + `firstMatch` capabilities. + +6. `debug` is reserved for information that is useful when programming. + +7. `trace`, where in addition to itself, all previous levels + are included. The trace level shows all HTTP requests received + by geckodriver, packets sent to and from the remote protocol in + Firefox, and responses sent back to your client. + +In other words this means that the configured level will coalesce +entries from all lower bands including itself. If you set the log +level to `error`, you will get log entries for both `fatal` and `error`. +Similarly for `trace`, you will get all the logs that are offered. + +To help debug a problem with geckodriver or Firefox, the trace-level +output is vital to understand what is going on. This is why we ask +that trace logs are included when filing bugs gainst geckodriver. +It is only under very special circumstances that a trace log is +not needed, so you will normally find that our first action when +triaging your issue will be to ask you to include one. Do yourself +and us a favour and provide a trace-level log right away. + +To silence geckodriver altogether you may for example either redirect +all output to append to some log files: + +```shell +% geckodriver >>geckodriver.log 2>>geckodriver.err.log +``` + +Or a black hole somewhere: + +```shell +% geckodriver >/dev/null 2>&1 +``` + +The log level set for geckodriver is propagated to the Marionette +logger in Firefox. Marionette is the remote protocol that geckodriver +uses to implement WebDriver. This means enabling trace logs for +geckodriver will also implicitly enable them for Marionette. + +The log level is set in different ways. Either by using the +`--log <LEVEL>` option, where `LEVEL` is one of the log levels +from the list above, or by using the `-v` (for debug) or `-vv` +(for trace) shorthands. For example, the following command will +enable trace logs for both geckodriver and Marionette: + +```shell +% geckodriver -vv +``` + +The second way of setting the log level is through capabilities. +geckodriver accepts a Mozilla-specific configuration object +in [`moz:firefoxOptions`]. This JSON Object, which is further +described in the [README] can hold Firefox-specific configuration, +such as which Firefox binary to use, additional preferences to set, +and of course which log level to use. + +[`moz:firefoxOptions`]: https://searchfox.org/mozilla-central/source/testing/geckodriver/README.md#firefox-capabilities +[README]: https://searchfox.org/mozilla-central/source/testing/geckodriver/README.md + +Each client has its own way of specifying capabilities, and some clients +include “helpers” for providing browser-specific configuration. +It is often advisable to use these helpers instead of encoding the +JSON Object yourself because it can be difficult to get the exact +details right, but if you choose to, it should look like this: + +```json +{"moz:firefoxOptions": {"log": {"level": "trace"}}} +``` + +Note that most known WebDriver clients, such as those provided by +the Selenium project, do not expose a way to actually _see_ the logs +unless you redirect the log output to a particular file (using the +method shown above) or let the client “inherit” geckodriver’s +output, for example by redirecting the stdout and stderr streams to +its own. The notable exceptions are the Python and Ruby bindings, +which surface geckodriver logs in a remarkable easy and efficient way. + +See the client-specific documentation below for the most idiomatic +way to enable trace logs in your language. We want to expand this +documentation to cover all the best known clients people use with +geckodriver. If you find your language missing, please consider +[submitting a patch]. + +[submitting a patch]: Patches.md + +## C-Sharp + +The Selenium [C# client] comes with a [`FirefoxOptions`] helper for +constructing the [`moz:firefoxOptions`] capabilities object: + +```csharp +FirefoxOptions options = new FirefoxOptions(); +options.LogLevel = FirefoxDriverLogLevel.Trace; +IWebDriver driver = new FirefoxDriver(options); +``` + +The log output is directed to stdout. + +[C# client]: https://seleniumhq.github.io/selenium/docs/api/dotnet/ +[`FirefoxOptions`]: https://seleniumhq.github.io/selenium/docs/api/dotnet/html/T_OpenQA_Selenium_Firefox_FirefoxOptions.htm + +## Java + +The Selenium [Java client] also comes with +a [`org.openqa.selenium.firefox.FirefoxOptions`] helper for +constructing the [`moz:firefoxOptions`] capabilities object: + +```java +FirefoxOptions options = new FirefoxOptions(); +options.setLogLevel(FirefoxDriverLogLevel.TRACE); +WebDriver driver = new FirefoxDriver(options); +``` + +The log output is directed to stdout. + +[Java client]: https://seleniumhq.github.io/selenium/docs/api/java/ +[`org.openqa.selenium.firefox.FirefoxOptions`]: https://seleniumhq.github.io/selenium/docs/api/java/org/openqa/selenium/firefox/FirefoxOptions.html + +## Javascript (webdriver.io) + +With the Selenium [JavaScript client] the capabilities object can directly be +constructed: + +```javascript +import WebDriver from 'webdriver' + +const driver = await WebDriver.newSession({ + capabilities: { + browserName: 'firefox', + 'moz:firefoxOptions': { + log: { level: 'trace' }, + } + } +}) +``` + +The log output is directed to stdout, or if geckodriver runs as a wdio plugin +then the generated logs are part of the wdio log system. + +[JavaScript client]: https://webdriver.io/ + +## Python + +The Selenium [Python client] comes with a +[`selenium.webdriver.firefox.options.Options`] helper that can +be used programmatically to construct the [`moz:firefoxOptions`] +capabilities object: + +```python +from selenium.webdriver import Firefox +from selenium.webdriver.firefox.options import Options + +opts = Options() +opts.log.level = "trace" +driver = Firefox(options=opts) +``` + +The log output is stored in a file called _geckodriver.log_ in your +script’s current working directory. + +[Python client]: https://selenium-python.readthedocs.io/ +[`selenium.webdriver.firefox.options.Options`]: https://github.com/SeleniumHQ/selenium/blob/master/py/selenium/webdriver/firefox/options.py + +## Ruby + +The Selenium [Ruby client] comes with an [`Options`] helper to +generate the correct [`moz:firefoxOptions`] capabilities object: + +```ruby +Selenium::WebDriver.logger.level = :debug +opts = Selenium::WebDriver::Firefox::Options.new(log_level: :trace) +driver = Selenium::WebDriver.for :firefox, options: opts +``` + +The log output is directed to stdout. + +[Ruby client]: https://seleniumhq.github.io/selenium/docs/api/rb/ +[`Options`]: https://seleniumhq.github.io/selenium/docs/api/rb/Selenium/WebDriver/Firefox/Options.html diff --git a/testing/geckodriver/doc/Usage.md b/testing/geckodriver/doc/Usage.md new file mode 100644 index 0000000000..ee4ab86f8d --- /dev/null +++ b/testing/geckodriver/doc/Usage.md @@ -0,0 +1,143 @@ +# Usage + +geckodriver is an implementation of WebDriver, and WebDriver can +be used for widely different purposes. How you invoke geckodriver +largely depends on your use case. + +## Running Firefox in a container-based package + +When Firefox is packaged inside a container (e.g. [Snap], [Flatpak]), it may +see a different filesystem to the host. This can affect access to the generated +profile directory, which may result in a hang when starting Firefox. + +This is known to affect launching the default Firefox shipped with Ubuntu 22.04+. + +There are several workarounds available for this problem: + +- Do not use container-packaged Firefox builds with geckodriver. Instead +download a Firefox release from <https://download.mozilla.org/?product=firefox-latest&os=linux> +and a geckodriver release from <https://github.com/mozilla/geckodriver/releases>. + +- Use a geckodriver that runs in the same container filesystem as the Firefox +package. For example on Ubuntu `/snap/bin/geckodriver` will work with the +default Firefox. + +- Set the `--profile-root` command line option to write the profile to a +directory accessible to both Firefox and geckodriver, for example a non-hidden +directory under `$HOME`. + +[Flatpak]: https://flatpak.org/ +[Snap]: https://ubuntu.com/core/services/guide/snaps-intro + +## Selenium + +If you are using geckodriver through [Selenium], you must ensure that +you have version 3.11 or greater. Because geckodriver implements the +[W3C WebDriver standard][WebDriver] and not the same Selenium wire +protocol older drivers are using, you may experience incompatibilities +and migration problems when making the switch from FirefoxDriver to +geckodriver. + +Generally speaking, Selenium 3 enabled geckodriver as the default +WebDriver implementation for Firefox. With the release of Firefox 47, +FirefoxDriver had to be discontinued for its lack of support for the +[new multi-processing architecture in Gecko][e10s]. + +Selenium client bindings will pick up the _geckodriver_ binary executable +from your [system’s `PATH` environmental variable][PATH] unless you +override it by setting the `webdriver.gecko.driver` [Java VM system +property]: + +```java +System.setProperty("webdriver.gecko.driver", "/home/user/bin"); +``` + +Or by passing it as a flag to the [java(1)] launcher: + +```shell +% java -Dwebdriver.gecko.driver=/home/user/bin YourApplication +``` + +Your mileage with this approach may vary based on which programming +language bindings you are using. It is in any case generally the case +that geckodriver will be picked up if it is available on the system path. +In a bash compatible shell, you can make other programs aware of its +location by exporting or setting the `PATH` variable: + +```shell +% export PATH=$PATH:/home/user/bin +% whereis geckodriver +geckodriver: /home/user/bin/geckodriver +``` + +On Window systems you can change the system path by right-clicking **My +Computer** and choosing **Properties**. In the dialogue that appears, +navigate **Advanced** → **Environmental Variables** → **Path**. + +Or in the Windows console window: + +```shell +% set PATH=%PATH%;C:\bin\geckodriver +``` + +## Standalone + +Since geckodriver is a separate HTTP server that is a complete remote end +implementation of [WebDriver], it is possible to avoid using the Selenium +remote server if you have no requirements to distribute processes across +a matrix of systems. + +Given a W3C WebDriver conforming client library (or _local end_) you +may interact with the geckodriver HTTP server as if you were speaking +to any Selenium server. + +Using [curl(1)]: + +```shell +% geckodriver & +[1] 16010 +% 1491834109194 geckodriver INFO Listening on 127.0.0.1:4444 +% curl -H 'Content-Type: application/json' -d '{"capabilities": {"alwaysMatch": {"acceptInsecureCerts": true}}}' http://localhost:4444/session +{"value":{"sessionId":"d4605710-5a4e-4d64-a52a-778bb0c31e00","capabilities":{"acceptInsecureCerts":true,[...]}}} +% curl -H 'Content-Type: application/json' -d '{"url": "https://mozilla.org"}' http://localhost:4444/session/d4605710-5a4e-4d64-a52a-778bb0c31e00/url +{} +% curl http://localhost:4444/session/d4605710-5a4e-4d64-a52a-778bb0c31e00/url +{"value":"https://www.mozilla.org/en-US/" +% curl -X DELETE http://localhost:4444/session/d4605710-5a4e-4d64-a52a-778bb0c31e00 +{} +% fg +geckodriver +^C +``` + +Using the Python [wdclient] library: + +```python +import webdriver + +with webdriver.Session("127.0.0.1", 4444) as session: + session.url = "https://mozilla.org" + print "The current URL is %s" % session.url +``` + +And to run: + +```shell +% geckodriver & +[1] 16054 +% python example.py +1491835308354 geckodriver INFO Listening on 127.0.0.1:4444 +The current URL is https://www.mozilla.org/en-US/ +% fg +geckodriver +^C +``` + +[Selenium]: http://seleniumhq.org/ +[e10s]: https://developer.mozilla.org/en-US/Firefox/Multiprocess_Firefox +[PATH]: https://en.wikipedia.org/wiki/PATH_(variable) +[Java VM system property]: http://docs.oracle.com/javase/tutorial/essential/environment/sysprop.html +[java(1)]: http://www.manpagez.com/man/1/java/ +[WebDriver]: https://w3c.github.io/webdriver/ +[curl(1)]: http://www.manpagez.com/man/1/curl/ +[wdclient]: https://github.com/web-platform-tests/wpt/tree/master/tools/webdriver diff --git a/testing/geckodriver/doc/index.rst b/testing/geckodriver/doc/index.rst new file mode 100644 index 0000000000..863c05810c --- /dev/null +++ b/testing/geckodriver/doc/index.rst @@ -0,0 +1,55 @@ +=========== +geckodriver +=========== + +Proxy for using W3C WebDriver-compatible clients to interact with +Gecko-based browsers. + +This program provides the HTTP API described by the `WebDriver protocol`_. +to communicate with Gecko browsers, such as Firefox. It translates calls +into the :ref:`Firefox remote protocol <Protocol>` by acting as a proxy between the local- +and remote ends. + +You can consult the `change log`_ for a record of all notable changes +to the program. Releases_ are made available on GitHub. + +.. _WebDriver protocol: https://w3c.github.io/webdriver/#protocol +.. _change log: https://github.com/mozilla/geckodriver/releases +.. _Releases: https://github.com/mozilla/geckodriver/releases + + +.. toctree:: + :maxdepth: 1 + + Support.md + WebDriver capabilities <https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities> + Capabilities.md + Usage.md + Flags.md + Profiles.md + Bugs.md + TraceLogs.md + CrashReports.md + Notarization.md + + +For developers +============== +.. toctree:: + :maxdepth: 1 + + Building.md + Testing.md + Patches.md + Releasing.md + ARM.md + + +Communication +============= + +The mailing list for geckodriver discussion is +https://groups.google.com/a/mozilla.org/g/dev-webdriver. + +If you prefer real-time chat, ask your questions +on `#webdriver:mozilla.org <https://chat.mozilla.org/#/room/#webdriver:mozilla.org>`__. diff --git a/testing/geckodriver/mach_commands.py b/testing/geckodriver/mach_commands.py new file mode 100644 index 0000000000..cb56d3acf2 --- /dev/null +++ b/testing/geckodriver/mach_commands.py @@ -0,0 +1,122 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import logging +import os + +from mach.decorators import Command, CommandArgument, CommandArgumentGroup +from mozbuild.base import BinaryNotFoundException + + +@Command( + "geckodriver", + category="post-build", + description="Run the WebDriver implementation for Gecko.", +) +@CommandArgument( + "--binary", type=str, help="Firefox binary (defaults to the local build)." +) +@CommandArgument( + "params", nargs="...", help="Flags to be passed through to geckodriver." +) +@CommandArgumentGroup("debugging") +@CommandArgument( + "--debug", + action="store_true", + group="debugging", + help="Enable the debugger. Not specifying a --debugger " + "option will result in the default debugger " + "being used.", +) +@CommandArgument( + "--debugger", + default=None, + type=str, + group="debugging", + help="Name of debugger to use.", +) +@CommandArgument( + "--debugger-args", + default=None, + metavar="params", + type=str, + group="debugging", + help="Flags to pass to the debugger itself; split as the Bourne shell would.", +) +def run(command_context, binary, params, debug, debugger, debugger_args): + try: + binpath = command_context.get_binary_path("geckodriver") + except BinaryNotFoundException as e: + command_context.log( + logging.ERROR, "geckodriver", {"error": str(e)}, "ERROR: {error}" + ) + command_context.log( + logging.INFO, + "geckodriver", + {}, + "It looks like geckodriver isn't built. " + "Add ac_add_options --enable-geckodriver to your " + "mozconfig " + "and run |./mach build| to build it.", + ) + return 1 + + args = [binpath] + + if params: + args.extend(params) + + if binary is None: + try: + binary = command_context.get_binary_path("app") + except BinaryNotFoundException as e: + command_context.log( + logging.ERROR, "geckodriver", {"error": str(e)}, "ERROR: {error}" + ) + command_context.log( + logging.INFO, "geckodriver", {"help": e.help()}, "{help}" + ) + return 1 + + args.extend(["--binary", binary]) + + if debug or debugger or debugger_args: + if "INSIDE_EMACS" in os.environ: + command_context.log_manager.terminal_handler.setLevel(logging.WARNING) + + import mozdebug + + if not debugger: + # No debugger name was provided. Look for the default ones on + # current OS. + debugger = mozdebug.get_default_debugger_name( + mozdebug.DebuggerSearch.KeepLooking + ) + + if debugger: + debuggerInfo = mozdebug.get_debugger_info(debugger, debugger_args) + if not debuggerInfo: + print("Could not find a suitable debugger in your PATH.") + return 1 + + # Parameters come from the CLI. We need to convert them before + # their use. + if debugger_args: + from mozbuild import shellutil + + try: + debugger_args = shellutil.split(debugger_args) + except shellutil.MetaCharacterException as e: + print( + "The --debugger-args you passed require a real shell to parse them." + ) + print("(We can't handle the %r character.)" % e.char) + return 1 + + # Prepend the debugger args. + args = [debuggerInfo.path] + debuggerInfo.args + args + + return command_context.run_process( + args=args, ensure_exit_code=False, pass_thru=True + ) diff --git a/testing/geckodriver/marionette/Cargo.toml b/testing/geckodriver/marionette/Cargo.toml new file mode 100644 index 0000000000..2bcfaa37a6 --- /dev/null +++ b/testing/geckodriver/marionette/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "marionette" +version = "0.4.0" +authors = ["Mozilla"] +description = "Library implementing the client side of Gecko's Marionette remote automation protocol." +edition = "2018" +keywords = ["mozilla", "firefox", "marionette", "webdriver"] +license = "MPL-2.0" +repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/geckodriver/marionette" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_repr = "0.1" diff --git a/testing/geckodriver/marionette/src/common.rs b/testing/geckodriver/marionette/src/common.rs new file mode 100644 index 0000000000..e819757e73 --- /dev/null +++ b/testing/geckodriver/marionette/src/common.rs @@ -0,0 +1,240 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use serde::ser::SerializeMap; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct BoolValue { + value: bool, +} + +impl BoolValue { + pub fn new(val: bool) -> Self { + BoolValue { value: val } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Cookie { + pub name: String, + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option<String>, + #[serde(default)] + pub secure: bool, + #[serde(default, rename = "httpOnly")] + pub http_only: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub expiry: Option<Date>, + #[serde(skip_serializing_if = "Option::is_none", rename = "sameSite")] + pub same_site: Option<String>, +} + +pub fn to_cookie<T, S>(data: T, serializer: S) -> Result<S::Ok, S::Error> +where + S: Serializer, + T: Serialize, +{ + #[derive(Serialize)] + struct Wrapper<T> { + cookie: T, + } + + Wrapper { cookie: data }.serialize(serializer) +} + +pub fn from_cookie<'de, D, T>(deserializer: D) -> Result<T, D::Error> +where + D: Deserializer<'de>, + T: serde::de::DeserializeOwned, + T: std::fmt::Debug, +{ + #[derive(Debug, Deserialize)] + struct Wrapper<T> { + cookie: T, + } + + let w = Wrapper::deserialize(deserializer)?; + Ok(w.cookie) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Date(pub u64); + +#[derive(Clone, Debug, PartialEq)] +pub enum Frame { + Index(u16), + Element(String), + Parent, +} + +impl Serialize for Frame { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(1))?; + match self { + Frame::Index(nth) => map.serialize_entry("id", nth)?, + Frame::Element(el) => map.serialize_entry("element", el)?, + Frame::Parent => map.serialize_entry("id", &Value::Null)?, + } + map.end() + } +} + +impl<'de> Deserialize<'de> for Frame { + fn deserialize<D>(deserializer: D) -> Result<Frame, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Debug, Deserialize)] + #[serde(rename_all = "lowercase")] + struct JsonFrame { + id: Option<u16>, + element: Option<String>, + } + + let json = JsonFrame::deserialize(deserializer)?; + match (json.id, json.element) { + (Some(_id), Some(_element)) => Err(de::Error::custom("conflicting frame identifiers")), + (Some(id), None) => Ok(Frame::Index(id)), + (None, Some(element)) => Ok(Frame::Element(element)), + (None, None) => Ok(Frame::Parent), + } + } +} + +// TODO(nupur): Bug 1567165 - Make WebElement in Marionette a unit struct +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WebElement { + #[serde(rename = "element-6066-11e4-a52e-4f735466cecf")] + pub element: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Timeouts { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub implicit: Option<u64>, + #[serde(default, rename = "pageLoad", skip_serializing_if = "Option::is_none")] + pub page_load: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[allow(clippy::option_option)] + pub script: Option<Option<u64>>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Window { + pub handle: String, +} + +pub fn to_name<T, S>(data: T, serializer: S) -> Result<S::Ok, S::Error> +where + S: Serializer, + T: Serialize, +{ + #[derive(Serialize)] + struct Wrapper<T> { + name: T, + } + + Wrapper { name: data }.serialize(serializer) +} + +pub fn from_name<'de, D, T>(deserializer: D) -> Result<T, D::Error> +where + D: Deserializer<'de>, + T: serde::de::DeserializeOwned, + T: std::fmt::Debug, +{ + #[derive(Debug, Deserialize)] + struct Wrapper<T> { + name: T, + } + + let w = Wrapper::deserialize(deserializer)?; + Ok(w.name) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::{assert_de, assert_ser, assert_ser_de, ELEMENT_KEY}; + use serde_json::json; + + #[test] + fn test_cookie_default_values() { + let data = Cookie { + name: "hello".into(), + value: "world".into(), + path: None, + domain: None, + secure: false, + http_only: false, + expiry: None, + same_site: None, + }; + assert_de(&data, json!({"name":"hello", "value":"world"})); + } + + #[test] + fn test_json_frame_index() { + assert_ser_de(&Frame::Index(1234), json!({"id": 1234})); + } + + #[test] + fn test_json_frame_element() { + assert_ser_de(&Frame::Element("elem".into()), json!({"element": "elem"})); + } + + #[test] + fn test_json_frame_parent() { + assert_ser_de(&Frame::Parent, json!({ "id": null })); + } + + #[test] + fn test_web_element() { + let data = WebElement { + element: "foo".into(), + }; + assert_ser_de(&data, json!({ELEMENT_KEY: "foo"})); + } + + #[test] + fn test_timeouts_with_all_params() { + let data = Timeouts { + implicit: Some(1000), + page_load: Some(200000), + script: Some(Some(60000)), + }; + assert_ser_de( + &data, + json!({"implicit":1000,"pageLoad":200000,"script":60000}), + ); + } + + #[test] + fn test_timeouts_with_missing_params() { + let data = Timeouts { + implicit: Some(1000), + page_load: None, + script: None, + }; + assert_ser_de(&data, json!({"implicit":1000})); + } + + #[test] + fn test_timeouts_setting_script_none() { + let data = Timeouts { + implicit: Some(1000), + page_load: None, + script: Some(None), + }; + assert_ser(&data, json!({"implicit":1000, "script":null})); + } +} diff --git a/testing/geckodriver/marionette/src/error.rs b/testing/geckodriver/marionette/src/error.rs new file mode 100644 index 0000000000..6b05410047 --- /dev/null +++ b/testing/geckodriver/marionette/src/error.rs @@ -0,0 +1,184 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::error; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum Error { + Marionette(MarionetteError), +} + +impl Error { + pub fn kind(&self) -> ErrorKind { + match *self { + Error::Marionette(ref err) => err.kind, + } + } +} + +impl fmt::Debug for Error { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::Marionette(ref err) => fmt + .debug_struct("Marionette") + .field("kind", &err.kind) + .field("message", &err.message) + .field("stacktrace", &err.stack.clone()) + .finish(), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::Marionette(ref err) => write!(fmt, "{}: {}", err.kind, err.message), + } + } +} + +impl error::Error for Error { + fn description(&self) -> &str { + match self { + Error::Marionette(_) => self.kind().as_str(), + } + } +} + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct MarionetteError { + #[serde(rename = "error")] + pub kind: ErrorKind, + #[serde(default = "empty_string")] + pub message: String, + #[serde(rename = "stacktrace", default = "empty_string")] + pub stack: String, +} + +fn empty_string() -> String { + "".to_owned() +} + +impl From<MarionetteError> for Error { + fn from(error: MarionetteError) -> Error { + Error::Marionette(error) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub enum ErrorKind { + #[serde(rename = "element click intercepted")] + ElementClickIntercepted, + #[serde(rename = "element not accessible")] + ElementNotAccessible, + #[serde(rename = "element not interactable")] + ElementNotInteractable, + #[serde(rename = "insecure certificate")] + InsecureCertificate, + #[serde(rename = "invalid argument")] + InvalidArgument, + #[serde(rename = "invalid cookie")] + InvalidCookieDomain, + #[serde(rename = "invalid element state")] + InvalidElementState, + #[serde(rename = "invalid selector")] + InvalidSelector, + #[serde(rename = "invalid session id")] + InvalidSessionId, + #[serde(rename = "javascript error")] + JavaScript, + #[serde(rename = "move target out of bounds")] + MoveTargetOutOfBounds, + #[serde(rename = "no such alert")] + NoSuchAlert, + #[serde(rename = "no such element")] + NoSuchElement, + #[serde(rename = "no such frame")] + NoSuchFrame, + #[serde(rename = "no such window")] + NoSuchWindow, + #[serde(rename = "script timeout")] + ScriptTimeout, + #[serde(rename = "session not created")] + SessionNotCreated, + #[serde(rename = "stale element reference")] + StaleElementReference, + #[serde(rename = "timeout")] + Timeout, + #[serde(rename = "unable to set cookie")] + UnableToSetCookie, + #[serde(rename = "unexpected alert open")] + UnexpectedAlertOpen, + #[serde(rename = "unknown command")] + UnknownCommand, + #[serde(rename = "unknown error")] + Unknown, + #[serde(rename = "unsupported operation")] + UnsupportedOperation, + #[serde(rename = "webdriver error")] + WebDriver, +} + +impl ErrorKind { + pub(crate) fn as_str(self) -> &'static str { + use ErrorKind::*; + match self { + ElementClickIntercepted => "element click intercepted", + ElementNotAccessible => "element not accessible", + ElementNotInteractable => "element not interactable", + InsecureCertificate => "insecure certificate", + InvalidArgument => "invalid argument", + InvalidCookieDomain => "invalid cookie", + InvalidElementState => "invalid element state", + InvalidSelector => "invalid selector", + InvalidSessionId => "invalid session id", + JavaScript => "javascript error", + MoveTargetOutOfBounds => "move target out of bounds", + NoSuchAlert => "no such alert", + NoSuchElement => "no such element", + NoSuchFrame => "no such frame", + NoSuchWindow => "no such window", + ScriptTimeout => "script timeout", + SessionNotCreated => "session not created", + StaleElementReference => "stale eelement referencee", + Timeout => "timeout", + UnableToSetCookie => "unable to set cookie", + UnexpectedAlertOpen => "unexpected alert open", + UnknownCommand => "unknown command", + Unknown => "unknown error", + UnsupportedOperation => "unsupported operation", + WebDriver => "webdriver error", + } + } +} + +impl fmt::Display for ErrorKind { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::assert_ser_de; + use serde_json::json; + + #[test] + fn test_json_error() { + let err = MarionetteError { + kind: ErrorKind::Timeout, + message: "".into(), + stack: "".into(), + }; + assert_ser_de( + &err, + json!({"error": "timeout", "message": "", "stacktrace": ""}), + ); + } +} diff --git a/testing/geckodriver/marionette/src/lib.rs b/testing/geckodriver/marionette/src/lib.rs new file mode 100644 index 0000000000..80817c5f5b --- /dev/null +++ b/testing/geckodriver/marionette/src/lib.rs @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +pub mod error; + +pub mod common; +pub mod marionette; +pub mod message; +pub mod result; +pub mod webdriver; + +#[cfg(test)] +mod test; diff --git a/testing/geckodriver/marionette/src/marionette.rs b/testing/geckodriver/marionette/src/marionette.rs new file mode 100644 index 0000000000..c06e2d60c7 --- /dev/null +++ b/testing/geckodriver/marionette/src/marionette.rs @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use serde::{Deserialize, Serialize}; + +use crate::common::BoolValue; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[allow(non_camel_case_types)] +pub enum AppStatus { + eAttemptQuit, + eConsiderQuit, + eForceQuit, + eRestart, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Command { + #[serde(rename = "Marionette:AcceptConnections")] + AcceptConnections(BoolValue), + #[serde(rename = "Marionette:Quit")] + DeleteSession { flags: Vec<AppStatus> }, + #[serde(rename = "Marionette:GetContext")] + GetContext, + #[serde(rename = "Marionette:GetScreenOrientation")] + GetScreenOrientation, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::assert_ser_de; + use serde_json::json; + + #[test] + fn test_json_command_accept_connections() { + assert_ser_de( + &Command::AcceptConnections(BoolValue::new(false)), + json!({"Marionette:AcceptConnections": {"value": false }}), + ); + } + + #[test] + fn test_json_command_delete_session() { + let data = &Command::DeleteSession { + flags: vec![AppStatus::eForceQuit], + }; + assert_ser_de(data, json!({"Marionette:Quit": {"flags": ["eForceQuit"]}})); + } + + #[test] + fn test_json_command_get_context() { + assert_ser_de(&Command::GetContext, json!("Marionette:GetContext")); + } + + #[test] + fn test_json_command_get_screen_orientation() { + assert_ser_de( + &Command::GetScreenOrientation, + json!("Marionette:GetScreenOrientation"), + ); + } + + #[test] + fn test_json_command_invalid() { + assert!(serde_json::from_value::<Command>(json!("foo")).is_err()); + } +} diff --git a/testing/geckodriver/marionette/src/message.rs b/testing/geckodriver/marionette/src/message.rs new file mode 100644 index 0000000000..704d52f67b --- /dev/null +++ b/testing/geckodriver/marionette/src/message.rs @@ -0,0 +1,336 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use serde::de::{self, SeqAccess, Unexpected, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Map, Value}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use std::fmt; + +use crate::error::MarionetteError; +use crate::marionette; +use crate::result::MarionetteResult; +use crate::webdriver; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Command { + WebDriver(webdriver::Command), + Marionette(marionette::Command), +} + +impl Command { + pub fn name(&self) -> String { + let (command_name, _) = self.first_entry(); + command_name + } + + fn params(&self) -> Value { + let (_, params) = self.first_entry(); + params + } + + fn first_entry(&self) -> (String, serde_json::Value) { + match serde_json::to_value(self).unwrap() { + Value::String(cmd) => (cmd, Value::Object(Map::new())), + Value::Object(items) => { + let mut iter = items.iter(); + let (cmd, params) = iter.next().unwrap(); + (cmd.to_string(), params.clone()) + } + _ => unreachable!(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize_repr, Deserialize_repr)] +#[repr(u8)] +enum MessageDirection { + Incoming = 0, + Outgoing = 1, +} + +pub type MessageId = u32; + +#[derive(Debug, Clone, PartialEq)] +pub struct Request(pub MessageId, pub Command); + +impl Request { + pub fn id(&self) -> MessageId { + self.0 + } + + pub fn command(&self) -> &Command { + &self.1 + } + + pub fn params(&self) -> Value { + self.command().params() + } +} + +impl Serialize for Request { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + ( + MessageDirection::Incoming, + self.id(), + self.command().name(), + self.params(), + ) + .serialize(serializer) + } +} + +#[derive(Debug, PartialEq)] +pub enum Response { + Result { + id: MessageId, + result: MarionetteResult, + }, + Error { + id: MessageId, + error: MarionetteError, + }, +} + +impl Serialize for Response { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + match self { + Response::Result { id, result } => { + (MessageDirection::Outgoing, id, Value::Null, &result).serialize(serializer) + } + Response::Error { id, error } => { + (MessageDirection::Outgoing, id, &error, Value::Null).serialize(serializer) + } + } + } +} + +#[derive(Debug, PartialEq, Serialize)] +#[serde(untagged)] +pub enum Message { + Incoming(Request), + Outgoing(Response), +} + +struct MessageVisitor; + +impl<'de> Visitor<'de> for MessageVisitor { + type Value = Message; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("four-element array") + } + + fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> { + let direction = seq + .next_element::<MessageDirection>()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let id: MessageId = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + + let msg = match direction { + MessageDirection::Incoming => { + let name: String = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(2, &self))?; + let params: Value = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(3, &self))?; + + let command = match params { + Value::Object(ref items) if !items.is_empty() => { + let command_to_params = { + let mut m = Map::new(); + m.insert(name, params); + Value::Object(m) + }; + serde_json::from_value(command_to_params).map_err(de::Error::custom) + } + Value::Object(_) | Value::Null => { + serde_json::from_value(Value::String(name)).map_err(de::Error::custom) + } + x => Err(de::Error::custom(format!("unknown params type: {}", x))), + }?; + Message::Incoming(Request(id, command)) + } + + MessageDirection::Outgoing => { + let maybe_error: Option<MarionetteError> = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(2, &self))?; + + let response = if let Some(error) = maybe_error { + seq.next_element::<Value>()? + .ok_or_else(|| de::Error::invalid_length(3, &self))? + .as_null() + .ok_or_else(|| de::Error::invalid_type(Unexpected::Unit, &self))?; + Response::Error { id, error } + } else { + let result: MarionetteResult = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(3, &self))?; + Response::Result { id, result } + }; + + Message::Outgoing(response) + } + }; + + Ok(msg) + } +} + +impl<'de> Deserialize<'de> for Message { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(MessageVisitor) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + use crate::common::*; + use crate::error::{ErrorKind, MarionetteError}; + use crate::test::assert_ser_de; + + #[test] + fn test_incoming() { + let json = + json!([0, 42, "WebDriver:FindElement", {"using": "css selector", "value": "value"}]); + let find_element = webdriver::Command::FindElement(webdriver::Locator { + using: webdriver::Selector::Css, + value: "value".into(), + }); + let req = Request(42, Command::WebDriver(find_element)); + let msg = Message::Incoming(req); + assert_ser_de(&msg, json); + } + + #[test] + fn test_incoming_empty_params() { + let json = json!([0, 42, "WebDriver:GetTimeouts", {}]); + let req = Request(42, Command::WebDriver(webdriver::Command::GetTimeouts)); + let msg = Message::Incoming(req); + assert_ser_de(&msg, json); + } + + #[test] + fn test_incoming_common_params() { + let json = json!([0, 42, "Marionette:AcceptConnections", {"value": false}]); + let params = BoolValue::new(false); + let req = Request( + 42, + Command::Marionette(marionette::Command::AcceptConnections(params)), + ); + let msg = Message::Incoming(req); + assert_ser_de(&msg, json); + } + + #[test] + fn test_incoming_params_derived() { + assert!(serde_json::from_value::<Message>( + json!([0,42,"WebDriver:FindElement",{"using":"foo","value":"foo"}]) + ) + .is_err()); + assert!(serde_json::from_value::<Message>( + json!([0,42,"Marionette:AcceptConnections",{"value":"foo"}]) + ) + .is_err()); + } + + #[test] + fn test_incoming_no_params() { + assert!(serde_json::from_value::<Message>( + json!([0,42,"WebDriver:GetTimeouts",{"value":true}]) + ) + .is_err()); + assert!(serde_json::from_value::<Message>( + json!([0,42,"Marionette:Context",{"value":"foo"}]) + ) + .is_err()); + assert!(serde_json::from_value::<Message>( + json!([0,42,"Marionette:GetScreenOrientation",{"value":true}]) + ) + .is_err()); + } + + #[test] + fn test_outgoing_result() { + let json = json!([1, 42, null, { "value": null }]); + let result = MarionetteResult::Null; + let msg = Message::Outgoing(Response::Result { id: 42, result }); + + assert_ser_de(&msg, json); + } + + #[test] + fn test_outgoing_error() { + let json = + json!([1, 42, {"error": "no such element", "message": "", "stacktrace": ""}, null]); + let error = MarionetteError { + kind: ErrorKind::NoSuchElement, + message: "".into(), + stack: "".into(), + }; + let msg = Message::Outgoing(Response::Error { id: 42, error }); + + assert_ser_de(&msg, json); + } + + #[test] + fn test_invalid_type() { + assert!( + serde_json::from_value::<Message>(json!([2, 42, "WebDriver:GetTimeouts", {}])).is_err() + ); + assert!(serde_json::from_value::<Message>(json!([3, 42, "no such element", {}])).is_err()); + } + + #[test] + fn test_missing_fields() { + // all fields are required + assert!( + serde_json::from_value::<Message>(json!([2, 42, "WebDriver:GetTimeouts"])).is_err() + ); + assert!(serde_json::from_value::<Message>(json!([2, 42])).is_err()); + assert!(serde_json::from_value::<Message>(json!([2])).is_err()); + assert!(serde_json::from_value::<Message>(json!([])).is_err()); + } + + #[test] + fn test_unknown_command() { + assert!(serde_json::from_value::<Message>(json!([0, 42, "hooba", {}])).is_err()); + } + + #[test] + fn test_unknown_error() { + assert!(serde_json::from_value::<Message>(json!([1, 42, "flooba", {}])).is_err()); + } + + #[test] + fn test_message_id_bounds() { + let overflow = i64::from(std::u32::MAX) + 1; + let underflow = -1; + + fn get_timeouts(message_id: i64) -> Value { + json!([0, message_id, "WebDriver:GetTimeouts", {}]) + } + + assert!(serde_json::from_value::<Message>(get_timeouts(overflow)).is_err()); + assert!(serde_json::from_value::<Message>(get_timeouts(underflow)).is_err()); + } +} diff --git a/testing/geckodriver/marionette/src/result.rs b/testing/geckodriver/marionette/src/result.rs new file mode 100644 index 0000000000..95817c15f0 --- /dev/null +++ b/testing/geckodriver/marionette/src/result.rs @@ -0,0 +1,223 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use serde::de; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use crate::common::{Cookie, Timeouts, WebElement}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct NewWindow { + handle: String, + #[serde(rename = "type")] + type_hint: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WindowRect { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ElementRect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MarionetteResult { + #[serde(deserialize_with = "from_value", serialize_with = "to_value")] + Bool(bool), + #[serde(deserialize_with = "from_value", serialize_with = "to_empty_value")] + Null, + NewWindow(NewWindow), + WindowRect(WindowRect), + ElementRect(ElementRect), + #[serde(deserialize_with = "from_value", serialize_with = "to_value")] + String(String), + Strings(Vec<String>), + #[serde(deserialize_with = "from_value", serialize_with = "to_value")] + WebElement(WebElement), + WebElements(Vec<WebElement>), + Cookies(Vec<Cookie>), + Timeouts(Timeouts), +} + +fn to_value<T, S>(data: T, serializer: S) -> Result<S::Ok, S::Error> +where + S: Serializer, + T: Serialize, +{ + #[derive(Serialize)] + struct Wrapper<T> { + value: T, + } + + Wrapper { value: data }.serialize(serializer) +} + +fn to_empty_value<S>(serializer: S) -> Result<S::Ok, S::Error> +where + S: Serializer, +{ + #[derive(Serialize)] + struct Wrapper { + value: Value, + } + + Wrapper { value: Value::Null }.serialize(serializer) +} + +fn from_value<'de, D, T>(deserializer: D) -> Result<T, D::Error> +where + D: Deserializer<'de>, + T: serde::de::DeserializeOwned, + T: std::fmt::Debug, +{ + #[derive(Debug, Deserialize)] + struct Wrapper<T> { + value: T, + } + + let v = Value::deserialize(deserializer)?; + if v.is_object() { + let w = serde_json::from_value::<Wrapper<T>>(v).map_err(de::Error::custom)?; + Ok(w.value) + } else { + Err(de::Error::custom("Cannot be deserialized to struct")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::{assert_de, assert_ser_de, ELEMENT_KEY}; + use serde_json::json; + + #[test] + fn test_boolean_response() { + assert_ser_de(&MarionetteResult::Bool(true), json!({"value": true})); + } + + #[test] + fn test_cookies_response() { + let mut data = Vec::new(); + data.push(Cookie { + name: "foo".into(), + value: "bar".into(), + path: Some("/common".into()), + domain: Some("web-platform.test".into()), + secure: false, + http_only: false, + expiry: None, + same_site: Some("Strict".into()), + }); + assert_ser_de( + &MarionetteResult::Cookies(data), + json!([{"name":"foo","value":"bar","path":"/common","domain":"web-platform.test","secure":false,"httpOnly":false,"sameSite":"Strict"}]), + ); + } + + #[test] + fn test_new_window_response() { + let data = NewWindow { + handle: "6442450945".into(), + type_hint: "tab".into(), + }; + let json = json!({"handle": "6442450945", "type": "tab"}); + assert_ser_de(&MarionetteResult::NewWindow(data), json); + } + + #[test] + fn test_web_element_response() { + let data = WebElement { + element: "foo".into(), + }; + assert_ser_de( + &MarionetteResult::WebElement(data), + json!({"value": {ELEMENT_KEY: "foo"}}), + ); + } + + #[test] + fn test_web_elements_response() { + let data = vec![ + WebElement { + element: "foo".into(), + }, + WebElement { + element: "bar".into(), + }, + ]; + assert_ser_de( + &MarionetteResult::WebElements(data), + json!([{ELEMENT_KEY: "foo"}, {ELEMENT_KEY: "bar"}]), + ); + } + + #[test] + fn test_timeouts_response() { + let data = Timeouts { + implicit: Some(1000), + page_load: Some(200000), + script: Some(Some(60000)), + }; + assert_ser_de( + &MarionetteResult::Timeouts(data), + json!({"implicit":1000,"pageLoad":200000,"script":60000}), + ); + } + + #[test] + fn test_string_response() { + assert_ser_de( + &MarionetteResult::String("foo".into()), + json!({"value": "foo"}), + ); + } + + #[test] + fn test_strings_response() { + assert_ser_de( + &MarionetteResult::Strings(vec!["2147483649".to_string()]), + json!(["2147483649"]), + ); + } + + #[test] + fn test_null_response() { + assert_ser_de(&MarionetteResult::Null, json!({ "value": null })); + } + + #[test] + fn test_window_rect_response() { + let data = WindowRect { + x: 100, + y: 100, + width: 800, + height: 600, + }; + let json = json!({"x": 100, "y": 100, "width": 800, "height": 600}); + assert_ser_de(&MarionetteResult::WindowRect(data), json); + } + + #[test] + fn test_element_rect_response() { + let data = ElementRect { + x: 8.0, + y: 8.0, + width: 148.6666717529297, + height: 22.0, + }; + let json = json!({"x": 8, "y": 8, "width": 148.6666717529297, "height": 22}); + assert_de(&MarionetteResult::ElementRect(data), json); + } +} diff --git a/testing/geckodriver/marionette/src/test.rs b/testing/geckodriver/marionette/src/test.rs new file mode 100644 index 0000000000..3b20bb0917 --- /dev/null +++ b/testing/geckodriver/marionette/src/test.rs @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +pub static ELEMENT_KEY: &'static str = "element-6066-11e4-a52e-4f735466cecf"; + +pub fn assert_ser_de<T>(data: &T, json: serde_json::Value) +where + T: std::fmt::Debug, + T: std::cmp::PartialEq, + T: serde::de::DeserializeOwned, + T: serde::Serialize, +{ + assert_eq!(serde_json::to_value(data).unwrap(), json); + assert_eq!(data, &serde_json::from_value::<T>(json).unwrap()); +} + +#[allow(dead_code)] +pub fn assert_ser<T>(data: &T, json: serde_json::Value) +where + T: std::fmt::Debug, + T: std::cmp::PartialEq, + T: serde::Serialize, +{ + assert_eq!(serde_json::to_value(data).unwrap(), json); +} + +pub fn assert_de<T>(data: &T, json: serde_json::Value) +where + T: std::fmt::Debug, + T: std::cmp::PartialEq, + T: serde::de::DeserializeOwned, +{ + assert_eq!(data, &serde_json::from_value::<T>(json).unwrap()); +} diff --git a/testing/geckodriver/marionette/src/webdriver.rs b/testing/geckodriver/marionette/src/webdriver.rs new file mode 100644 index 0000000000..fa1a48927c --- /dev/null +++ b/testing/geckodriver/marionette/src/webdriver.rs @@ -0,0 +1,512 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::common::{from_cookie, from_name, to_cookie, to_name, Cookie, Frame, Timeouts, Window}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Url { + pub url: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Locator { + pub using: Selector, + pub value: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Selector { + #[serde(rename = "css selector")] + Css, + #[serde(rename = "link text")] + LinkText, + #[serde(rename = "partial link text")] + PartialLinkText, + #[serde(rename = "tag name")] + TagName, + #[serde(rename = "xpath")] + XPath, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct NewWindow { + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_hint: Option<String>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WindowRect { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x: Option<i32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub y: Option<i32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option<i32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option<i32>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Keys { + pub text: String, + pub value: Vec<String>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintParameters { + pub orientation: PrintOrientation, + pub scale: f64, + pub background: bool, + pub page: PrintPage, + pub margin: PrintMargins, + pub page_ranges: Vec<String>, + pub shrink_to_fit: bool, +} + +impl Default for PrintParameters { + fn default() -> Self { + PrintParameters { + orientation: PrintOrientation::default(), + scale: 1.0, + background: false, + page: PrintPage::default(), + margin: PrintMargins::default(), + page_ranges: Vec::new(), + shrink_to_fit: true, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PrintOrientation { + Landscape, + Portrait, +} + +impl Default for PrintOrientation { + fn default() -> Self { + PrintOrientation::Portrait + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PrintPage { + pub width: f64, + pub height: f64, +} + +impl Default for PrintPage { + fn default() -> Self { + PrintPage { + width: 21.59, + height: 27.94, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PrintMargins { + pub top: f64, + pub bottom: f64, + pub left: f64, + pub right: f64, +} + +impl Default for PrintMargins { + fn default() -> Self { + PrintMargins { + top: 1.0, + bottom: 1.0, + left: 1.0, + right: 1.0, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ScreenshotOptions { + pub id: Option<String>, + pub highlights: Vec<Option<String>>, + pub full: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Script { + pub script: String, + pub args: Option<Vec<Value>>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Command { + #[serde(rename = "WebDriver:AcceptAlert")] + AcceptAlert, + #[serde( + rename = "WebDriver:AddCookie", + serialize_with = "to_cookie", + deserialize_with = "from_cookie" + )] + AddCookie(Cookie), + #[serde(rename = "WebDriver:CloseWindow")] + CloseWindow, + #[serde( + rename = "WebDriver:DeleteCookie", + serialize_with = "to_name", + deserialize_with = "from_name" + )] + DeleteCookie(String), + #[serde(rename = "WebDriver:DeleteAllCookies")] + DeleteCookies, + #[serde(rename = "WebDriver:DeleteSession")] + DeleteSession, + #[serde(rename = "WebDriver:DismissAlert")] + DismissAlert, + #[serde(rename = "WebDriver:ElementClear")] + ElementClear { id: String }, + #[serde(rename = "WebDriver:ElementClick")] + ElementClick { id: String }, + #[serde(rename = "WebDriver:ElementSendKeys")] + ElementSendKeys { + id: String, + text: String, + value: Vec<String>, + }, + #[serde(rename = "WebDriver:ExecuteAsyncScript")] + ExecuteAsyncScript(Script), + #[serde(rename = "WebDriver:ExecuteScript")] + ExecuteScript(Script), + #[serde(rename = "WebDriver:FindElement")] + FindElement(Locator), + #[serde(rename = "WebDriver:FindElements")] + FindElements(Locator), + #[serde(rename = "WebDriver:FindElement")] + FindElementElement { + element: String, + using: Selector, + value: String, + }, + #[serde(rename = "WebDriver:FindElements")] + FindElementElements { + element: String, + using: Selector, + value: String, + }, + #[serde(rename = "WebDriver:FindElementFromShadowRoot")] + FindShadowRootElement { + #[serde(rename = "shadowRoot")] + shadow_root: String, + using: Selector, + value: String, + }, + #[serde(rename = "WebDriver:FindElementsFromShadowRoot")] + FindShadowRootElements { + #[serde(rename = "shadowRoot")] + shadow_root: String, + using: Selector, + value: String, + }, + #[serde(rename = "WebDriver:FullscreenWindow")] + FullscreenWindow, + #[serde(rename = "WebDriver:Navigate")] + Get(Url), + #[serde(rename = "WebDriver:GetActiveElement")] + GetActiveElement, + #[serde(rename = "WebDriver:GetAlertText")] + GetAlertText, + #[serde(rename = "WebDriver:GetComputedLabel")] + GetComputedLabel { id: String }, + #[serde(rename = "WebDriver:GetComputedRole")] + GetComputedRole { id: String }, + #[serde(rename = "WebDriver:GetCookies")] + GetCookies, + #[serde(rename = "WebDriver:GetElementCSSValue")] + GetCSSValue { + id: String, + #[serde(rename = "propertyName")] + property: String, + }, + #[serde(rename = "WebDriver:GetCurrentURL")] + GetCurrentUrl, + #[serde(rename = "WebDriver:GetElementAttribute")] + GetElementAttribute { id: String, name: String }, + #[serde(rename = "WebDriver:GetElementProperty")] + GetElementProperty { id: String, name: String }, + #[serde(rename = "WebDriver:GetElementRect")] + GetElementRect { id: String }, + #[serde(rename = "WebDriver:GetElementTagName")] + GetElementTagName { id: String }, + #[serde(rename = "WebDriver:GetElementText")] + GetElementText { id: String }, + #[serde(rename = "WebDriver:GetPageSource")] + GetPageSource, + #[serde(rename = "WebDriver:GetShadowRoot")] + GetShadowRoot { id: String }, + #[serde(rename = "WebDriver:GetTimeouts")] + GetTimeouts, + #[serde(rename = "WebDriver:GetTitle")] + GetTitle, + #[serde(rename = "WebDriver:GetWindowHandle")] + GetWindowHandle, + #[serde(rename = "WebDriver:GetWindowHandles")] + GetWindowHandles, + #[serde(rename = "WebDriver:GetWindowRect")] + GetWindowRect, + #[serde(rename = "WebDriver:Back")] + GoBack, + #[serde(rename = "WebDriver:Forward")] + GoForward, + #[serde(rename = "WebDriver:IsElementDisplayed")] + IsDisplayed { id: String }, + #[serde(rename = "WebDriver:IsElementEnabled")] + IsEnabled { id: String }, + #[serde(rename = "WebDriver:IsElementSelected")] + IsSelected { id: String }, + #[serde(rename = "WebDriver:MaximizeWindow")] + MaximizeWindow, + #[serde(rename = "WebDriver:MinimizeWindow")] + MinimizeWindow, + #[serde(rename = "WebDriver:NewWindow")] + NewWindow(NewWindow), + #[serde(rename = "WebDriver:Print")] + Print(PrintParameters), + #[serde(rename = "WebDriver:Refresh")] + Refresh, + #[serde(rename = "WebDriver:ReleaseActions")] + ReleaseActions, + #[serde(rename = "WebDriver:SendAlertText")] + SendAlertText(Keys), + #[serde(rename = "WebDriver:SetTimeouts")] + SetTimeouts(Timeouts), + #[serde(rename = "WebDriver:SetWindowRect")] + SetWindowRect(WindowRect), + #[serde(rename = "WebDriver:SwitchToFrame")] + SwitchToFrame(Frame), + #[serde(rename = "WebDriver:SwitchToParentFrame")] + SwitchToParentFrame, + #[serde(rename = "WebDriver:SwitchToWindow")] + SwitchToWindow(Window), + #[serde(rename = "WebDriver:TakeScreenshot")] + TakeElementScreenshot(ScreenshotOptions), + #[serde(rename = "WebDriver:TakeScreenshot")] + TakeFullScreenshot(ScreenshotOptions), + #[serde(rename = "WebDriver:TakeScreenshot")] + TakeScreenshot(ScreenshotOptions), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::Date; + use crate::test::{assert_ser, assert_ser_de}; + use serde_json::json; + + #[test] + fn test_json_screenshot() { + let data = ScreenshotOptions { + id: None, + highlights: vec![], + full: false, + }; + let json = json!({"full":false,"highlights":[],"id":null}); + assert_ser_de(&data, json); + } + + #[test] + fn test_json_selector_css() { + assert_ser_de(&Selector::Css, json!("css selector")); + } + + #[test] + fn test_json_selector_link_text() { + assert_ser_de(&Selector::LinkText, json!("link text")); + } + + #[test] + fn test_json_selector_partial_link_text() { + assert_ser_de(&Selector::PartialLinkText, json!("partial link text")); + } + + #[test] + fn test_json_selector_tag_name() { + assert_ser_de(&Selector::TagName, json!("tag name")); + } + + #[test] + fn test_json_selector_xpath() { + assert_ser_de(&Selector::XPath, json!("xpath")); + } + + #[test] + fn test_json_selector_invalid() { + assert!(serde_json::from_value::<Selector>(json!("foo")).is_err()); + } + + #[test] + fn test_json_locator() { + let json = json!({ + "using": "partial link text", + "value": "link text", + }); + let data = Locator { + using: Selector::PartialLinkText, + value: "link text".into(), + }; + + assert_ser_de(&data, json); + } + + #[test] + fn test_json_keys() { + let data = Keys { + text: "Foo".into(), + value: vec!["F".into(), "o".into(), "o".into()], + }; + let json = json!({"text": "Foo", "value": ["F", "o", "o"]}); + assert_ser_de(&data, json); + } + + #[test] + fn test_json_new_window() { + let data = NewWindow { + type_hint: Some("foo".into()), + }; + assert_ser_de(&data, json!({ "type": "foo" })); + } + + #[test] + fn test_json_window_rect() { + let data = WindowRect { + x: Some(123), + y: None, + width: None, + height: None, + }; + assert_ser_de(&data, json!({"x": 123})); + } + + #[test] + fn test_command_with_params() { + let locator = Locator { + using: Selector::Css, + value: "value".into(), + }; + let json = json!({"WebDriver:FindElement": {"using": "css selector", "value": "value"}}); + assert_ser_de(&Command::FindElement(locator), json); + } + + #[test] + fn test_command_with_wrapper_params() { + let cookie = Cookie { + name: "hello".into(), + value: "world".into(), + path: None, + domain: None, + secure: false, + http_only: false, + expiry: Some(Date(1564488092)), + same_site: None, + }; + let json = json!({"WebDriver:AddCookie": {"cookie": {"name": "hello", "value": "world", "secure": false, "httpOnly": false, "expiry": 1564488092}}}); + assert_ser_de(&Command::AddCookie(cookie), json); + } + + #[test] + fn test_empty_commands() { + assert_ser_de(&Command::GetTimeouts, json!("WebDriver:GetTimeouts")); + } + + #[test] + fn test_json_command_invalid() { + assert!(serde_json::from_value::<Command>(json!("foo")).is_err()); + } + + #[test] + fn test_json_delete_cookie_command() { + let json = json!({"WebDriver:DeleteCookie": {"name": "foo"}}); + assert_ser_de(&Command::DeleteCookie("foo".into()), json); + } + + #[test] + fn test_json_new_window_command() { + let data = NewWindow { + type_hint: Some("foo".into()), + }; + let json = json!({"WebDriver:NewWindow": {"type": "foo"}}); + assert_ser_de(&Command::NewWindow(data), json); + } + + #[test] + fn test_json_new_window_command_with_none_value() { + let data = NewWindow { type_hint: None }; + let json = json!({"WebDriver:NewWindow": {}}); + assert_ser_de(&Command::NewWindow(data), json); + } + + #[test] + fn test_json_command_as_struct() { + assert_ser( + &Command::FindElementElement { + element: "foo".into(), + using: Selector::XPath, + value: "bar".into(), + }, + json!({"WebDriver:FindElement": {"element": "foo", "using": "xpath", "value": "bar" }}), + ); + } + + #[test] + fn test_json_get_computed_label_command() { + assert_ser_de( + &Command::GetComputedLabel { id: "foo".into() }, + json!({"WebDriver:GetComputedLabel": {"id": "foo"}}), + ); + } + + #[test] + fn test_json_get_computed_role_command() { + assert_ser_de( + &Command::GetComputedRole { id: "foo".into() }, + json!({"WebDriver:GetComputedRole": {"id": "foo"}}), + ); + } + + #[test] + fn test_json_get_css_value() { + assert_ser_de( + &Command::GetCSSValue { + id: "foo".into(), + property: "bar".into(), + }, + json!({"WebDriver:GetElementCSSValue": {"id": "foo", "propertyName": "bar"}}), + ); + } + + #[test] + fn test_json_find_shadow_root_element() { + assert_ser_de( + &Command::FindShadowRootElement { + shadow_root: "foo".into(), + using: Selector::Css, + value: "bar".into(), + }, + json!({"WebDriver:FindElementFromShadowRoot": {"shadowRoot": "foo", "using": "css selector", "value": "bar"}}), + ); + } + + #[test] + fn test_json_find_shadow_root_elements() { + assert_ser_de( + &Command::FindShadowRootElements { + shadow_root: "foo".into(), + using: Selector::Css, + value: "bar".into(), + }, + json!({"WebDriver:FindElementsFromShadowRoot": {"shadowRoot": "foo", "using": "css selector", "value": "bar"}}), + ); + } +} diff --git a/testing/geckodriver/moz.build b/testing/geckodriver/moz.build new file mode 100644 index 0000000000..9cda32b430 --- /dev/null +++ b/testing/geckodriver/moz.build @@ -0,0 +1,27 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +RUST_PROGRAMS += ["geckodriver"] +# Some Rust build scripts compile C/C++ sources, don't error on warnings for them. +AllowCompilerWarnings() + +RUST_TESTS = [ + "geckodriver", + "webdriver", + "marionette", + # TODO: Move to mozbase/rust/moz.build once those crates can be + # tested separately. + "mozdevice", + "mozprofile", + "mozrunner", + "mozversion", +] + +with Files("**"): + BUG_COMPONENT = ("Testing", "geckodriver") + +SPHINX_TREES["/testing/geckodriver"] = "doc" + +with Files("doc/**"): + SCHEDULES.exclusive = ["docs"] diff --git a/testing/geckodriver/src/android.rs b/testing/geckodriver/src/android.rs new file mode 100644 index 0000000000..10293d5c4e --- /dev/null +++ b/testing/geckodriver/src/android.rs @@ -0,0 +1,533 @@ +use crate::capabilities::AndroidOptions; +use mozdevice::{AndroidStorage, Device, Host, UnixPathBuf}; +use mozprofile::profile::Profile; +use serde::Serialize; +use serde_yaml::{Mapping, Value}; +use std::fmt; +use std::io; +use std::time; +use webdriver::error::{ErrorStatus, WebDriverError}; + +// TODO: avoid port clashes across GeckoView-vehicles. +// For now, we always use target port 2829, leading to issues like bug 1533704. +const MARIONETTE_TARGET_PORT: u16 = 2829; + +const CONFIG_FILE_HEADING: &str = r#"## GeckoView configuration YAML +## +## Auto-generated by geckodriver. +## See https://mozilla.github.io/geckoview/consumer/docs/automation. +"#; + +pub type Result<T> = std::result::Result<T, AndroidError>; + +#[derive(Debug)] +pub enum AndroidError { + ActivityNotFound(String), + Device(mozdevice::DeviceError), + IO(io::Error), + PackageNotFound(String), + Serde(serde_yaml::Error), +} + +impl fmt::Display for AndroidError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + AndroidError::ActivityNotFound(ref package) => { + write!(f, "Activity for package '{}' not found", package) + } + AndroidError::Device(ref message) => message.fmt(f), + AndroidError::IO(ref message) => message.fmt(f), + AndroidError::PackageNotFound(ref package) => { + write!(f, "Package '{}' not found", package) + } + AndroidError::Serde(ref message) => message.fmt(f), + } + } +} + +impl From<io::Error> for AndroidError { + fn from(value: io::Error) -> AndroidError { + AndroidError::IO(value) + } +} + +impl From<mozdevice::DeviceError> for AndroidError { + fn from(value: mozdevice::DeviceError) -> AndroidError { + AndroidError::Device(value) + } +} + +impl From<serde_yaml::Error> for AndroidError { + fn from(value: serde_yaml::Error) -> AndroidError { + AndroidError::Serde(value) + } +} + +impl From<AndroidError> for WebDriverError { + fn from(value: AndroidError) -> WebDriverError { + WebDriverError::new(ErrorStatus::UnknownError, value.to_string()) + } +} + +/// A remote Gecko instance. +/// +/// Host refers to the device running `geckodriver`. Target refers to the +/// Android device running Gecko in a GeckoView-based vehicle. +#[derive(Debug)] +pub struct AndroidProcess { + pub device: Device, + pub package: String, + pub activity: String, +} + +impl AndroidProcess { + pub fn new( + device: Device, + package: String, + activity: String, + ) -> mozdevice::Result<AndroidProcess> { + Ok(AndroidProcess { + device, + package, + activity, + }) + } +} + +#[derive(Debug)] +pub struct AndroidHandler { + pub config: UnixPathBuf, + pub options: AndroidOptions, + pub process: AndroidProcess, + pub profile: UnixPathBuf, + pub test_root: UnixPathBuf, + + // Port forwarding for Marionette: host => target + pub marionette_host_port: u16, + pub marionette_target_port: u16, + + // Port forwarding for WebSocket connections (WebDriver BiDi and CDP) + pub websocket_port: Option<u16>, +} + +impl Drop for AndroidHandler { + fn drop(&mut self) { + // Try to clean up various settings + let clear_command = format!("am clear-debug-app {}", self.process.package); + match self + .process + .device + .execute_host_shell_command(&clear_command) + { + Ok(_) => debug!("Disabled reading from configuration file"), + Err(e) => error!("Failed disabling from configuration file: {}", e), + } + + match self.process.device.remove(&self.config) { + Ok(_) => debug!("Deleted GeckoView configuration file"), + Err(e) => error!("Failed deleting GeckoView configuration file: {}", e), + } + + match self.process.device.remove(&self.test_root) { + Ok(_) => debug!("Deleted test root folder: {}", &self.test_root.display()), + Err(e) => error!("Failed deleting test root folder: {}", e), + } + + match self + .process + .device + .kill_forward_port(self.marionette_host_port) + { + Ok(_) => debug!( + "Marionette port forward ({} -> {}) stopped", + &self.marionette_host_port, &self.marionette_target_port + ), + Err(e) => error!( + "Marionette port forward ({} -> {}) failed to stop: {}", + &self.marionette_host_port, &self.marionette_target_port, e + ), + } + + if let Some(port) = self.websocket_port { + match self.process.device.kill_forward_port(port) { + Ok(_) => debug!("WebSocket port forward ({0} -> {0}) stopped", &port), + Err(e) => error!( + "WebSocket port forward ({0} -> {0}) failed to stop: {1}", + &port, e + ), + } + } + } +} + +impl AndroidHandler { + pub fn new( + options: &AndroidOptions, + marionette_host_port: u16, + websocket_port: Option<u16>, + ) -> Result<AndroidHandler> { + // We need to push profile.pathbuf to a safe space on the device. + // Make it per-Android package to avoid clashes and confusion. + // This naming scheme follows GeckoView's configuration file naming scheme, + // see bug 1533385. + + let host = Host { + host: None, + port: None, + read_timeout: Some(time::Duration::from_millis(5000)), + write_timeout: Some(time::Duration::from_millis(5000)), + }; + + let mut device = host.device_or_default(options.device_serial.as_ref(), options.storage)?; + + // Set up port forwarding for Marionette. + device.forward_port(marionette_host_port, MARIONETTE_TARGET_PORT)?; + debug!( + "Marionette port forward ({} -> {}) started", + marionette_host_port, MARIONETTE_TARGET_PORT + ); + + if let Some(port) = websocket_port { + // Set up port forwarding for WebSocket connections (WebDriver BiDi, and CDP). + device.forward_port(port, port)?; + debug!("WebSocket port forward ({} -> {}) started", port, port); + } + + let test_root = match device.storage { + AndroidStorage::App => { + device.run_as_package = Some(options.package.to_owned()); + let mut buf = UnixPathBuf::from("/data/data"); + buf.push(&options.package); + buf.push("test_root"); + buf + } + AndroidStorage::Internal => UnixPathBuf::from("/data/local/tmp/test_root"), + AndroidStorage::Sdcard => { + // We need to push the profile to a location on the device that can also + // be read and write by the application, and works for unrooted devices. + // The only location that meets this criteria is under: + // $EXTERNAL_STORAGE/Android/data/%options.package%/files + let response = device.execute_host_shell_command("echo $EXTERNAL_STORAGE")?; + let mut buf = UnixPathBuf::from(response.trim_end_matches('\n')); + buf.push("Android/data"); + buf.push(&options.package); + buf.push("files/test_root"); + buf + } + }; + + debug!( + "Connecting: options={:?}, storage={:?}) test_root={}, run_as_package={:?}", + options, + device.storage, + test_root.display(), + device.run_as_package + ); + + let mut profile = test_root.clone(); + profile.push(format!("{}-geckodriver-profile", &options.package)); + + // Check if the specified package is installed + let response = + device.execute_host_shell_command(&format!("pm list packages {}", &options.package))?; + let mut packages = response + .trim() + .split_terminator('\n') + .filter(|line| line.starts_with("package:")) + .map(|line| line.rsplit(':').next().expect("Package name found")); + if !packages.any(|x| x == options.package.as_str()) { + return Err(AndroidError::PackageNotFound(options.package.clone())); + } + + let config = UnixPathBuf::from(format!( + "/data/local/tmp/{}-geckoview-config.yaml", + &options.package + )); + + // If activity hasn't been specified default to the main activity of the package + let activity = match options.activity { + Some(ref activity) => activity.clone(), + None => { + let response = device.execute_host_shell_command(&format!( + "cmd package resolve-activity --brief {}", + &options.package + ))?; + let activities = response + .split_terminator('\n') + .filter(|line| line.starts_with(&options.package)) + .map(|line| line.rsplit('/').next().unwrap()) + .collect::<Vec<&str>>(); + if activities.is_empty() { + return Err(AndroidError::ActivityNotFound(options.package.clone())); + } + + activities[0].to_owned() + } + }; + + let process = AndroidProcess::new(device, options.package.clone(), activity)?; + + Ok(AndroidHandler { + config, + process, + profile, + test_root, + marionette_host_port, + marionette_target_port: MARIONETTE_TARGET_PORT, + options: options.clone(), + websocket_port, + }) + } + + pub fn generate_config_file<I, K, V>( + &self, + args: Option<Vec<String>>, + envs: I, + ) -> Result<String> + where + I: IntoIterator<Item = (K, V)>, + K: ToString, + V: ToString, + { + // To configure GeckoView, we use the automation techniques documented at + // https://mozilla.github.io/geckoview/consumer/docs/automation. + #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] + pub struct Config { + pub env: Mapping, + pub args: Vec<String>, + } + + let mut config = Config { + args: vec![ + "--marionette".into(), + "--profile".into(), + self.profile.display().to_string(), + ], + env: Mapping::new(), + }; + + config.args.append(&mut args.unwrap_or_default()); + + for (key, value) in envs { + config.env.insert( + Value::String(key.to_string()), + Value::String(value.to_string()), + ); + } + + config.env.insert( + Value::String("MOZ_CRASHREPORTER".to_owned()), + Value::String("1".to_owned()), + ); + config.env.insert( + Value::String("MOZ_CRASHREPORTER_NO_REPORT".to_owned()), + Value::String("1".to_owned()), + ); + config.env.insert( + Value::String("MOZ_CRASHREPORTER_SHUTDOWN".to_owned()), + Value::String("1".to_owned()), + ); + + let mut contents: Vec<String> = vec![CONFIG_FILE_HEADING.to_owned()]; + contents.push(serde_yaml::to_string(&config)?); + + Ok(contents.concat()) + } + + pub fn prepare<I, K, V>( + &self, + profile: &Profile, + args: Option<Vec<String>>, + env: I, + ) -> Result<()> + where + I: IntoIterator<Item = (K, V)>, + K: ToString, + V: ToString, + { + self.process.device.clear_app_data(&self.process.package)?; + + // These permissions, at least, are required to read profiles in /mnt/sdcard. + for perm in &["READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"] { + self.process.device.execute_host_shell_command(&format!( + "pm grant {} android.permission.{}", + &self.process.package, perm + ))?; + } + + // Make sure to create the test root. + self.process.device.create_dir(&self.test_root)?; + self.process.device.chmod(&self.test_root, "777", true)?; + + // Replace the profile + self.process.device.remove(&self.profile)?; + self.process + .device + .push_dir(&profile.path, &self.profile, 0o777)?; + + let contents = self.generate_config_file(args, env)?; + debug!("Content of generated GeckoView config file:\n{}", contents); + let reader = &mut io::BufReader::new(contents.as_bytes()); + + debug!( + "Pushing GeckoView configuration file to {}", + self.config.display() + ); + self.process.device.push(reader, &self.config, 0o777)?; + + // Tell GeckoView to read configuration even when `android:debuggable="false"`. + self.process.device.execute_host_shell_command(&format!( + "am set-debug-app --persistent {}", + self.process.package + ))?; + + Ok(()) + } + + pub fn launch(&self) -> Result<()> { + // TODO: Remove the usage of intent arguments once Fennec is no longer + // supported. Packages which are using GeckoView always read the arguments + // via the YAML configuration file. + let mut intent_arguments = self + .options + .intent_arguments + .clone() + .unwrap_or_else(|| Vec::with_capacity(3)); + intent_arguments.push("--es".to_owned()); + intent_arguments.push("args".to_owned()); + intent_arguments.push(format!("--marionette --profile {}", self.profile.display())); + + debug!( + "Launching {}/{}", + self.process.package, self.process.activity + ); + self.process + .device + .launch( + &self.process.package, + &self.process.activity, + &intent_arguments, + ) + .map_err(|e| { + let message = format!( + "Could not launch Android {}/{}: {}", + self.process.package, self.process.activity, e + ); + mozdevice::DeviceError::Adb(message) + })?; + + Ok(()) + } + + pub fn force_stop(&self) -> Result<()> { + debug!( + "Force stopping the Android package: {}", + &self.process.package + ); + self.process.device.force_stop(&self.process.package)?; + + Ok(()) + } +} + +#[cfg(test)] +mod test { + // To successfully run those tests the geckoview_example package needs to + // be installed on the device or emulator. After setting up the build + // environment (https://mzl.la/3muLv5M), the following mach commands have to + // be executed: + // + // $ ./mach build && ./mach install + // + // Currently the mozdevice API is not safe for multiple requests at the same + // time. It is recommended to run each of the unit tests on its own. Also adb + // specific tests cannot be run in CI yet. To check those locally, also run + // the ignored tests. + // + // Use the following command to accomplish that: + // + // $ cargo test -- --ignored --test-threads=1 + + use crate::android::AndroidHandler; + use crate::capabilities::AndroidOptions; + use mozdevice::{AndroidStorage, AndroidStorageInput, UnixPathBuf}; + + fn run_handler_storage_test(package: &str, storage: AndroidStorageInput) { + let options = AndroidOptions::new(package.to_owned(), storage); + let handler = AndroidHandler::new(&options, 4242, None).expect("has valid Android handler"); + + assert_eq!(handler.options, options); + assert_eq!(handler.process.package, package); + + let expected_config_path = UnixPathBuf::from(format!( + "/data/local/tmp/{}-geckoview-config.yaml", + &package + )); + assert_eq!(handler.config, expected_config_path); + + if handler.process.device.storage == AndroidStorage::App { + assert_eq!( + handler.process.device.run_as_package, + Some(package.to_owned()) + ); + } else { + assert_eq!(handler.process.device.run_as_package, None); + } + + let test_root = match handler.process.device.storage { + AndroidStorage::App => { + let mut buf = UnixPathBuf::from("/data/data"); + buf.push(&package); + buf.push("test_root"); + buf + } + AndroidStorage::Internal => UnixPathBuf::from("/data/local/tmp/test_root"), + AndroidStorage::Sdcard => { + let response = handler + .process + .device + .execute_host_shell_command("echo $EXTERNAL_STORAGE") + .unwrap(); + + let mut buf = UnixPathBuf::from(response.trim_end_matches('\n')); + buf.push("Android/data/"); + buf.push(&package); + buf.push("files/test_root"); + buf + } + }; + assert_eq!(handler.test_root, test_root); + + let mut profile = test_root; + profile.push(format!("{}-geckodriver-profile", &package)); + assert_eq!(handler.profile, profile); + } + + #[test] + #[ignore] + fn android_handler_storage_as_app() { + let package = "org.mozilla.geckoview_example"; + run_handler_storage_test(package, AndroidStorageInput::App); + } + + #[test] + #[ignore] + fn android_handler_storage_as_auto() { + let package = "org.mozilla.geckoview_example"; + run_handler_storage_test(package, AndroidStorageInput::Auto); + } + + #[test] + #[ignore] + fn android_handler_storage_as_internal() { + let package = "org.mozilla.geckoview_example"; + run_handler_storage_test(package, AndroidStorageInput::Internal); + } + + #[test] + #[ignore] + fn android_handler_storage_as_sdcard() { + let package = "org.mozilla.geckoview_example"; + run_handler_storage_test(package, AndroidStorageInput::Sdcard); + } +} diff --git a/testing/geckodriver/src/browser.rs b/testing/geckodriver/src/browser.rs new file mode 100644 index 0000000000..a33a755fa2 --- /dev/null +++ b/testing/geckodriver/src/browser.rs @@ -0,0 +1,556 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::android::AndroidHandler; +use crate::capabilities::{FirefoxOptions, ProfileType}; +use crate::logging; +use crate::prefs; +use mozprofile::preferences::Pref; +use mozprofile::profile::{PrefFile, Profile}; +use mozrunner::runner::{FirefoxProcess, FirefoxRunner, Runner, RunnerProcess}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time; +use webdriver::error::{ErrorStatus, WebDriverError, WebDriverResult}; + +/// A running Gecko instance. +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub(crate) enum Browser { + Local(LocalBrowser), + Remote(RemoteBrowser), + + /// An existing browser instance not controlled by GeckoDriver + Existing(u16), +} + +impl Browser { + pub(crate) fn close(self, wait_for_shutdown: bool) -> WebDriverResult<()> { + match self { + Browser::Local(x) => x.close(wait_for_shutdown), + Browser::Remote(x) => x.close(), + Browser::Existing(_) => Ok(()), + } + } + + pub(crate) fn marionette_port(&mut self) -> WebDriverResult<Option<u16>> { + match self { + Browser::Local(x) => x.marionette_port(), + Browser::Remote(x) => x.marionette_port(), + Browser::Existing(x) => Ok(Some(*x)), + } + } + + pub(crate) fn update_marionette_port(&mut self, port: u16) { + match self { + Browser::Local(x) => x.update_marionette_port(port), + Browser::Remote(x) => x.update_marionette_port(port), + Browser::Existing(x) => { + if port != *x { + error!( + "Cannot re-assign Marionette port when connected to an existing browser" + ); + } + } + } + } +} + +#[derive(Debug)] +/// A local Firefox process, running on this (host) device. +pub(crate) struct LocalBrowser { + marionette_port: u16, + prefs_backup: Option<PrefsBackup>, + process: FirefoxProcess, + profile_path: Option<PathBuf>, +} + +impl LocalBrowser { + pub(crate) fn new( + options: FirefoxOptions, + marionette_port: u16, + jsdebugger: bool, + profile_root: Option<&Path>, + ) -> WebDriverResult<LocalBrowser> { + let binary = options.binary.ok_or_else(|| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + "Expected browser binary location, but unable to find \ + binary in default location, no \ + 'moz:firefoxOptions.binary' capability provided, and \ + no binary flag set on the command line", + ) + })?; + + let is_custom_profile = matches!(options.profile, ProfileType::Path(_)); + + let mut profile = match options.profile { + ProfileType::Named => None, + ProfileType::Path(x) => Some(x), + ProfileType::Temporary => Some(Profile::new(profile_root)?), + }; + + let (profile_path, prefs_backup) = if let Some(ref mut profile) = profile { + let profile_path = profile.path.clone(); + let prefs_backup = set_prefs( + marionette_port, + profile, + is_custom_profile, + options.prefs, + jsdebugger, + ) + .map_err(|e| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + format!("Failed to set preferences: {}", e), + ) + })?; + (Some(profile_path), prefs_backup) + } else { + warn!("Unable to set geckodriver prefs when using a named profile"); + (None, None) + }; + + let mut runner = FirefoxRunner::new(&binary, profile); + + runner.arg("--marionette"); + if jsdebugger { + runner.arg("--jsdebugger"); + } + if let Some(args) = options.args.as_ref() { + runner.args(args); + } + + // https://developer.mozilla.org/docs/Environment_variables_affecting_crash_reporting + runner + .env("MOZ_CRASHREPORTER", "1") + .env("MOZ_CRASHREPORTER_NO_REPORT", "1") + .env("MOZ_CRASHREPORTER_SHUTDOWN", "1"); + + let process = match runner.start() { + Ok(process) => process, + Err(e) => { + if let Some(backup) = prefs_backup { + backup.restore(); + } + return Err(WebDriverError::new( + ErrorStatus::SessionNotCreated, + format!("Failed to start browser {}: {}", binary.display(), e), + )); + } + }; + + Ok(LocalBrowser { + marionette_port, + prefs_backup, + process, + profile_path, + }) + } + + fn close(mut self, wait_for_shutdown: bool) -> WebDriverResult<()> { + if wait_for_shutdown { + // TODO(https://bugzil.la/1443922): + // Use toolkit.asyncshutdown.crash_timout pref + let duration = time::Duration::from_secs(70); + match self.process.wait(duration) { + Ok(x) => debug!("Browser process stopped: {}", x), + Err(e) => error!("Failed to stop browser process: {}", e), + } + } + self.process.kill()?; + + // Restoring the prefs if the browser fails to stop perhaps doesn't work anyway + if let Some(prefs_backup) = self.prefs_backup { + prefs_backup.restore(); + }; + + Ok(()) + } + + fn marionette_port(&mut self) -> WebDriverResult<Option<u16>> { + if self.marionette_port != 0 { + return Ok(Some(self.marionette_port)); + } + + if let Some(profile_path) = self.profile_path.as_ref() { + return Ok(read_marionette_port(profile_path)); + } + + // This should be impossible, but it isn't enforced + Err(WebDriverError::new( + ErrorStatus::SessionNotCreated, + "Port not known when using named profile", + )) + } + + fn update_marionette_port(&mut self, port: u16) { + self.marionette_port = port; + } + + pub(crate) fn check_status(&mut self) -> Option<String> { + match self.process.try_wait() { + Ok(Some(status)) => Some( + status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".into()), + ), + Ok(None) => None, + Err(_) => Some("{unknown}".into()), + } + } +} + +fn read_marionette_port(profile_path: &Path) -> Option<u16> { + let port_file = profile_path.join("MarionetteActivePort"); + let mut port_str = String::with_capacity(6); + let mut file = match fs::File::open(&port_file) { + Ok(file) => file, + Err(_) => { + trace!("Failed to open {}", &port_file.to_string_lossy()); + return None; + } + }; + if let Err(e) = file.read_to_string(&mut port_str) { + trace!("Failed to read {}: {}", &port_file.to_string_lossy(), e); + return None; + }; + println!("Read port: {}", port_str); + let port = port_str.parse::<u16>().ok(); + if port.is_none() { + warn!("Failed fo convert {} to u16", &port_str); + } + port +} + +#[derive(Debug)] +/// A remote instance, running on a (target) Android device. +pub(crate) struct RemoteBrowser { + handler: AndroidHandler, + marionette_port: u16, + prefs_backup: Option<PrefsBackup>, +} + +impl RemoteBrowser { + pub(crate) fn new( + options: FirefoxOptions, + marionette_port: u16, + websocket_port: Option<u16>, + profile_root: Option<&Path>, + ) -> WebDriverResult<RemoteBrowser> { + let android_options = options.android.unwrap(); + + let handler = AndroidHandler::new(&android_options, marionette_port, websocket_port)?; + + // Profile management. + let (mut profile, is_custom_profile) = match options.profile { + ProfileType::Named => { + return Err(WebDriverError::new( + ErrorStatus::SessionNotCreated, + "Cannot use a named profile on Android", + )); + } + ProfileType::Path(x) => (x, true), + ProfileType::Temporary => (Profile::new(profile_root)?, false), + }; + + let prefs_backup = set_prefs( + handler.marionette_target_port, + &mut profile, + is_custom_profile, + options.prefs, + false, + ) + .map_err(|e| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + format!("Failed to set preferences: {}", e), + ) + })?; + + handler.prepare(&profile, options.args, options.env.unwrap_or_default())?; + + handler.launch()?; + + Ok(RemoteBrowser { + handler, + marionette_port, + prefs_backup, + }) + } + + fn close(self) -> WebDriverResult<()> { + self.handler.force_stop()?; + + // Restoring the prefs if the browser fails to stop perhaps doesn't work anyway + if let Some(prefs_backup) = self.prefs_backup { + prefs_backup.restore(); + }; + + Ok(()) + } + + fn marionette_port(&mut self) -> WebDriverResult<Option<u16>> { + Ok(Some(self.marionette_port)) + } + + fn update_marionette_port(&mut self, port: u16) { + self.marionette_port = port; + } +} + +fn set_prefs( + port: u16, + profile: &mut Profile, + custom_profile: bool, + extra_prefs: Vec<(String, Pref)>, + js_debugger: bool, +) -> WebDriverResult<Option<PrefsBackup>> { + let prefs = profile.user_prefs().map_err(|_| { + WebDriverError::new( + ErrorStatus::UnknownError, + "Unable to read profile preferences file", + ) + })?; + + let backup_prefs = if custom_profile && prefs.path.exists() { + Some(PrefsBackup::new(prefs)?) + } else { + None + }; + + for &(name, ref value) in prefs::DEFAULT.iter() { + if !custom_profile || !prefs.contains_key(name) { + prefs.insert(name.to_string(), (*value).clone()); + } + } + + prefs.insert_slice(&extra_prefs[..]); + + if js_debugger { + prefs.insert("devtools.browsertoolbox.panel", Pref::new("jsdebugger")); + prefs.insert("devtools.debugger.remote-enabled", Pref::new(true)); + prefs.insert("devtools.chrome.enabled", Pref::new(true)); + prefs.insert("devtools.debugger.prompt-connection", Pref::new(false)); + } + + prefs.insert("marionette.port", Pref::new(port)); + prefs.insert("remote.log.level", logging::max_level().into()); + + prefs.write().map_err(|e| { + WebDriverError::new( + ErrorStatus::UnknownError, + format!("Unable to write Firefox profile: {}", e), + ) + })?; + Ok(backup_prefs) +} + +#[derive(Debug)] +struct PrefsBackup { + orig_path: PathBuf, + backup_path: PathBuf, +} + +impl PrefsBackup { + fn new(prefs: &PrefFile) -> WebDriverResult<PrefsBackup> { + let mut prefs_backup_path = prefs.path.clone(); + let mut counter = 0; + while { + let ext = if counter > 0 { + format!("geckodriver_backup_{}", counter) + } else { + "geckodriver_backup".to_string() + }; + prefs_backup_path.set_extension(ext); + prefs_backup_path.exists() + } { + counter += 1 + } + debug!("Backing up prefs to {:?}", prefs_backup_path); + fs::copy(&prefs.path, &prefs_backup_path)?; + + Ok(PrefsBackup { + orig_path: prefs.path.clone(), + backup_path: prefs_backup_path, + }) + } + + fn restore(self) { + if self.backup_path.exists() { + let _ = fs::rename(self.backup_path, self.orig_path); + } + } +} + +#[cfg(test)] +mod tests { + use super::set_prefs; + use crate::browser::read_marionette_port; + use crate::capabilities::{FirefoxOptions, ProfileType}; + use base64::prelude::BASE64_STANDARD; + use base64::Engine; + use mozprofile::preferences::{Pref, PrefValue}; + use mozprofile::profile::Profile; + use serde_json::{Map, Value}; + use std::fs::File; + use std::io::{Read, Write}; + use std::path::Path; + use tempfile::tempdir; + + fn example_profile() -> Value { + let mut profile_data = Vec::with_capacity(1024); + let mut profile = File::open("src/tests/profile.zip").unwrap(); + profile.read_to_end(&mut profile_data).unwrap(); + Value::String(BASE64_STANDARD.encode(&profile_data)) + } + + // This is not a pretty test, mostly due to the nature of + // mozprofile's and MarionetteHandler's APIs, but we have had + // several regressions related to remote.log.level. + #[test] + fn test_remote_log_level() { + let mut profile = Profile::new(None).unwrap(); + set_prefs(2828, &mut profile, false, vec![], false).ok(); + let user_prefs = profile.user_prefs().unwrap(); + + let pref = user_prefs.get("remote.log.level").unwrap(); + let value = match pref.value { + PrefValue::String(ref s) => s, + _ => panic!(), + }; + for (i, ch) in value.chars().enumerate() { + if i == 0 { + assert!(ch.is_uppercase()); + } else { + assert!(ch.is_lowercase()); + } + } + } + + #[test] + fn test_prefs() { + let marionette_settings = Default::default(); + + let encoded_profile = example_profile(); + let mut prefs: Map<String, Value> = Map::new(); + prefs.insert( + "browser.display.background_color".into(), + Value::String("#00ff00".into()), + ); + + let mut firefox_opts = Map::new(); + firefox_opts.insert("profile".into(), encoded_profile); + firefox_opts.insert("prefs".into(), Value::Object(prefs)); + + let mut caps = Map::new(); + caps.insert("moz:firefoxOptions".into(), Value::Object(firefox_opts)); + + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid profile and prefs"); + + let mut profile = match opts.profile { + ProfileType::Path(profile) => profile, + _ => panic!("Expected ProfileType::Path"), + }; + + set_prefs(2828, &mut profile, true, opts.prefs, false).expect("set preferences"); + + let prefs_set = profile.user_prefs().expect("valid user preferences"); + println!("{:#?}", prefs_set.prefs); + + assert_eq!( + prefs_set.get("startup.homepage_welcome_url"), + Some(&Pref::new("data:text/html,PASS")) + ); + assert_eq!( + prefs_set.get("browser.display.background_color"), + Some(&Pref::new("#00ff00")) + ); + assert_eq!(prefs_set.get("marionette.port"), Some(&Pref::new(2828))); + } + + #[test] + fn test_pref_backup() { + let mut profile = Profile::new(None).unwrap(); + + // Create some prefs in the profile + let initial_prefs = profile.user_prefs().unwrap(); + initial_prefs.insert("geckodriver.example", Pref::new("example")); + initial_prefs.write().unwrap(); + + let prefs_path = initial_prefs.path.clone(); + + let mut conflicting_backup_path = initial_prefs.path.clone(); + conflicting_backup_path.set_extension("geckodriver_backup"); + println!("{:?}", conflicting_backup_path); + let mut file = File::create(&conflicting_backup_path).unwrap(); + file.write_all(b"test").unwrap(); + assert!(conflicting_backup_path.exists()); + + let mut initial_prefs_data = String::new(); + File::open(&prefs_path) + .expect("Initial prefs exist") + .read_to_string(&mut initial_prefs_data) + .unwrap(); + + let backup = set_prefs(2828, &mut profile, true, vec![], false) + .unwrap() + .unwrap(); + let user_prefs = profile.user_prefs().unwrap(); + + assert!(user_prefs.path.exists()); + let mut backup_path = user_prefs.path.clone(); + backup_path.set_extension("geckodriver_backup_1"); + + assert!(backup_path.exists()); + + // Ensure the actual prefs contain both the existing ones and the ones we added + let pref = user_prefs.get("marionette.port").unwrap(); + assert_eq!(pref.value, PrefValue::Int(2828)); + + let pref = user_prefs.get("geckodriver.example").unwrap(); + assert_eq!(pref.value, PrefValue::String("example".into())); + + // Ensure the backup prefs don't contain the new settings + let mut backup_data = String::new(); + File::open(&backup_path) + .expect("Backup prefs exist") + .read_to_string(&mut backup_data) + .unwrap(); + assert_eq!(backup_data, initial_prefs_data); + + backup.restore(); + + assert!(!backup_path.exists()); + let mut final_prefs_data = String::new(); + File::open(&prefs_path) + .expect("Initial prefs exist") + .read_to_string(&mut final_prefs_data) + .unwrap(); + assert_eq!(final_prefs_data, initial_prefs_data); + } + + #[test] + fn test_local_read_marionette_port() { + fn create_port_file(profile_path: &Path, data: &[u8]) { + let port_path = profile_path.join("MarionetteActivePort"); + let mut file = File::create(&port_path).unwrap(); + file.write_all(data).unwrap(); + } + + let profile_dir = tempdir().unwrap(); + let profile_path = profile_dir.path(); + assert_eq!(read_marionette_port(profile_path), None); + assert_eq!(read_marionette_port(profile_path), None); + create_port_file(profile_path, b""); + assert_eq!(read_marionette_port(profile_path), None); + create_port_file(profile_path, b"1234"); + assert_eq!(read_marionette_port(profile_path), Some(1234)); + create_port_file(profile_path, b"1234abc"); + assert_eq!(read_marionette_port(profile_path), None); + } +} diff --git a/testing/geckodriver/src/build.rs b/testing/geckodriver/src/build.rs new file mode 100644 index 0000000000..f77a3363fa --- /dev/null +++ b/testing/geckodriver/src/build.rs @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use serde_json::Value; +use std::fmt; + +include!(concat!(env!("OUT_DIR"), "/build-info.rs")); + +pub struct BuildInfo; + +impl BuildInfo { + pub fn version() -> &'static str { + crate_version!() + } + + pub fn hash() -> Option<&'static str> { + COMMIT_HASH + } + + pub fn date() -> Option<&'static str> { + COMMIT_DATE + } +} + +impl fmt::Display for BuildInfo { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", BuildInfo::version())?; + match (BuildInfo::hash(), BuildInfo::date()) { + (Some(hash), Some(date)) => write!(f, " ({} {})", hash, date)?, + (Some(hash), None) => write!(f, " ({})", hash)?, + _ => {} + } + Ok(()) + } +} + +impl From<BuildInfo> for Value { + fn from(_: BuildInfo) -> Value { + Value::String(BuildInfo::version().to_string()) + } +} + +/// Returns build-time information about geckodriver. +pub fn build_info() -> BuildInfo { + BuildInfo {} +} diff --git a/testing/geckodriver/src/capabilities.rs b/testing/geckodriver/src/capabilities.rs new file mode 100644 index 0000000000..eda75ad62b --- /dev/null +++ b/testing/geckodriver/src/capabilities.rs @@ -0,0 +1,1417 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::command::LogOptions; +use crate::logging::Level; +use crate::marionette::MarionetteSettings; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use mozdevice::AndroidStorageInput; +use mozprofile::preferences::Pref; +use mozprofile::profile::Profile; +use mozrunner::firefox_args::{get_arg_value, parse_args, Arg}; +use mozrunner::runner::platform::firefox_default_path; +use mozversion::{self, firefox_binary_version, firefox_version, Version}; +use regex::bytes::Regex; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::default::Default; +use std::ffi::OsString; +use std::fmt::{self, Display}; +use std::fs; +use std::io; +use std::io::BufWriter; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::str::{self, FromStr}; +use webdriver::capabilities::{BrowserCapabilities, Capabilities}; +use webdriver::error::{ErrorStatus, WebDriverError, WebDriverResult}; + +#[derive(Clone, Debug)] +enum VersionError { + VersionError(mozversion::Error), + MissingBinary, +} + +impl From<mozversion::Error> for VersionError { + fn from(err: mozversion::Error) -> VersionError { + VersionError::VersionError(err) + } +} + +impl Display for VersionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + VersionError::VersionError(ref x) => x.fmt(f), + VersionError::MissingBinary => "No binary provided".fmt(f), + } + } +} + +impl From<VersionError> for WebDriverError { + fn from(err: VersionError) -> WebDriverError { + WebDriverError::new(ErrorStatus::SessionNotCreated, err.to_string()) + } +} + +/// Provides matching of `moz:firefoxOptions` and resolutionnized of which Firefox +/// binary to use. +/// +/// `FirefoxCapabilities` is constructed with the fallback binary, should +/// `moz:firefoxOptions` not contain a binary entry. This may either be the +/// system Firefox installation or an override, for example given to the +/// `--binary` flag of geckodriver. +pub struct FirefoxCapabilities<'a> { + pub chosen_binary: Option<PathBuf>, + fallback_binary: Option<&'a PathBuf>, + version_cache: BTreeMap<PathBuf, Result<Version, VersionError>>, +} + +impl<'a> FirefoxCapabilities<'a> { + pub fn new(fallback_binary: Option<&'a PathBuf>) -> FirefoxCapabilities<'a> { + FirefoxCapabilities { + chosen_binary: None, + fallback_binary, + version_cache: BTreeMap::new(), + } + } + + fn set_binary(&mut self, capabilities: &Map<String, Value>) { + self.chosen_binary = capabilities + .get("moz:firefoxOptions") + .and_then(|x| x.get("binary")) + .and_then(|x| x.as_str()) + .map(PathBuf::from) + .or_else(|| self.fallback_binary.cloned()) + .or_else(firefox_default_path); + } + + fn version(&mut self, binary: Option<&Path>) -> Result<Version, VersionError> { + if let Some(binary) = binary { + if let Some(cache_value) = self.version_cache.get(binary) { + return cache_value.clone(); + } + let rv = self + .version_from_ini(binary) + .or_else(|_| self.version_from_binary(binary)); + if let Ok(ref version) = rv { + debug!("Found version {}", version); + } else { + debug!("Failed to get binary version"); + } + self.version_cache.insert(binary.to_path_buf(), rv.clone()); + rv + } else { + Err(VersionError::MissingBinary) + } + } + + fn version_from_ini(&self, binary: &Path) -> Result<Version, VersionError> { + debug!("Trying to read firefox version from ini files"); + let version = firefox_version(binary)?; + if let Some(version_string) = version.version_string { + Version::from_str(&version_string).map_err(|err| err.into()) + } else { + Err(VersionError::VersionError( + mozversion::Error::MetadataError("Missing version string".into()), + )) + } + } + + fn version_from_binary(&self, binary: &Path) -> Result<Version, VersionError> { + debug!("Trying to read firefox version from binary"); + Ok(firefox_binary_version(binary)?) + } +} + +impl<'a> BrowserCapabilities for FirefoxCapabilities<'a> { + fn init(&mut self, capabilities: &Capabilities) { + self.set_binary(capabilities); + } + + fn browser_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { + Ok(Some("firefox".into())) + } + + fn browser_version(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { + let binary = self.chosen_binary.clone(); + self.version(binary.as_ref().map(|x| x.as_ref())) + .map_err(|err| err.into()) + .map(|x| Some(x.to_string())) + } + + fn platform_name(&mut self, _: &Capabilities) -> WebDriverResult<Option<String>> { + Ok(if cfg!(target_os = "windows") { + Some("windows".into()) + } else if cfg!(target_os = "macos") { + Some("mac".into()) + } else if cfg!(target_os = "linux") { + Some("linux".into()) + } else { + None + }) + } + + fn accept_insecure_certs(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn accept_proxy(&mut self, _: &Capabilities, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn set_window_rect(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn compare_browser_version( + &mut self, + version: &str, + comparison: &str, + ) -> WebDriverResult<bool> { + Version::from_str(version) + .map_err(VersionError::from)? + .matches(comparison) + .map_err(|err| VersionError::from(err).into()) + } + + fn strict_file_interactability(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn web_socket_url(&mut self, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } + + fn validate_custom(&mut self, name: &str, value: &Value) -> WebDriverResult<()> { + if !name.starts_with("moz:") { + return Ok(()); + } + match name { + "moz:firefoxOptions" => { + let data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "moz:firefoxOptions is not an object" + ); + for (key, value) in data.iter() { + match &**key { + "androidActivity" + | "androidDeviceSerial" + | "androidPackage" + | "profile" => { + if !value.is_string() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} is not a string", &**key), + )); + } + } + "androidIntentArguments" | "args" => { + if !try_opt!( + value.as_array(), + ErrorStatus::InvalidArgument, + format!("{} is not an array", &**key) + ) + .iter() + .all(|value| value.is_string()) + { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} entry is not a string", &**key), + )); + } + } + "binary" => { + if let Some(binary) = value.as_str() { + if !data.contains_key("androidPackage") + && self.version(Some(Path::new(binary))).is_err() + { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} is not a Firefox executable", &**key), + )); + } + } else { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("{} is not a string", &**key), + )); + } + } + "env" => { + let env_data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "env value is not an object" + ); + if !env_data.values().all(Value::is_string) { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Environment values were not all strings", + )); + } + } + "log" => { + let log_data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "log value is not an object" + ); + for (log_key, log_value) in log_data.iter() { + match &**log_key { + "level" => { + let level = try_opt!( + log_value.as_str(), + ErrorStatus::InvalidArgument, + "log level is not a string" + ); + if Level::from_str(level).is_err() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Not a valid log level: {}", level), + )); + } + } + x => { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Invalid log field {}", x), + )) + } + } + } + } + "prefs" => { + let prefs_data = try_opt!( + value.as_object(), + ErrorStatus::InvalidArgument, + "prefs value is not an object" + ); + let is_pref_value_type = |x: &Value| { + x.is_string() || x.is_i64() || x.is_u64() || x.is_boolean() + }; + if !prefs_data.values().all(is_pref_value_type) { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Preference values not all string or integer or boolean", + )); + } + } + x => { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Invalid moz:firefoxOptions field {}", x), + )) + } + } + } + } + "moz:useNonSpecCompliantPointerOrigin" => { + warn!("You are using the deprecated vendor specific capability 'moz:useNonSpecCompliantPointerOrigin', which will be removed in Firefox 116."); + if !value.is_boolean() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "moz:useNonSpecCompliantPointerOrigin is not a boolean", + )); + } + } + "moz:webdriverClick" => { + if !value.is_boolean() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "moz:webdriverClick is not a boolean", + )); + } + } + "moz:debuggerAddress" => { + if !value.is_boolean() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "moz:debuggerAddress is not a boolean", + )); + } + } + _ => { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Unrecognised option {}", name), + )) + } + } + Ok(()) + } + + fn accept_custom(&mut self, _: &str, _: &Value, _: &Capabilities) -> WebDriverResult<bool> { + Ok(true) + } +} + +/// Android-specific options in the `moz:firefoxOptions` struct. +/// These map to "androidCamelCase", following [chromedriver's Android-specific +/// Capabilities](http://chromedriver.chromium.org/getting-started/getting-started---android). +#[derive(Default, Clone, Debug, PartialEq)] +pub struct AndroidOptions { + pub activity: Option<String>, + pub device_serial: Option<String>, + pub intent_arguments: Option<Vec<String>>, + pub package: String, + pub storage: AndroidStorageInput, +} + +impl AndroidOptions { + pub fn new(package: String, storage: AndroidStorageInput) -> AndroidOptions { + AndroidOptions { + package, + storage, + ..Default::default() + } + } +} + +#[derive(Debug, PartialEq)] +pub enum ProfileType { + Path(Profile), + Named, + Temporary, +} + +impl Default for ProfileType { + fn default() -> Self { + ProfileType::Temporary + } +} + +/// Rust representation of `moz:firefoxOptions`. +/// +/// Calling `FirefoxOptions::from_capabilities(binary, capabilities)` causes +/// the encoded profile, the binary arguments, log settings, and additional +/// preferences to be checked and unmarshaled from the `moz:firefoxOptions` +/// JSON Object into a Rust representation. +#[derive(Default, Debug)] +pub struct FirefoxOptions { + pub binary: Option<PathBuf>, + pub profile: ProfileType, + pub args: Option<Vec<String>>, + pub env: Option<Vec<(String, String)>>, + pub log: LogOptions, + pub prefs: Vec<(String, Pref)>, + pub android: Option<AndroidOptions>, + pub use_websocket: bool, +} + +impl FirefoxOptions { + pub fn new() -> FirefoxOptions { + Default::default() + } + + pub(crate) fn from_capabilities( + binary_path: Option<PathBuf>, + settings: &MarionetteSettings, + matched: &mut Capabilities, + ) -> WebDriverResult<FirefoxOptions> { + let mut rv = FirefoxOptions::new(); + rv.binary = binary_path; + + if let Some(json) = matched.remove("moz:firefoxOptions") { + let options = json.as_object().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "'moz:firefoxOptions' \ + capability is not an object", + ) + })?; + + if options.get("androidPackage").is_some() && options.get("binary").is_some() { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidPackage and binary are mutual exclusive", + )); + } + + rv.android = FirefoxOptions::load_android(settings.android_storage, options)?; + rv.args = FirefoxOptions::load_args(options)?; + rv.env = FirefoxOptions::load_env(options)?; + rv.log = FirefoxOptions::load_log(options)?; + rv.prefs = FirefoxOptions::load_prefs(options)?; + if let Some(profile) = + FirefoxOptions::load_profile(settings.profile_root.as_deref(), options)? + { + rv.profile = ProfileType::Path(profile); + } + } + + if let Some(args) = rv.args.as_ref() { + let os_args = parse_args(args.iter().map(OsString::from).collect::<Vec<_>>().iter()); + + if let Some(path) = get_arg_value(os_args.iter(), Arg::Profile) { + if let ProfileType::Path(_) = rv.profile { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Can't provide both a --profile argument and a profile", + )); + } + let path_buf = PathBuf::from(path); + rv.profile = ProfileType::Path(Profile::new_from_path(&path_buf)?); + } + + if get_arg_value(os_args.iter(), Arg::NamedProfile).is_some() { + if let ProfileType::Path(_) = rv.profile { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Can't provide both a -P argument and a profile", + )); + } + // See bug 1757720 + warn!("Firefox was configured to use a named profile (`-P <name>`). \ + Support for named profiles will be removed in a future geckodriver release. \ + Please instead use the `--profile <path>` Firefox argument to start with an existing profile"); + rv.profile = ProfileType::Named; + } + + // Block these Firefox command line arguments that should not be settable + // via session capabilities. + if let Some(arg) = os_args + .iter() + .filter_map(|(opt_arg, _)| opt_arg.as_ref()) + .find(|arg| { + matches!( + arg, + Arg::Marionette + | Arg::RemoteAllowHosts + | Arg::RemoteAllowOrigins + | Arg::RemoteDebuggingPort + ) + }) + { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + format!("Argument {} can't be set via capabilities", arg), + )); + }; + } + + let has_web_socket_url = matched + .get("webSocketUrl") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + + let has_debugger_address = matched + .remove("moz:debuggerAddress") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + + // Set a command line provided port for the Remote Agent for now. + // It needs to be the same on the host and the Android device. + if has_web_socket_url || has_debugger_address { + rv.use_websocket = true; + + // Bug 1722863: Setting of command line arguments would be + // better suited in the individual Browser implementations. + let mut remote_args = Vec::new(); + remote_args.push("--remote-debugging-port".to_owned()); + remote_args.push(settings.websocket_port.to_string()); + + // Handle additional hosts for WebDriver BiDi WebSocket connections + if !settings.allow_hosts.is_empty() { + remote_args.push("--remote-allow-hosts".to_owned()); + remote_args.push( + settings + .allow_hosts + .iter() + .map(|host| host.to_string()) + .collect::<Vec<String>>() + .join(","), + ); + } + + // Handle additional origins for WebDriver BiDi WebSocket connections + if !settings.allow_origins.is_empty() { + remote_args.push("--remote-allow-origins".to_owned()); + remote_args.push( + settings + .allow_origins + .iter() + .map(|origin| origin.to_string()) + .collect::<Vec<String>>() + .join(","), + ); + } + + if let Some(ref mut args) = rv.args { + args.append(&mut remote_args); + } else { + rv.args = Some(remote_args); + } + } + + Ok(rv) + } + + fn load_profile( + profile_root: Option<&Path>, + options: &Capabilities, + ) -> WebDriverResult<Option<Profile>> { + if let Some(profile_json) = options.get("profile") { + let profile_base64 = profile_json.as_str().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Profile is not a string") + })?; + let profile_zip = &*BASE64_STANDARD.decode(profile_base64)?; + + // Create an emtpy profile directory + let profile = Profile::new(profile_root)?; + unzip_buffer( + profile_zip, + profile + .temp_dir + .as_ref() + .expect("Profile doesn't have a path") + .path(), + )?; + + Ok(Some(profile)) + } else { + Ok(None) + } + } + + fn load_args(options: &Capabilities) -> WebDriverResult<Option<Vec<String>>> { + if let Some(args_json) = options.get("args") { + let args_array = args_json.as_array().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Arguments were not an array") + })?; + let args = args_array + .iter() + .map(|x| x.as_str().map(|x| x.to_owned())) + .collect::<Option<Vec<String>>>() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "Arguments entries were not all strings", + ) + })?; + + Ok(Some(args)) + } else { + Ok(None) + } + } + + pub fn load_env(options: &Capabilities) -> WebDriverResult<Option<Vec<(String, String)>>> { + if let Some(env_data) = options.get("env") { + let env = env_data.as_object().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Env was not an object") + })?; + let mut rv = Vec::with_capacity(env.len()); + for (key, value) in env.iter() { + rv.push(( + key.clone(), + value + .as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "Env value is not a string", + ) + })? + .to_string(), + )); + } + Ok(Some(rv)) + } else { + Ok(None) + } + } + + fn load_log(options: &Capabilities) -> WebDriverResult<LogOptions> { + if let Some(json) = options.get("log") { + let log = json.as_object().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Log section is not an object") + })?; + + let level = match log.get("level") { + Some(json) => { + let s = json.as_str().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "Log level is not a string", + ) + })?; + Some(Level::from_str(s).ok().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Log level is unknown") + })?) + } + None => None, + }; + + Ok(LogOptions { level }) + } else { + Ok(Default::default()) + } + } + + pub fn load_prefs(options: &Capabilities) -> WebDriverResult<Vec<(String, Pref)>> { + if let Some(prefs_data) = options.get("prefs") { + let prefs = prefs_data.as_object().ok_or_else(|| { + WebDriverError::new(ErrorStatus::InvalidArgument, "Prefs were not an object") + })?; + let mut rv = Vec::with_capacity(prefs.len()); + for (key, value) in prefs.iter() { + rv.push((key.clone(), pref_from_json(value)?)); + } + Ok(rv) + } else { + Ok(vec![]) + } + } + + pub fn load_android( + storage: AndroidStorageInput, + options: &Capabilities, + ) -> WebDriverResult<Option<AndroidOptions>> { + if let Some(package_json) = options.get("androidPackage") { + let package = package_json + .as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidPackage is not a string", + ) + })? + .to_owned(); + + // https://developer.android.com/studio/build/application-id + let package_regexp = + Regex::new(r#"^([a-zA-Z][a-zA-Z0-9_]*\.){1,}([a-zA-Z][a-zA-Z0-9_]*)$"#).unwrap(); + if !package_regexp.is_match(package.as_bytes()) { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "Not a valid androidPackage name", + )); + } + + let mut android = AndroidOptions::new(package.clone(), storage); + + android.activity = match options.get("androidActivity") { + Some(json) => { + let activity = json + .as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidActivity is not a string", + ) + })? + .to_owned(); + + if activity.contains('/') { + return Err(WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidActivity should not contain '/", + )); + } + + Some(activity) + } + None => { + match package.as_str() { + "org.mozilla.firefox" + | "org.mozilla.firefox_beta" + | "org.mozilla.fenix" + | "org.mozilla.fenix.debug" + | "org.mozilla.reference.browser" => { + Some("org.mozilla.fenix.IntentReceiverActivity".to_string()) + } + "org.mozilla.focus" + | "org.mozilla.focus.debug" + | "org.mozilla.klar" + | "org.mozilla.klar.debug" => { + Some("org.mozilla.focus.activity.IntentReceiverActivity".to_string()) + } + // For all other applications fallback to auto-detection. + _ => None, + } + } + }; + + android.device_serial = match options.get("androidDeviceSerial") { + Some(json) => Some( + json.as_str() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidDeviceSerial is not a string", + ) + })? + .to_owned(), + ), + None => None, + }; + + android.intent_arguments = match options.get("androidIntentArguments") { + Some(json) => { + let args_array = json.as_array().ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidIntentArguments is not an array", + ) + })?; + let args = args_array + .iter() + .map(|x| x.as_str().map(|x| x.to_owned())) + .collect::<Option<Vec<String>>>() + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::InvalidArgument, + "androidIntentArguments entries are not all strings", + ) + })?; + + Some(args) + } + None => { + // All GeckoView based applications support this view, + // and allow to open a blank page in a Gecko window. + Some(vec![ + "-a".to_string(), + "android.intent.action.VIEW".to_string(), + "-d".to_string(), + "about:blank".to_string(), + ]) + } + }; + + Ok(Some(android)) + } else { + Ok(None) + } + } +} + +fn pref_from_json(value: &Value) -> WebDriverResult<Pref> { + match *value { + Value::String(ref x) => Ok(Pref::new(x.clone())), + Value::Number(ref x) => Ok(Pref::new(x.as_i64().unwrap())), + Value::Bool(x) => Ok(Pref::new(x)), + _ => Err(WebDriverError::new( + ErrorStatus::UnknownError, + "Could not convert pref value to string, boolean, or integer", + )), + } +} + +fn unzip_buffer(buf: &[u8], dest_dir: &Path) -> WebDriverResult<()> { + let reader = Cursor::new(buf); + let mut zip = zip::ZipArchive::new(reader) + .map_err(|_| WebDriverError::new(ErrorStatus::UnknownError, "Failed to unzip profile"))?; + + for i in 0..zip.len() { + let mut file = zip.by_index(i).map_err(|_| { + WebDriverError::new( + ErrorStatus::UnknownError, + "Processing profile zip file failed", + ) + })?; + let unzip_path = { + let name = file.name(); + let is_dir = name.ends_with('/'); + let rel_path = Path::new(name); + let dest_path = dest_dir.join(rel_path); + + { + let create_dir = if is_dir { + Some(dest_path.as_path()) + } else { + dest_path.parent() + }; + if let Some(dir) = create_dir { + if !dir.exists() { + debug!("Creating profile directory tree {}", dir.to_string_lossy()); + fs::create_dir_all(dir)?; + } + } + } + + if is_dir { + None + } else { + Some(dest_path) + } + }; + + if let Some(unzip_path) = unzip_path { + debug!("Extracting profile to {}", unzip_path.to_string_lossy()); + let dest = fs::File::create(unzip_path)?; + if file.size() > 0 { + let mut writer = BufWriter::new(dest); + io::copy(&mut file, &mut writer)?; + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + extern crate mozprofile; + + use self::mozprofile::preferences::Pref; + use super::*; + use serde_json::{json, Map, Value}; + use std::fs::File; + use std::io::Read; + use url::{Host, Url}; + use webdriver::capabilities::Capabilities; + + fn example_profile() -> Value { + let mut profile_data = Vec::with_capacity(1024); + let mut profile = File::open("src/tests/profile.zip").unwrap(); + profile.read_to_end(&mut profile_data).unwrap(); + Value::String(BASE64_STANDARD.encode(&profile_data)) + } + + fn make_options( + firefox_opts: Capabilities, + marionette_settings: Option<MarionetteSettings>, + ) -> WebDriverResult<FirefoxOptions> { + let mut caps = Capabilities::new(); + caps.insert("moz:firefoxOptions".into(), Value::Object(firefox_opts)); + + FirefoxOptions::from_capabilities(None, &marionette_settings.unwrap_or_default(), &mut caps) + } + + #[test] + fn fx_options_default() { + let opts: FirefoxOptions = Default::default(); + assert_eq!(opts.android, None); + assert_eq!(opts.args, None); + assert_eq!(opts.binary, None); + assert_eq!(opts.log, LogOptions { level: None }); + assert_eq!(opts.prefs, vec![]); + // Profile doesn't support PartialEq + // assert_eq!(opts.profile, None); + } + + #[test] + fn fx_options_from_capabilities_no_binary_and_empty_caps() { + let mut caps = Capabilities::new(); + + let marionette_settings = Default::default(); + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("valid firefox options"); + assert_eq!(opts.android, None); + assert_eq!(opts.args, None); + assert_eq!(opts.binary, None); + assert_eq!(opts.log, LogOptions { level: None }); + assert_eq!(opts.prefs, vec![]); + } + + #[test] + fn fx_options_from_capabilities_with_binary_and_caps() { + let mut caps = Capabilities::new(); + caps.insert( + "moz:firefoxOptions".into(), + Value::Object(Capabilities::new()), + ); + + let binary = PathBuf::from("foo"); + let marionette_settings = Default::default(); + + let opts = FirefoxOptions::from_capabilities( + Some(binary.clone()), + &marionette_settings, + &mut caps, + ) + .expect("valid firefox options"); + assert_eq!(opts.android, None); + assert_eq!(opts.args, None); + assert_eq!(opts.binary, Some(binary)); + assert_eq!(opts.log, LogOptions { level: None }); + assert_eq!(opts.prefs, vec![]); + } + + #[test] + fn fx_options_from_capabilities_with_blocked_firefox_arguments() { + let blocked_args = vec![ + "--marionette", + "--remote-allow-hosts", + "--remote-allow-origins", + "--remote-debugging-port", + ]; + + for arg in blocked_args { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!([arg])); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + } + + #[test] + fn fx_options_from_capabilities_with_websocket_url_not_set() { + let mut caps = Capabilities::new(); + + let marionette_settings = Default::default(); + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_websocket_url_false() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(false)); + + let marionette_settings = Default::default(); + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_websocket_url_true() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(true)); + + let settings = MarionetteSettings { + websocket_port: 1234, + ..Default::default() + }; + let opts = FirefoxOptions::from_capabilities(None, &settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-debugging-port".to_owned())); + assert_eq!(iter.next(), Some(&"1234".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_websocket_and_allow_hosts() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(true)); + + let mut marionette_settings: MarionetteSettings = Default::default(); + marionette_settings.allow_hosts = vec![ + Host::parse("foo").expect("host"), + Host::parse("bar").expect("host"), + ]; + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-allow-hosts".to_owned())); + assert_eq!(iter.next(), Some(&"foo,bar".to_owned())); + assert!(!iter.any(|arg| arg == &"--remote-allow-origins".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_websocket_and_allow_origins() { + let mut caps = Capabilities::new(); + caps.insert("webSocketUrl".into(), json!(true)); + + let mut marionette_settings: MarionetteSettings = Default::default(); + marionette_settings.allow_origins = vec![ + Url::parse("http://foo/").expect("url"), + Url::parse("http://bar/").expect("url"), + ]; + let opts = FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-allow-origins".to_owned())); + assert_eq!(iter.next(), Some(&"http://foo/,http://bar/".to_owned())); + assert!(!iter.any(|arg| arg == &"--remote-allow-hosts".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_debugger_address_not_set() { + let caps = Capabilities::new(); + + let opts = make_options(caps, None).expect("valid firefox options"); + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_debugger_address_false() { + let mut caps = Capabilities::new(); + caps.insert("moz:debuggerAddress".into(), json!(false)); + + let opts = make_options(caps, None).expect("valid firefox options"); + assert!( + opts.args.is_none(), + "CLI arguments for Firefox unexpectedly found" + ); + } + + #[test] + fn fx_options_from_capabilities_with_debugger_address_true() { + let mut caps = Capabilities::new(); + caps.insert("moz:debuggerAddress".into(), json!(true)); + + let settings = MarionetteSettings { + websocket_port: 1234, + ..Default::default() + }; + let opts = FirefoxOptions::from_capabilities(None, &settings, &mut caps) + .expect("Valid Firefox options"); + + if let Some(args) = opts.args { + let mut iter = args.iter(); + assert!(iter.any(|arg| arg == &"--remote-debugging-port".to_owned())); + assert_eq!(iter.next(), Some(&"1234".to_owned())); + } else { + panic!("CLI arguments for Firefox not found"); + } + } + + #[test] + fn fx_options_from_capabilities_with_invalid_caps() { + let mut caps = Capabilities::new(); + caps.insert("moz:firefoxOptions".into(), json!(42)); + + let marionette_settings = Default::default(); + FirefoxOptions::from_capabilities(None, &marionette_settings, &mut caps) + .expect_err("Firefox options need to be of type object"); + } + + #[test] + fn fx_options_android_package_and_binary() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo")); + firefox_opts.insert("binary".into(), json!("bar")); + + make_options(firefox_opts, None) + .expect_err("androidPackage and binary are mutual exclusive"); + } + + #[test] + fn fx_options_android_no_package() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidAvtivity".into(), json!("foo")); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android, None); + } + + #[test] + fn fx_options_android_package_valid_value() { + for value in ["foo.bar", "foo.bar.cheese.is.good", "Foo.Bar_9"].iter() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(value)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().package, value.to_string()); + } + } + + #[test] + fn fx_options_android_package_invalid_type() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(42)); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_package_invalid_value() { + for value in ["../foo", "\\foo\n", "foo", "_foo", "0foo"].iter() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(value)); + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + } + + #[test] + fn fx_options_android_activity_default_known_apps() { + let packages = vec![ + "org.mozilla.firefox", + "org.mozilla.firefox_beta", + "org.mozilla.fenix", + "org.mozilla.fenix.debug", + "org.mozilla.focus", + "org.mozilla.focus.debug", + "org.mozilla.klar", + "org.mozilla.klar.debug", + "org.mozilla.reference.browser", + ]; + + for package in packages { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(package)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert!(opts + .android + .unwrap() + .activity + .unwrap() + .contains("IntentReceiverActivity")); + } + } + + #[test] + fn fx_options_android_activity_default_unknown_apps() { + let packages = vec!["org.mozilla.geckoview_example", "com.some.other.app"]; + + for package in packages { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(package)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().activity, None); + } + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert( + "androidPackage".into(), + json!("org.mozilla.geckoview_example"), + ); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().activity, None); + } + + #[test] + fn fx_options_android_activity_override() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidActivity".into(), json!("foo")); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!(opts.android.unwrap().activity, Some("foo".to_string())); + } + + #[test] + fn fx_options_android_activity_invalid_type() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidActivity".into(), json!(42)); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_activity_invalid_value() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidActivity".into(), json!("foo.bar/cheese")); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_device_serial() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidDeviceSerial".into(), json!("cheese")); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!( + opts.android.unwrap().device_serial, + Some("cheese".to_string()) + ); + } + + #[test] + fn fx_options_android_device_serial_invalid() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidDeviceSerial".into(), json!(42)); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_intent_arguments_defaults() { + let packages = vec![ + "org.mozilla.firefox", + "org.mozilla.firefox_beta", + "org.mozilla.fenix", + "org.mozilla.fenix.debug", + "org.mozilla.geckoview_example", + "org.mozilla.reference.browser", + "com.some.other.app", + ]; + + for package in packages { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!(package)); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!( + opts.android.unwrap().intent_arguments, + Some(vec![ + "-a".to_string(), + "android.intent.action.VIEW".to_string(), + "-d".to_string(), + "about:blank".to_string(), + ]) + ); + } + } + + #[test] + fn fx_options_android_intent_arguments_override() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidIntentArguments".into(), json!(["lorem", "ipsum"])); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + assert_eq!( + opts.android.unwrap().intent_arguments, + Some(vec!["lorem".to_string(), "ipsum".to_string()]) + ); + } + + #[test] + fn fx_options_android_intent_arguments_no_array() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidIntentArguments".into(), json!(42)); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_android_intent_arguments_invalid_value() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("androidPackage".into(), json!("foo.bar")); + firefox_opts.insert("androidIntentArguments".into(), json!(["lorem", 42])); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_env() { + let mut env: Map<String, Value> = Map::new(); + env.insert("TEST_KEY_A".into(), Value::String("test_value_a".into())); + env.insert("TEST_KEY_B".into(), Value::String("test_value_b".into())); + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("env".into(), env.into()); + + let mut opts = make_options(firefox_opts, None).expect("valid firefox options"); + for sorted in opts.env.iter_mut() { + sorted.sort() + } + assert_eq!( + opts.env, + Some(vec![ + ("TEST_KEY_A".into(), "test_value_a".into()), + ("TEST_KEY_B".into(), "test_value_b".into()), + ]) + ); + } + + #[test] + fn fx_options_env_invalid_container() { + let env = Value::Number(1.into()); + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("env".into(), env); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn fx_options_env_invalid_value() { + let mut env: Map<String, Value> = Map::new(); + env.insert("TEST_KEY".into(), Value::Number(1.into())); + + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("env".into(), env.into()); + + make_options(firefox_opts, None).expect_err("invalid firefox options"); + } + + #[test] + fn test_profile() { + let encoded_profile = example_profile(); + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("profile".into(), encoded_profile); + + let opts = make_options(firefox_opts, None).expect("valid firefox options"); + let mut profile = match opts.profile { + ProfileType::Path(profile) => profile, + _ => panic!("Expected ProfileType::Path"), + }; + let prefs = profile.user_prefs().expect("valid preferences"); + + println!("{:#?}", prefs.prefs); + + assert_eq!( + prefs.get("startup.homepage_welcome_url"), + Some(&Pref::new("data:text/html,PASS")) + ); + } + + #[test] + fn fx_options_args_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["--profile", "foo"])); + + let options = make_options(firefox_opts, None).expect("Valid args"); + assert!(matches!(options.profile, ProfileType::Path(_))); + } + + #[test] + fn fx_options_args_named_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["-P", "foo"])); + + let options = make_options(firefox_opts, None).expect("Valid args"); + assert!(matches!(options.profile, ProfileType::Named)); + } + + #[test] + fn fx_options_args_no_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["--headless"])); + + let options = make_options(firefox_opts, None).expect("Valid args"); + assert!(matches!(options.profile, ProfileType::Temporary)); + } + + #[test] + fn fx_options_args_profile_and_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["--profile", "foo"])); + firefox_opts.insert("profile".into(), json!("foo")); + + make_options(firefox_opts, None).expect_err("Invalid args"); + } + + #[test] + fn fx_options_args_p_and_profile() { + let mut firefox_opts = Capabilities::new(); + firefox_opts.insert("args".into(), json!(["-P"])); + firefox_opts.insert("profile".into(), json!("foo")); + + make_options(firefox_opts, None).expect_err("Invalid args"); + } +} diff --git a/testing/geckodriver/src/command.rs b/testing/geckodriver/src/command.rs new file mode 100644 index 0000000000..c92eabf6f3 --- /dev/null +++ b/testing/geckodriver/src/command.rs @@ -0,0 +1,343 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::logging; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use hyper::Method; +use serde::de::{self, Deserialize, Deserializer}; +use serde_json::{self, Value}; +use std::env; +use std::fs::File; +use std::io::prelude::*; +use uuid::Uuid; +use webdriver::command::{WebDriverCommand, WebDriverExtensionCommand}; +use webdriver::error::WebDriverResult; +use webdriver::httpapi::WebDriverExtensionRoute; +use webdriver::Parameters; + +pub fn extension_routes() -> Vec<(Method, &'static str, GeckoExtensionRoute)> { + vec![ + ( + Method::GET, + "/session/{sessionId}/moz/context", + GeckoExtensionRoute::GetContext, + ), + ( + Method::POST, + "/session/{sessionId}/moz/context", + GeckoExtensionRoute::SetContext, + ), + ( + Method::POST, + "/session/{sessionId}/moz/addon/install", + GeckoExtensionRoute::InstallAddon, + ), + ( + Method::POST, + "/session/{sessionId}/moz/addon/uninstall", + GeckoExtensionRoute::UninstallAddon, + ), + ( + Method::GET, + "/session/{sessionId}/moz/screenshot/full", + GeckoExtensionRoute::TakeFullScreenshot, + ), + ] +} + +#[derive(Clone, PartialEq, Eq)] +pub enum GeckoExtensionRoute { + GetContext, + SetContext, + InstallAddon, + UninstallAddon, + TakeFullScreenshot, +} + +impl WebDriverExtensionRoute for GeckoExtensionRoute { + type Command = GeckoExtensionCommand; + + fn command( + &self, + _params: &Parameters, + body_data: &Value, + ) -> WebDriverResult<WebDriverCommand<GeckoExtensionCommand>> { + use self::GeckoExtensionRoute::*; + + let command = match *self { + GetContext => GeckoExtensionCommand::GetContext, + SetContext => { + GeckoExtensionCommand::SetContext(serde_json::from_value(body_data.clone())?) + } + InstallAddon => { + GeckoExtensionCommand::InstallAddon(serde_json::from_value(body_data.clone())?) + } + UninstallAddon => { + GeckoExtensionCommand::UninstallAddon(serde_json::from_value(body_data.clone())?) + } + TakeFullScreenshot => GeckoExtensionCommand::TakeFullScreenshot, + }; + + Ok(WebDriverCommand::Extension(command)) + } +} + +#[derive(Clone)] +pub enum GeckoExtensionCommand { + GetContext, + SetContext(GeckoContextParameters), + InstallAddon(AddonInstallParameters), + UninstallAddon(AddonUninstallParameters), + TakeFullScreenshot, +} + +impl WebDriverExtensionCommand for GeckoExtensionCommand { + fn parameters_json(&self) -> Option<Value> { + use self::GeckoExtensionCommand::*; + match self { + GetContext => None, + InstallAddon(x) => Some(serde_json::to_value(x).unwrap()), + SetContext(x) => Some(serde_json::to_value(x).unwrap()), + UninstallAddon(x) => Some(serde_json::to_value(x).unwrap()), + TakeFullScreenshot => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AddonInstallParameters { + pub path: String, + pub temporary: Option<bool>, +} + +impl<'de> Deserialize<'de> for AddonInstallParameters { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct Base64 { + addon: String, + temporary: Option<bool>, + } + + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct Path { + path: String, + temporary: Option<bool>, + } + + #[derive(Debug, Deserialize)] + #[serde(untagged)] + enum Helper { + Base64(Base64), + Path(Path), + } + + let params = match Helper::deserialize(deserializer)? { + Helper::Path(ref mut data) => AddonInstallParameters { + path: data.path.clone(), + temporary: data.temporary, + }, + Helper::Base64(ref mut data) => { + let content = BASE64_STANDARD + .decode(&data.addon) + .map_err(de::Error::custom)?; + + let path = env::temp_dir() + .as_path() + .join(format!("addon-{}.xpi", Uuid::new_v4())); + let mut xpi_file = File::create(&path).map_err(de::Error::custom)?; + xpi_file + .write(content.as_slice()) + .map_err(de::Error::custom)?; + + let path = match path.to_str() { + Some(path) => path.to_string(), + None => return Err(de::Error::custom("could not write addon to file")), + }; + + AddonInstallParameters { + path, + temporary: data.temporary, + } + } + }; + + Ok(params) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AddonUninstallParameters { + pub id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GeckoContext { + Content, + Chrome, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GeckoContextParameters { + pub context: GeckoContext, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct XblLocatorParameters { + pub name: String, + pub value: String, +} + +#[derive(Default, Debug, PartialEq, Eq)] +pub struct LogOptions { + pub level: Option<logging::Level>, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::test::assert_de; + + #[test] + fn test_json_addon_install_parameters_invalid() { + assert!(serde_json::from_str::<AddonInstallParameters>("").is_err()); + assert!(serde_json::from_value::<AddonInstallParameters>(json!(null)).is_err()); + assert!(serde_json::from_value::<AddonInstallParameters>(json!({})).is_err()); + } + + #[test] + fn test_json_addon_install_parameters_with_path_and_temporary() { + let params = AddonInstallParameters { + path: "/path/to.xpi".to_string(), + temporary: Some(true), + }; + assert_de(¶ms, json!({"path": "/path/to.xpi", "temporary": true})); + } + + #[test] + fn test_json_addon_install_parameters_with_path() { + let params = AddonInstallParameters { + path: "/path/to.xpi".to_string(), + temporary: None, + }; + assert_de(¶ms, json!({"path": "/path/to.xpi"})); + } + + #[test] + fn test_json_addon_install_parameters_with_path_invalid_type() { + let json = json!({"path": true, "temporary": true}); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_addon_install_parameters_with_path_and_temporary_invalid_type() { + let json = json!({"path": "/path/to.xpi", "temporary": "foo"}); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_addon_install_parameters_with_addon() { + let json = json!({"addon": "aGVsbG8=", "temporary": true}); + let data = serde_json::from_value::<AddonInstallParameters>(json).unwrap(); + + assert_eq!(data.temporary, Some(true)); + let mut file = File::open(data.path).unwrap(); + let mut contents = String::new(); + file.read_to_string(&mut contents).unwrap(); + assert_eq!(contents, "hello"); + } + + #[test] + fn test_json_addon_install_parameters_with_addon_only() { + let json = json!({"addon": "aGVsbG8="}); + let data = serde_json::from_value::<AddonInstallParameters>(json).unwrap(); + + assert_eq!(data.temporary, None); + let mut file = File::open(data.path).unwrap(); + let mut contents = String::new(); + file.read_to_string(&mut contents).unwrap(); + assert_eq!(contents, "hello"); + } + + #[test] + fn test_json_addon_install_parameters_with_addon_invalid_type() { + let json = json!({"addon": true, "temporary": true}); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_addon_install_parameters_with_addon_and_temporary_invalid_type() { + let json = json!({"addon": "aGVsbG8=", "temporary": "foo"}); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_install_parameters_with_temporary_only() { + let json = json!({"temporary": true}); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_addon_install_parameters_with_both_path_and_addon() { + let json = json!({ + "path": "/path/to.xpi", + "addon": "aGVsbG8=", + "temporary": true, + }); + assert!(serde_json::from_value::<AddonInstallParameters>(json).is_err()); + } + + #[test] + fn test_json_addon_uninstall_parameters_invalid() { + assert!(serde_json::from_str::<AddonUninstallParameters>("").is_err()); + assert!(serde_json::from_value::<AddonUninstallParameters>(json!(null)).is_err()); + assert!(serde_json::from_value::<AddonUninstallParameters>(json!({})).is_err()); + } + + #[test] + fn test_json_addon_uninstall_parameters() { + let params = AddonUninstallParameters { + id: "foo".to_string(), + }; + assert_de(¶ms, json!({"id": "foo"})); + } + + #[test] + fn test_json_addon_uninstall_parameters_id_invalid_type() { + let json = json!({"id": true}); + assert!(serde_json::from_value::<AddonUninstallParameters>(json).is_err()); + } + + #[test] + fn test_json_gecko_context_parameters_content() { + let params = GeckoContextParameters { + context: GeckoContext::Content, + }; + assert_de(¶ms, json!({"context": "content"})); + } + + #[test] + fn test_json_gecko_context_parameters_chrome() { + let params = GeckoContextParameters { + context: GeckoContext::Chrome, + }; + assert_de(¶ms, json!({"context": "chrome"})); + } + + #[test] + fn test_json_gecko_context_parameters_context_invalid() { + type P = GeckoContextParameters; + assert!(serde_json::from_value::<P>(json!({})).is_err()); + assert!(serde_json::from_value::<P>(json!({ "context": null })).is_err()); + assert!(serde_json::from_value::<P>(json!({"context": "foo"})).is_err()); + } +} diff --git a/testing/geckodriver/src/logging.rs b/testing/geckodriver/src/logging.rs new file mode 100644 index 0000000000..073da956ed --- /dev/null +++ b/testing/geckodriver/src/logging.rs @@ -0,0 +1,403 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Gecko-esque logger implementation for the [`log`] crate. +//! +//! The [`log`] crate provides a single logging API that abstracts over the +//! actual logging implementation. This module uses the logging API +//! to provide a log implementation that shares many aesthetical traits with +//! [Log.sys.mjs] from Gecko. +//! +//! Using the [`error!`], [`warn!`], [`info!`], [`debug!`], and +//! [`trace!`] macros from `log` will output a timestamp field, followed by the +//! log level, and then the message. The fields are separated by a tab +//! character, making the output suitable for further text processing with +//! `awk(1)`. +//! +//! This module shares the same API as `log`, except it provides additional +//! entry functions [`init`] and [`init_with_level`] and additional log levels +//! `Level::Fatal` and `Level::Config`. Converting these into the +//! [`log::Level`] is lossy so that `Level::Fatal` becomes `log::Level::Error` +//! and `Level::Config` becomes `log::Level::Debug`. +//! +//! [`log`]: https://docs.rs/log/newest/log/ +//! [Log.sys.mjs]: https://searchfox.org/mozilla-central/source/toolkit/modules/Log.sys.mjs +//! [`error!`]: https://docs.rs/log/newest/log/macro.error.html +//! [`warn!`]: https://docs.rs/log/newest/log/macro.warn.html +//! [`info!`]: https://docs.rs/log/newest/log/macro.info.html +//! [`debug!`]: https://docs.rs/log/newest/log/macro.debug.html +//! [`trace!`]: https://docs.rs/log/newest/log/macro.trace.html +//! [`init`]: fn.init.html +//! [`init_with_level`]: fn.init_with_level.html + +use std::fmt; +use std::io; +use std::io::Write; +use std::str; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use unicode_segmentation::UnicodeSegmentation; + +use mozprofile::preferences::Pref; + +static LOG_TRUNCATE: AtomicBool = AtomicBool::new(true); +static MAX_LOG_LEVEL: AtomicUsize = AtomicUsize::new(0); + +const MAX_STRING_LENGTH: usize = 250; + +const LOGGED_TARGETS: &[&str] = &[ + "geckodriver", + "mozdevice", + "mozprofile", + "mozrunner", + "mozversion", + "webdriver", +]; + +/// Logger levels from [Log.sys.mjs]. +/// +/// [Log.sys.mjs]: https://searchfox.org/mozilla-central/source/toolkit/modules/Log.sys.mjs +#[repr(usize)] +#[derive(Clone, Copy, Eq, Debug, Hash, PartialEq)] +pub enum Level { + Fatal = 70, + Error = 60, + Warn = 50, + Info = 40, + Config = 30, + Debug = 20, + Trace = 10, +} + +impl From<usize> for Level { + fn from(n: usize) -> Level { + use self::Level::*; + match n { + 70 => Fatal, + 60 => Error, + 50 => Warn, + 40 => Info, + 30 => Config, + 20 => Debug, + 10 => Trace, + _ => Info, + } + } +} + +impl fmt::Display for Level { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use self::Level::*; + let s = match *self { + Fatal => "FATAL", + Error => "ERROR", + Warn => "WARN", + Info => "INFO", + Config => "CONFIG", + Debug => "DEBUG", + Trace => "TRACE", + }; + write!(f, "{}", s) + } +} + +impl str::FromStr for Level { + type Err = (); + + fn from_str(s: &str) -> Result<Level, ()> { + use self::Level::*; + match s.to_lowercase().as_ref() { + "fatal" => Ok(Fatal), + "error" => Ok(Error), + "warn" => Ok(Warn), + "info" => Ok(Info), + "config" => Ok(Config), + "debug" => Ok(Debug), + "trace" => Ok(Trace), + _ => Err(()), + } + } +} + +impl From<Level> for log::Level { + fn from(level: Level) -> log::Level { + use self::Level::*; + match level { + Fatal | Error => log::Level::Error, + Warn => log::Level::Warn, + Info => log::Level::Info, + Config | Debug => log::Level::Debug, + Trace => log::Level::Trace, + } + } +} + +impl From<Level> for Pref { + fn from(level: Level) -> Pref { + use self::Level::*; + Pref::new(match level { + Fatal => "Fatal", + Error => "Error", + Warn => "Warn", + Info => "Info", + Config => "Config", + Debug => "Debug", + Trace => "Trace", + }) + } +} + +impl From<log::Level> for Level { + fn from(log_level: log::Level) -> Level { + use log::Level::*; + match log_level { + Error => Level::Error, + Warn => Level::Warn, + Info => Level::Info, + Debug => Level::Debug, + Trace => Level::Trace, + } + } +} + +struct Logger; + +impl log::Log for Logger { + fn enabled(&self, meta: &log::Metadata) -> bool { + LOGGED_TARGETS.iter().any(|&x| meta.target().starts_with(x)) + && meta.level() <= log::max_level() + } + + fn log(&self, record: &log::Record) { + if self.enabled(record.metadata()) { + if let Some((s1, s2)) = truncate_message(record.args()) { + println!( + "{}\t{}\t{}\t{} ... {}", + format_ts(chrono::Local::now()), + record.target(), + record.level(), + s1, + s2 + ); + } else { + println!( + "{}\t{}\t{}\t{}", + format_ts(chrono::Local::now()), + record.target(), + record.level(), + record.args() + ) + } + } + } + + fn flush(&self) { + io::stdout().flush().unwrap(); + } +} + +/// Initialises the logging subsystem with the default log level. +pub fn init(truncate: bool) -> Result<(), log::SetLoggerError> { + init_with_level(Level::Info, truncate) +} + +/// Initialises the logging subsystem. +pub fn init_with_level(level: Level, truncate: bool) -> Result<(), log::SetLoggerError> { + let logger = Logger {}; + set_max_level(level); + set_truncate(truncate); + log::set_boxed_logger(Box::new(logger))?; + Ok(()) +} + +/// Returns the current maximum log level. +pub fn max_level() -> Level { + MAX_LOG_LEVEL.load(Ordering::Relaxed).into() +} + +/// Sets the global maximum log level. +pub fn set_max_level(level: Level) { + MAX_LOG_LEVEL.store(level as usize, Ordering::SeqCst); + + let slevel: log::Level = level.into(); + log::set_max_level(slevel.to_level_filter()) +} + +/// Sets the global maximum log level. +pub fn set_truncate(truncate: bool) { + LOG_TRUNCATE.store(truncate, Ordering::SeqCst); +} + +/// Returns the truncation flag. +pub fn truncate() -> bool { + LOG_TRUNCATE.load(Ordering::Relaxed) +} + +/// Produces a 13-digit Unix Epoch timestamp similar to Gecko. +fn format_ts(ts: chrono::DateTime<chrono::Local>) -> String { + format!("{}{:03}", ts.timestamp(), ts.timestamp_subsec_millis()) +} + +/// Truncate a log message if it's too long +fn truncate_message(args: &fmt::Arguments) -> Option<(String, String)> { + // Don't truncate the message if requested. + if !truncate() { + return None; + } + + let message = format!("{}", args); + let chars = message.graphemes(true).collect::<Vec<&str>>(); + + if chars.len() > MAX_STRING_LENGTH { + let middle: usize = MAX_STRING_LENGTH / 2; + let s1 = chars[0..middle].concat(); + let s2 = chars[chars.len() - middle..].concat(); + Some((s1, s2)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::str::FromStr; + use std::sync::Mutex; + + use mozprofile::preferences::{Pref, PrefValue}; + + lazy_static! { + static ref LEVEL_MUTEX: Mutex<()> = Mutex::new(()); + } + + #[test] + fn test_level_repr() { + assert_eq!(Level::Fatal as usize, 70); + assert_eq!(Level::Error as usize, 60); + assert_eq!(Level::Warn as usize, 50); + assert_eq!(Level::Info as usize, 40); + assert_eq!(Level::Config as usize, 30); + assert_eq!(Level::Debug as usize, 20); + assert_eq!(Level::Trace as usize, 10); + } + + #[test] + fn test_level_from_log() { + assert_eq!(Level::from(log::Level::Error), Level::Error); + assert_eq!(Level::from(log::Level::Warn), Level::Warn); + assert_eq!(Level::from(log::Level::Info), Level::Info); + assert_eq!(Level::from(log::Level::Debug), Level::Debug); + assert_eq!(Level::from(log::Level::Trace), Level::Trace); + } + + #[test] + fn test_level_into_log() { + assert_eq!(Into::<log::Level>::into(Level::Fatal), log::Level::Error); + assert_eq!(Into::<log::Level>::into(Level::Error), log::Level::Error); + assert_eq!(Into::<log::Level>::into(Level::Warn), log::Level::Warn); + assert_eq!(Into::<log::Level>::into(Level::Info), log::Level::Info); + assert_eq!(Into::<log::Level>::into(Level::Config), log::Level::Debug); + assert_eq!(Into::<log::Level>::into(Level::Debug), log::Level::Debug); + assert_eq!(Into::<log::Level>::into(Level::Trace), log::Level::Trace); + } + + #[test] + fn test_level_into_pref() { + let tests = [ + (Level::Fatal, "Fatal"), + (Level::Error, "Error"), + (Level::Warn, "Warn"), + (Level::Info, "Info"), + (Level::Config, "Config"), + (Level::Debug, "Debug"), + (Level::Trace, "Trace"), + ]; + + for &(lvl, s) in tests.iter() { + let expected = Pref { + value: PrefValue::String(s.to_string()), + sticky: false, + }; + assert_eq!(Into::<Pref>::into(lvl), expected); + } + } + + #[test] + fn test_level_from_str() { + assert_eq!(Level::from_str("fatal"), Ok(Level::Fatal)); + assert_eq!(Level::from_str("error"), Ok(Level::Error)); + assert_eq!(Level::from_str("warn"), Ok(Level::Warn)); + assert_eq!(Level::from_str("info"), Ok(Level::Info)); + assert_eq!(Level::from_str("config"), Ok(Level::Config)); + assert_eq!(Level::from_str("debug"), Ok(Level::Debug)); + assert_eq!(Level::from_str("trace"), Ok(Level::Trace)); + + assert_eq!(Level::from_str("INFO"), Ok(Level::Info)); + + assert!(Level::from_str("foo").is_err()); + } + + #[test] + fn test_level_to_str() { + assert_eq!(Level::Fatal.to_string(), "FATAL"); + assert_eq!(Level::Error.to_string(), "ERROR"); + assert_eq!(Level::Warn.to_string(), "WARN"); + assert_eq!(Level::Info.to_string(), "INFO"); + assert_eq!(Level::Config.to_string(), "CONFIG"); + assert_eq!(Level::Debug.to_string(), "DEBUG"); + assert_eq!(Level::Trace.to_string(), "TRACE"); + } + + #[test] + fn test_max_level() { + let _guard = LEVEL_MUTEX.lock(); + set_max_level(Level::Info); + assert_eq!(max_level(), Level::Info); + } + + #[test] + fn test_set_max_level() { + let _guard = LEVEL_MUTEX.lock(); + set_max_level(Level::Error); + assert_eq!(max_level(), Level::Error); + set_max_level(Level::Fatal); + assert_eq!(max_level(), Level::Fatal); + } + + #[test] + fn test_init_with_level() { + let _guard = LEVEL_MUTEX.lock(); + init_with_level(Level::Debug, false).unwrap(); + assert_eq!(max_level(), Level::Debug); + assert!(init_with_level(Level::Warn, false).is_err()); + } + + #[test] + fn test_format_ts() { + let ts = chrono::Local::now(); + let s = format_ts(ts); + assert_eq!(s.len(), 13); + } + + #[test] + fn test_truncate() { + let short_message = (0..MAX_STRING_LENGTH).map(|_| "x").collect::<String>(); + // A message up to MAX_STRING_LENGTH is not truncated + assert_eq!(truncate_message(&format_args!("{}", short_message)), None); + + let long_message = (0..MAX_STRING_LENGTH + 1).map(|_| "x").collect::<String>(); + let part = (0..MAX_STRING_LENGTH / 2).map(|_| "x").collect::<String>(); + + // A message longer than MAX_STRING_LENGTH is not truncated if requested + set_truncate(false); + assert_eq!(truncate_message(&format_args!("{}", long_message)), None); + + // A message longer than MAX_STRING_LENGTH is truncated if requested + set_truncate(true); + assert_eq!( + truncate_message(&format_args!("{}", long_message)), + Some((part.to_owned(), part)) + ); + } +} diff --git a/testing/geckodriver/src/main.rs b/testing/geckodriver/src/main.rs new file mode 100644 index 0000000000..64df65a0d0 --- /dev/null +++ b/testing/geckodriver/src/main.rs @@ -0,0 +1,549 @@ +#![forbid(unsafe_code)] + +extern crate chrono; +#[macro_use] +extern crate clap; +#[macro_use] +extern crate lazy_static; +extern crate hyper; +extern crate marionette as marionette_rs; +extern crate mozdevice; +extern crate mozprofile; +extern crate mozrunner; +extern crate mozversion; +extern crate regex; +extern crate serde; +#[macro_use] +extern crate serde_derive; +extern crate serde_json; +extern crate serde_yaml; +extern crate tempfile; +extern crate url; +extern crate uuid; +extern crate webdriver; +extern crate zip; + +#[macro_use] +extern crate log; + +use std::env; +use std::fmt; +use std::io; +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::path::PathBuf; +use std::result; +use std::str::FromStr; + +use clap::{AppSettings, Arg, Command}; + +macro_rules! try_opt { + ($expr:expr, $err_type:expr, $err_msg:expr) => {{ + match $expr { + Some(x) => x, + None => return Err(WebDriverError::new($err_type, $err_msg)), + } + }}; +} + +mod android; +mod browser; +mod build; +mod capabilities; +mod command; +mod logging; +mod marionette; +mod prefs; + +#[cfg(test)] +pub mod test; + +use crate::command::extension_routes; +use crate::logging::Level; +use crate::marionette::{MarionetteHandler, MarionetteSettings}; +use mozdevice::AndroidStorageInput; +use url::{Host, Url}; + +const EXIT_SUCCESS: i32 = 0; +const EXIT_USAGE: i32 = 64; +const EXIT_UNAVAILABLE: i32 = 69; + +enum FatalError { + Parsing(clap::Error), + Usage(String), + Server(io::Error), +} + +impl FatalError { + fn exit_code(&self) -> i32 { + use FatalError::*; + match *self { + Parsing(_) | Usage(_) => EXIT_USAGE, + Server(_) => EXIT_UNAVAILABLE, + } + } + + fn help_included(&self) -> bool { + matches!(*self, FatalError::Parsing(_)) + } +} + +impl From<clap::Error> for FatalError { + fn from(err: clap::Error) -> FatalError { + FatalError::Parsing(err) + } +} + +impl From<io::Error> for FatalError { + fn from(err: io::Error) -> FatalError { + FatalError::Server(err) + } +} + +// harmonise error message from clap to avoid duplicate "error:" prefix +impl fmt::Display for FatalError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use FatalError::*; + let s = match *self { + Parsing(ref err) => err.to_string(), + Usage(ref s) => format!("error: {}", s), + Server(ref err) => format!("error: {}", err), + }; + write!(f, "{}", s) + } +} + +macro_rules! usage { + ($msg:expr) => { + return Err(FatalError::Usage($msg.to_string())) + }; + + ($fmt:expr, $($arg:tt)+) => { + return Err(FatalError::Usage(format!($fmt, $($arg)+))) + }; +} + +type ProgramResult<T> = result::Result<T, FatalError>; + +#[allow(clippy::large_enum_variant)] +enum Operation { + Help, + Version, + Server { + log_level: Option<Level>, + log_truncate: bool, + address: SocketAddr, + allow_hosts: Vec<Host>, + allow_origins: Vec<Url>, + settings: MarionetteSettings, + deprecated_storage_arg: bool, + }, +} + +/// Get a socket address from the provided host and port +/// +/// # Arguments +/// * `webdriver_host` - The hostname on which the server will listen +/// * `webdriver_port` - The port on which the server will listen +/// +/// When the host and port resolve to multiple addresses, prefer +/// IPv4 addresses vs IPv6. +fn server_address(webdriver_host: &str, webdriver_port: u16) -> ProgramResult<SocketAddr> { + let mut socket_addrs = match format!("{}:{}", webdriver_host, webdriver_port).to_socket_addrs() + { + Ok(addrs) => addrs.collect::<Vec<_>>(), + Err(e) => usage!("{}: {}:{}", e, webdriver_host, webdriver_port), + }; + if socket_addrs.is_empty() { + usage!( + "Unable to resolve host: {}:{}", + webdriver_host, + webdriver_port + ) + } + // Prefer ipv4 address + socket_addrs.sort_by(|a, b| { + let a_val = i32::from(!a.ip().is_ipv4()); + let b_val = i32::from(!b.ip().is_ipv4()); + a_val.partial_cmp(&b_val).expect("Comparison failed") + }); + Ok(socket_addrs.remove(0)) +} + +/// Parse a given string into a Host +fn parse_hostname(webdriver_host: &str) -> Result<Host, url::ParseError> { + let host_str = if let Ok(ip_addr) = IpAddr::from_str(webdriver_host) { + // In this case we have an IP address as the host + if ip_addr.is_ipv6() { + // Convert to quoted form + format!("[{}]", &webdriver_host) + } else { + webdriver_host.into() + } + } else { + webdriver_host.into() + }; + + Host::parse(&host_str) +} + +/// Get a list of default hostnames to allow +/// +/// This only covers domain names, not IP addresses, since IP adresses +/// are always accepted. +fn get_default_allowed_hosts(ip: IpAddr) -> Vec<Result<Host, url::ParseError>> { + let localhost_is_loopback = ("localhost".to_string(), 80) + .to_socket_addrs() + .map(|addr_iter| { + addr_iter + .map(|addr| addr.ip()) + .filter(|ip| ip.is_loopback()) + }) + .iter() + .len() + > 0; + if ip.is_loopback() && localhost_is_loopback { + vec![Host::parse("localhost")] + } else { + vec![] + } +} + +fn get_allowed_hosts( + host: Host, + allow_hosts: Option<clap::Values>, +) -> Result<Vec<Host>, url::ParseError> { + allow_hosts + .map(|hosts| hosts.map(Host::parse).collect::<Vec<_>>()) + .unwrap_or_else(|| match host { + Host::Domain(_) => { + vec![Ok(host.clone())] + } + Host::Ipv4(ip) => get_default_allowed_hosts(IpAddr::V4(ip)), + Host::Ipv6(ip) => get_default_allowed_hosts(IpAddr::V6(ip)), + }) + .into_iter() + .collect::<Result<Vec<Host>, url::ParseError>>() +} + +fn get_allowed_origins(allow_origins: Option<clap::Values>) -> Result<Vec<Url>, url::ParseError> { + allow_origins + .map(|origins| { + origins + .map(Url::parse) + .collect::<Result<Vec<Url>, url::ParseError>>() + }) + .unwrap_or_else(|| Ok(vec![])) +} + +fn parse_args(cmd: &mut Command) -> ProgramResult<Operation> { + let args = cmd.try_get_matches_from_mut(env::args())?; + + if args.is_present("help") { + return Ok(Operation::Help); + } else if args.is_present("version") { + return Ok(Operation::Version); + } + + let log_level = if args.is_present("log_level") { + Level::from_str(args.value_of("log_level").unwrap()).ok() + } else { + Some(match args.occurrences_of("verbosity") { + 0 => Level::Info, + 1 => Level::Debug, + _ => Level::Trace, + }) + }; + + let webdriver_host = args.value_of("webdriver_host").unwrap(); + let webdriver_port = { + let s = args.value_of("webdriver_port").unwrap(); + match u16::from_str(s) { + Ok(n) => n, + Err(e) => usage!("invalid --port: {}: {}", e, s), + } + }; + + let android_storage = args + .value_of_t::<AndroidStorageInput>("android_storage") + .unwrap_or(AndroidStorageInput::Auto); + + let binary = args.value_of("binary").map(PathBuf::from); + + let profile_root = args.value_of("profile_root").map(PathBuf::from); + + // Try to create a temporary directory on startup to check that the directory exists and is writable + { + let tmp_dir = if let Some(ref tmp_root) = profile_root { + tempfile::tempdir_in(tmp_root) + } else { + tempfile::tempdir() + }; + if tmp_dir.is_err() { + usage!("Unable to write to temporary directory; consider --profile-root with a writeable directory") + } + } + + let marionette_host = args.value_of("marionette_host").unwrap(); + let marionette_port = match args.value_of("marionette_port") { + Some(s) => match u16::from_str(s) { + Ok(n) => Some(n), + Err(e) => usage!("invalid --marionette-port: {}", e), + }, + None => None, + }; + + // For Android the port on the device must be the same as the one on the + // host. For now default to 9222, which is the default for --remote-debugging-port. + let websocket_port = match args.value_of("websocket_port") { + Some(s) => match u16::from_str(s) { + Ok(n) => n, + Err(e) => usage!("invalid --websocket-port: {}", e), + }, + None => 9222, + }; + + let host = match parse_hostname(webdriver_host) { + Ok(name) => name, + Err(e) => usage!("invalid --host {}: {}", webdriver_host, e), + }; + + let allow_hosts = match get_allowed_hosts(host, args.values_of("allow_hosts")) { + Ok(hosts) => hosts, + Err(e) => usage!("invalid --allow-hosts {}", e), + }; + + let allow_origins = match get_allowed_origins(args.values_of("allow_origins")) { + Ok(origins) => origins, + Err(e) => usage!("invalid --allow-origins {}", e), + }; + + let address = server_address(webdriver_host, webdriver_port)?; + + let settings = MarionetteSettings { + binary, + profile_root, + connect_existing: args.is_present("connect_existing"), + host: marionette_host.into(), + port: marionette_port, + websocket_port, + allow_hosts: allow_hosts.clone(), + allow_origins: allow_origins.clone(), + jsdebugger: args.is_present("jsdebugger"), + android_storage, + }; + Ok(Operation::Server { + log_level, + log_truncate: !args.is_present("log_no_truncate"), + allow_hosts, + allow_origins, + address, + settings, + deprecated_storage_arg: args.is_present("android_storage"), + }) +} + +fn inner_main(cmd: &mut Command) -> ProgramResult<()> { + match parse_args(cmd)? { + Operation::Help => print_help(cmd), + Operation::Version => print_version(), + + Operation::Server { + log_level, + log_truncate, + address, + allow_hosts, + allow_origins, + settings, + deprecated_storage_arg, + } => { + if let Some(ref level) = log_level { + logging::init_with_level(*level, log_truncate).unwrap(); + } else { + logging::init(log_truncate).unwrap(); + } + + if deprecated_storage_arg { + warn!("--android-storage argument is deprecated and will be removed soon."); + }; + + let handler = MarionetteHandler::new(settings); + let listening = webdriver::server::start( + address, + allow_hosts, + allow_origins, + handler, + extension_routes(), + )?; + info!("Listening on {}", listening.socket); + } + } + + Ok(()) +} + +fn main() { + use std::process::exit; + + let mut cmd = make_command(); + + // use std::process:Termination when it graduates + exit(match inner_main(&mut cmd) { + Ok(_) => EXIT_SUCCESS, + + Err(e) => { + eprintln!("{}: {}", get_program_name(), e); + if !e.help_included() { + print_help(&mut cmd); + } + + e.exit_code() + } + }); +} + +fn make_command<'a>() -> Command<'a> { + Command::new(format!("geckodriver {}", build::build_info())) + .setting(AppSettings::NoAutoHelp) + .setting(AppSettings::NoAutoVersion) + .about("WebDriver implementation for Firefox") + .arg( + Arg::new("webdriver_host") + .long("host") + .takes_value(true) + .value_name("HOST") + .default_value("127.0.0.1") + .help("Host IP to use for WebDriver server"), + ) + .arg( + Arg::new("webdriver_port") + .short('p') + .long("port") + .takes_value(true) + .value_name("PORT") + .default_value("4444") + .help("Port to use for WebDriver server"), + ) + .arg( + Arg::new("binary") + .short('b') + .long("binary") + .takes_value(true) + .value_name("BINARY") + .help("Path to the Firefox binary"), + ) + .arg( + Arg::new("marionette_host") + .long("marionette-host") + .takes_value(true) + .value_name("HOST") + .default_value("127.0.0.1") + .help("Host to use to connect to Gecko"), + ) + .arg( + Arg::new("marionette_port") + .long("marionette-port") + .takes_value(true) + .value_name("PORT") + .help("Port to use to connect to Gecko [default: system-allocated port]"), + ) + .arg( + Arg::new("websocket_port") + .long("websocket-port") + .takes_value(true) + .value_name("PORT") + .conflicts_with("connect_existing") + .help("Port to use to connect to WebDriver BiDi [default: 9222]"), + ) + .arg( + Arg::new("connect_existing") + .long("connect-existing") + .requires("marionette_port") + .help("Connect to an existing Firefox instance"), + ) + .arg( + Arg::new("jsdebugger") + .long("jsdebugger") + .help("Attach browser toolbox debugger for Firefox"), + ) + .arg( + Arg::new("verbosity") + .multiple_occurrences(true) + .conflicts_with("log_level") + .short('v') + .help("Log level verbosity (-v for debug and -vv for trace level)"), + ) + .arg( + Arg::new("log_level") + .long("log") + .takes_value(true) + .value_name("LEVEL") + .possible_values(["fatal", "error", "warn", "info", "config", "debug", "trace"]) + .help("Set Gecko log level"), + ) + .arg( + Arg::new("log_no_truncate") + .long("log-no-truncate") + .help("Disable truncation of long log lines"), + ) + .arg( + Arg::new("help") + .short('h') + .long("help") + .help("Prints this message"), + ) + .arg( + Arg::new("version") + .short('V') + .long("version") + .help("Prints version and copying information"), + ) + .arg( + Arg::new("profile_root") + .long("profile-root") + .takes_value(true) + .value_name("PROFILE_ROOT") + .help("Directory in which to create profiles. Defaults to the system temporary directory."), + ) + .arg( + Arg::new("android_storage") + .long("android-storage") + .possible_values(["auto", "app", "internal", "sdcard"]) + .value_name("ANDROID_STORAGE") + .help("Selects storage location to be used for test data (deprecated)."), + ) + .arg( + Arg::new("allow_hosts") + .long("allow-hosts") + .takes_value(true) + .multiple_values(true) + .value_name("ALLOW_HOSTS") + .help("List of hostnames to allow. By default the value of --host is allowed, and in addition if that's a well known local address, other variations on well known local addresses are allowed. If --allow-hosts is provided only exactly those hosts are allowed."), + ) + .arg( + Arg::new("allow_origins") + .long("allow-origins") + .takes_value(true) + .multiple_values(true) + .value_name("ALLOW_ORIGINS") + .help("List of request origins to allow. These must be formatted as scheme://host:port. By default any request with an origin header is rejected. If --allow-origins is provided then only exactly those origins are allowed."), + ) +} + +fn get_program_name() -> String { + env::args().next().unwrap() +} + +fn print_help(cmd: &mut Command) { + cmd.print_help().ok(); + println!(); +} + +fn print_version() { + println!("geckodriver {}", build::build_info()); + println!(); + println!("The source code of this program is available from"); + println!("testing/geckodriver in https://hg.mozilla.org/mozilla-central."); + println!(); + println!("This program is subject to the terms of the Mozilla Public License 2.0."); + println!("You can obtain a copy of the license at https://mozilla.org/MPL/2.0/."); +} diff --git a/testing/geckodriver/src/marionette.rs b/testing/geckodriver/src/marionette.rs new file mode 100644 index 0000000000..836382b7db --- /dev/null +++ b/testing/geckodriver/src/marionette.rs @@ -0,0 +1,1623 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::browser::{Browser, LocalBrowser, RemoteBrowser}; +use crate::build; +use crate::capabilities::{FirefoxCapabilities, FirefoxOptions, ProfileType}; +use crate::command::{ + AddonInstallParameters, AddonUninstallParameters, GeckoContextParameters, + GeckoExtensionCommand, GeckoExtensionRoute, +}; +use crate::logging; +use marionette_rs::common::{ + Cookie as MarionetteCookie, Date as MarionetteDate, Frame as MarionetteFrame, + Timeouts as MarionetteTimeouts, WebElement as MarionetteWebElement, Window, +}; +use marionette_rs::marionette::AppStatus; +use marionette_rs::message::{Command, Message, MessageId, Request}; +use marionette_rs::webdriver::{ + Command as MarionetteWebDriverCommand, Keys as MarionetteKeys, Locator as MarionetteLocator, + NewWindow as MarionetteNewWindow, PrintMargins as MarionettePrintMargins, + PrintOrientation as MarionettePrintOrientation, PrintPage as MarionettePrintPage, + PrintParameters as MarionettePrintParameters, ScreenshotOptions, Script as MarionetteScript, + Selector as MarionetteSelector, Url as MarionetteUrl, WindowRect as MarionetteWindowRect, +}; +use mozdevice::AndroidStorageInput; +use serde::de::{self, Deserialize, Deserializer}; +use serde::ser::{Serialize, Serializer}; +use serde_json::{self, Map, Value}; +use std::io::prelude::*; +use std::io::Error as IoError; +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::thread; +use std::time; +use url::{Host, Url}; +use webdriver::capabilities::BrowserCapabilities; +use webdriver::command::WebDriverCommand::{ + AcceptAlert, AddCookie, CloseWindow, DeleteCookie, DeleteCookies, DeleteSession, DismissAlert, + ElementClear, ElementClick, ElementSendKeys, ExecuteAsyncScript, ExecuteScript, Extension, + FindElement, FindElementElement, FindElementElements, FindElements, FindShadowRootElement, + FindShadowRootElements, FullscreenWindow, Get, GetActiveElement, GetAlertText, GetCSSValue, + GetComputedLabel, GetComputedRole, GetCookies, GetCurrentUrl, GetElementAttribute, + GetElementProperty, GetElementRect, GetElementTagName, GetElementText, GetNamedCookie, + GetPageSource, GetShadowRoot, GetTimeouts, GetTitle, GetWindowHandle, GetWindowHandles, + GetWindowRect, GoBack, GoForward, IsDisplayed, IsEnabled, IsSelected, MaximizeWindow, + MinimizeWindow, NewSession, NewWindow, PerformActions, Print, Refresh, ReleaseActions, + SendAlertText, SetTimeouts, SetWindowRect, Status, SwitchToFrame, SwitchToParentFrame, + SwitchToWindow, TakeElementScreenshot, TakeScreenshot, +}; +use webdriver::command::{ + ActionsParameters, AddCookieParameters, GetNamedCookieParameters, GetParameters, + JavascriptCommandParameters, LocatorParameters, NewSessionParameters, NewWindowParameters, + PrintMargins, PrintOrientation, PrintPage, PrintParameters, SendKeysParameters, + SwitchToFrameParameters, SwitchToWindowParameters, TimeoutsParameters, WindowRectParameters, +}; +use webdriver::command::{WebDriverCommand, WebDriverMessage}; +use webdriver::common::{ + Cookie, Date, FrameId, LocatorStrategy, ShadowRoot, WebElement, ELEMENT_KEY, FRAME_KEY, + SHADOW_KEY, WINDOW_KEY, +}; +use webdriver::error::{ErrorStatus, WebDriverError, WebDriverResult}; +use webdriver::response::{ + CloseWindowResponse, CookieResponse, CookiesResponse, ElementRectResponse, NewSessionResponse, + NewWindowResponse, TimeoutsResponse, ValueResponse, WebDriverResponse, WindowRectResponse, +}; +use webdriver::server::{Session, WebDriverHandler}; +use webdriver::{capabilities::CapabilitiesMatching, server::SessionTeardownKind}; + +#[derive(Debug, PartialEq, Deserialize)] +struct MarionetteHandshake { + #[serde(rename = "marionetteProtocol")] + protocol: u16, + #[serde(rename = "applicationType")] + application_type: String, +} + +#[derive(Default)] +pub(crate) struct MarionetteSettings { + pub(crate) binary: Option<PathBuf>, + pub(crate) profile_root: Option<PathBuf>, + pub(crate) connect_existing: bool, + pub(crate) host: String, + pub(crate) port: Option<u16>, + pub(crate) websocket_port: u16, + pub(crate) allow_hosts: Vec<Host>, + pub(crate) allow_origins: Vec<Url>, + + /// Brings up the Browser Toolbox when starting Firefox, + /// letting you debug internals. + pub(crate) jsdebugger: bool, + + pub(crate) android_storage: AndroidStorageInput, +} + +#[derive(Default)] +pub(crate) struct MarionetteHandler { + connection: Mutex<Option<MarionetteConnection>>, + settings: MarionetteSettings, +} + +impl MarionetteHandler { + pub(crate) fn new(settings: MarionetteSettings) -> MarionetteHandler { + MarionetteHandler { + connection: Mutex::new(None), + settings, + } + } + + fn create_connection( + &self, + session_id: Option<String>, + new_session_parameters: &NewSessionParameters, + ) -> WebDriverResult<MarionetteConnection> { + let mut fx_capabilities = FirefoxCapabilities::new(self.settings.binary.as_ref()); + let (capabilities, options) = { + let mut capabilities = new_session_parameters + .match_browser(&mut fx_capabilities)? + .ok_or_else(|| { + WebDriverError::new( + ErrorStatus::SessionNotCreated, + "Unable to find a matching set of capabilities", + ) + })?; + + let options = FirefoxOptions::from_capabilities( + fx_capabilities.chosen_binary.clone(), + &self.settings, + &mut capabilities, + )?; + (capabilities, options) + }; + + if let Some(l) = options.log.level { + logging::set_max_level(l); + } + + let marionette_host = self.settings.host.to_owned(); + let marionette_port = match self.settings.port { + Some(port) => port, + None => { + // If we're launching Firefox Desktop version 95 or later, and there's no port + // specified, we can pass 0 as the port and later read it back from + // the profile. + let can_use_profile: bool = options.android.is_none() + && options.profile != ProfileType::Named + && !self.settings.connect_existing + && fx_capabilities + .browser_version(&capabilities) + .map(|opt_v| { + opt_v + .map(|v| { + fx_capabilities + .compare_browser_version(&v, ">=95") + .unwrap_or(false) + }) + .unwrap_or(false) + }) + .unwrap_or(false); + if can_use_profile { + 0 + } else { + get_free_port(&marionette_host)? + } + } + }; + + let websocket_port = if options.use_websocket { + Some(self.settings.websocket_port) + } else { + None + }; + + let browser = if options.android.is_some() { + // TODO: support connecting to running Apps. There's no real obstruction here, + // just some details about port forwarding to work through. We can't follow + // `chromedriver` here since it uses an abstract socket rather than a TCP socket: + // see bug 1240830 for thoughts on doing that for Marionette. + if self.settings.connect_existing { + return Err(WebDriverError::new( + ErrorStatus::SessionNotCreated, + "Cannot connect to an existing Android App yet", + )); + } + Browser::Remote(RemoteBrowser::new( + options, + marionette_port, + websocket_port, + self.settings.profile_root.as_deref(), + )?) + } else if !self.settings.connect_existing { + Browser::Local(LocalBrowser::new( + options, + marionette_port, + self.settings.jsdebugger, + self.settings.profile_root.as_deref(), + )?) + } else { + Browser::Existing(marionette_port) + }; + let session = MarionetteSession::new(session_id, capabilities); + MarionetteConnection::new(marionette_host, browser, session) + } + + fn close_connection(&mut self, wait_for_shutdown: bool) { + if let Ok(connection) = self.connection.get_mut() { + if let Some(conn) = connection.take() { + if let Err(e) = conn.close(wait_for_shutdown) { + error!("Failed to close browser connection: {}", e) + } + } + } + } +} + +impl WebDriverHandler<GeckoExtensionRoute> for MarionetteHandler { + fn handle_command( + &mut self, + _: &Option<Session>, + msg: WebDriverMessage<GeckoExtensionRoute>, + ) -> WebDriverResult<WebDriverResponse> { + // First handle the status message which doesn't actually require a marionette + // connection or message + if let Status = msg.command { + let (ready, message) = self + .connection + .get_mut() + .map(|ref connection| { + connection + .as_ref() + .map(|_| (false, "Session already started")) + .unwrap_or((true, "")) + }) + .unwrap_or((false, "geckodriver internal error")); + let mut value = Map::new(); + value.insert("ready".to_string(), Value::Bool(ready)); + value.insert("message".to_string(), Value::String(message.into())); + return Ok(WebDriverResponse::Generic(ValueResponse(Value::Object( + value, + )))); + } + + match self.connection.lock() { + Ok(mut connection) => { + if connection.is_none() { + if let NewSession(ref capabilities) = msg.command { + let conn = self.create_connection(msg.session_id.clone(), capabilities)?; + *connection = Some(conn); + } else { + return Err(WebDriverError::new( + ErrorStatus::InvalidSessionId, + "Tried to run command without establishing a connection", + )); + } + } + let conn = connection.as_mut().expect("Missing connection"); + conn.send_command(&msg).map_err(|mut err| { + // Shutdown the browser if no session can + // be established due to errors. + if let NewSession(_) = msg.command { + err.delete_session = true; + } + err + }) + } + Err(_) => Err(WebDriverError::new( + ErrorStatus::UnknownError, + "Failed to aquire Marionette connection", + )), + } + } + + fn teardown_session(&mut self, kind: SessionTeardownKind) { + let wait_for_shutdown = match kind { + SessionTeardownKind::Deleted => true, + SessionTeardownKind::NotDeleted => false, + }; + self.close_connection(wait_for_shutdown); + } +} + +impl Drop for MarionetteHandler { + fn drop(&mut self) { + self.close_connection(false); + } +} + +struct MarionetteSession { + session_id: String, + capabilities: Map<String, Value>, + command_id: MessageId, +} + +impl MarionetteSession { + fn new(session_id: Option<String>, capabilities: Map<String, Value>) -> MarionetteSession { + let initital_id = session_id.unwrap_or_default(); + MarionetteSession { + session_id: initital_id, + capabilities, + command_id: 0, + } + } + + fn update( + &mut self, + msg: &WebDriverMessage<GeckoExtensionRoute>, + resp: &MarionetteResponse, + ) -> WebDriverResult<()> { + if let NewSession(_) = msg.command { + let session_id = try_opt!( + try_opt!( + resp.result.get("sessionId"), + ErrorStatus::SessionNotCreated, + "Unable to get session id" + ) + .as_str(), + ErrorStatus::SessionNotCreated, + "Unable to convert session id to string" + ); + self.session_id = session_id.to_string(); + }; + Ok(()) + } + + /// Converts a Marionette JSON response into a `WebElement`. + /// + /// Note that it currently coerces all chrome elements, web frames, and web + /// windows also into web elements. This will change at a later point. + fn to_web_element(&self, json_data: &Value) -> WebDriverResult<WebElement> { + let data = try_opt!( + json_data.as_object(), + ErrorStatus::UnknownError, + "Failed to convert data to an object" + ); + + let element = data.get(ELEMENT_KEY); + let frame = data.get(FRAME_KEY); + let window = data.get(WINDOW_KEY); + + let value = try_opt!( + element.or(frame).or(window), + ErrorStatus::UnknownError, + "Failed to extract web element from Marionette response" + ); + let id = try_opt!( + value.as_str(), + ErrorStatus::UnknownError, + "Failed to convert web element reference value to string" + ) + .to_string(); + Ok(WebElement(id)) + } + + /// Converts a Marionette JSON response into a `ShadowRoot`. + fn to_shadow_root(&self, json_data: &Value) -> WebDriverResult<ShadowRoot> { + let data = try_opt!( + json_data.as_object(), + ErrorStatus::UnknownError, + "Failed to convert data to an object" + ); + + let shadow_root = data.get(SHADOW_KEY); + + let value = try_opt!( + shadow_root, + ErrorStatus::UnknownError, + "Failed to extract shadow root from Marionette response" + ); + let id = try_opt!( + value.as_str(), + ErrorStatus::UnknownError, + "Failed to convert shadow root reference value to string" + ) + .to_string(); + Ok(ShadowRoot(id)) + } + + fn next_command_id(&mut self) -> MessageId { + self.command_id += 1; + self.command_id + } + + fn response( + &mut self, + msg: &WebDriverMessage<GeckoExtensionRoute>, + resp: MarionetteResponse, + ) -> WebDriverResult<WebDriverResponse> { + use self::GeckoExtensionCommand::*; + + if resp.id != self.command_id { + return Err(WebDriverError::new( + ErrorStatus::UnknownError, + format!( + "Marionette responses arrived out of sequence, expected {}, got {}", + self.command_id, resp.id + ), + )); + } + + if let Some(error) = resp.error { + return Err(error.into()); + } + + self.update(msg, &resp)?; + + Ok(match msg.command { + // Everything that doesn't have a response value + Get(_) + | GoBack + | GoForward + | Refresh + | SetTimeouts(_) + | SwitchToWindow(_) + | SwitchToFrame(_) + | SwitchToParentFrame + | AddCookie(_) + | DeleteCookies + | DeleteCookie(_) + | DismissAlert + | AcceptAlert + | SendAlertText(_) + | ElementClick(_) + | ElementClear(_) + | ElementSendKeys(_, _) + | PerformActions(_) + | ReleaseActions => WebDriverResponse::Void, + // Things that simply return the contents of the marionette "value" property + GetCurrentUrl + | GetTitle + | GetPageSource + | GetWindowHandle + | IsDisplayed(_) + | IsSelected(_) + | GetElementAttribute(_, _) + | GetElementProperty(_, _) + | GetCSSValue(_, _) + | GetElementText(_) + | GetElementTagName(_) + | GetComputedLabel(_) + | GetComputedRole(_) + | IsEnabled(_) + | ExecuteScript(_) + | ExecuteAsyncScript(_) + | GetAlertText + | TakeScreenshot + | Print(_) + | TakeElementScreenshot(_) => { + WebDriverResponse::Generic(resp.into_value_response(true)?) + } + GetTimeouts => { + let script = match try_opt!( + resp.result.get("script"), + ErrorStatus::UnknownError, + "Missing field: script" + ) { + Value::Null => None, + n => try_opt!( + Some(n.as_u64()), + ErrorStatus::UnknownError, + "Failed to interpret script timeout duration as u64" + ), + }; + let page_load = try_opt!( + try_opt!( + resp.result.get("pageLoad"), + ErrorStatus::UnknownError, + "Missing field: pageLoad" + ) + .as_u64(), + ErrorStatus::UnknownError, + "Failed to interpret page load duration as u64" + ); + let implicit = try_opt!( + try_opt!( + resp.result.get("implicit"), + ErrorStatus::UnknownError, + "Missing field: implicit" + ) + .as_u64(), + ErrorStatus::UnknownError, + "Failed to interpret implicit search duration as u64" + ); + + WebDriverResponse::Timeouts(TimeoutsResponse { + script, + page_load, + implicit, + }) + } + Status => panic!("Got status command that should already have been handled"), + GetWindowHandles => WebDriverResponse::Generic(resp.into_value_response(false)?), + NewWindow(_) => { + let handle: String = try_opt!( + try_opt!( + resp.result.get("handle"), + ErrorStatus::UnknownError, + "Failed to find handle field" + ) + .as_str(), + ErrorStatus::UnknownError, + "Failed to interpret handle as string" + ) + .into(); + let typ: String = try_opt!( + try_opt!( + resp.result.get("type"), + ErrorStatus::UnknownError, + "Failed to find type field" + ) + .as_str(), + ErrorStatus::UnknownError, + "Failed to interpret type as string" + ) + .into(); + + WebDriverResponse::NewWindow(NewWindowResponse { handle, typ }) + } + CloseWindow => { + let data = try_opt!( + resp.result.as_array(), + ErrorStatus::UnknownError, + "Failed to interpret value as array" + ); + let handles = data + .iter() + .map(|x| { + Ok(try_opt!( + x.as_str(), + ErrorStatus::UnknownError, + "Failed to interpret window handle as string" + ) + .to_owned()) + }) + .collect::<Result<Vec<_>, _>>()?; + WebDriverResponse::CloseWindow(CloseWindowResponse(handles)) + } + GetElementRect(_) => { + let x = try_opt!( + try_opt!( + resp.result.get("x"), + ErrorStatus::UnknownError, + "Failed to find x field" + ) + .as_f64(), + ErrorStatus::UnknownError, + "Failed to interpret x as float" + ); + + let y = try_opt!( + try_opt!( + resp.result.get("y"), + ErrorStatus::UnknownError, + "Failed to find y field" + ) + .as_f64(), + ErrorStatus::UnknownError, + "Failed to interpret y as float" + ); + + let width = try_opt!( + try_opt!( + resp.result.get("width"), + ErrorStatus::UnknownError, + "Failed to find width field" + ) + .as_f64(), + ErrorStatus::UnknownError, + "Failed to interpret width as float" + ); + + let height = try_opt!( + try_opt!( + resp.result.get("height"), + ErrorStatus::UnknownError, + "Failed to find height field" + ) + .as_f64(), + ErrorStatus::UnknownError, + "Failed to interpret width as float" + ); + + let rect = ElementRectResponse { + x, + y, + width, + height, + }; + WebDriverResponse::ElementRect(rect) + } + FullscreenWindow | MinimizeWindow | MaximizeWindow | GetWindowRect + | SetWindowRect(_) => { + let width = try_opt!( + try_opt!( + resp.result.get("width"), + ErrorStatus::UnknownError, + "Failed to find width field" + ) + .as_u64(), + ErrorStatus::UnknownError, + "Failed to interpret width as positive integer" + ); + + let height = try_opt!( + try_opt!( + resp.result.get("height"), + ErrorStatus::UnknownError, + "Failed to find heigenht field" + ) + .as_u64(), + ErrorStatus::UnknownError, + "Failed to interpret height as positive integer" + ); + + let x = try_opt!( + try_opt!( + resp.result.get("x"), + ErrorStatus::UnknownError, + "Failed to find x field" + ) + .as_i64(), + ErrorStatus::UnknownError, + "Failed to interpret x as integer" + ); + + let y = try_opt!( + try_opt!( + resp.result.get("y"), + ErrorStatus::UnknownError, + "Failed to find y field" + ) + .as_i64(), + ErrorStatus::UnknownError, + "Failed to interpret y as integer" + ); + + let rect = WindowRectResponse { + x: x as i32, + y: y as i32, + width: width as i32, + height: height as i32, + }; + WebDriverResponse::WindowRect(rect) + } + GetCookies => { + let cookies: Vec<Cookie> = serde_json::from_value(resp.result)?; + WebDriverResponse::Cookies(CookiesResponse(cookies)) + } + GetNamedCookie(ref name) => { + let mut cookies: Vec<Cookie> = serde_json::from_value(resp.result)?; + cookies.retain(|x| x.name == *name); + let cookie = try_opt!( + cookies.pop(), + ErrorStatus::NoSuchCookie, + format!("No cookie with name {}", name) + ); + WebDriverResponse::Cookie(CookieResponse(cookie)) + } + FindElement(_) | FindElementElement(_, _) | FindShadowRootElement(_, _) => { + let element = self.to_web_element(try_opt!( + resp.result.get("value"), + ErrorStatus::UnknownError, + "Failed to find value field" + ))?; + WebDriverResponse::Generic(ValueResponse(serde_json::to_value(element)?)) + } + FindElements(_) | FindElementElements(_, _) | FindShadowRootElements(_, _) => { + let element_vec = try_opt!( + resp.result.as_array(), + ErrorStatus::UnknownError, + "Failed to interpret value as array" + ); + let elements = element_vec + .iter() + .map(|x| self.to_web_element(x)) + .collect::<Result<Vec<_>, _>>()?; + + // TODO(Henrik): How to remove unwrap? + WebDriverResponse::Generic(ValueResponse(Value::Array( + elements + .iter() + .map(|x| serde_json::to_value(x).unwrap()) + .collect(), + ))) + } + GetShadowRoot(_) => { + let shadow_root = self.to_shadow_root(try_opt!( + resp.result.get("value"), + ErrorStatus::UnknownError, + "Failed to find value field" + ))?; + WebDriverResponse::Generic(ValueResponse(serde_json::to_value(shadow_root)?)) + } + GetActiveElement => { + let element = self.to_web_element(try_opt!( + resp.result.get("value"), + ErrorStatus::UnknownError, + "Failed to find value field" + ))?; + WebDriverResponse::Generic(ValueResponse(serde_json::to_value(element)?)) + } + NewSession(_) => { + let session_id = try_opt!( + try_opt!( + resp.result.get("sessionId"), + ErrorStatus::InvalidSessionId, + "Failed to find sessionId field" + ) + .as_str(), + ErrorStatus::InvalidSessionId, + "sessionId is not a string" + ); + + let mut capabilities = try_opt!( + try_opt!( + resp.result.get("capabilities"), + ErrorStatus::UnknownError, + "Failed to find capabilities field" + ) + .as_object(), + ErrorStatus::UnknownError, + "capabilities field is not an object" + ) + .clone(); + + capabilities.insert("moz:geckodriverVersion".into(), build::build_info().into()); + + WebDriverResponse::NewSession(NewSessionResponse::new( + session_id.to_string(), + Value::Object(capabilities), + )) + } + DeleteSession => WebDriverResponse::DeleteSession, + Extension(ref extension) => match extension { + GetContext => WebDriverResponse::Generic(resp.into_value_response(true)?), + SetContext(_) => WebDriverResponse::Void, + InstallAddon(_) => WebDriverResponse::Generic(resp.into_value_response(true)?), + UninstallAddon(_) => WebDriverResponse::Void, + TakeFullScreenshot => WebDriverResponse::Generic(resp.into_value_response(true)?), + }, + }) + } +} + +fn try_convert_to_marionette_message( + msg: &WebDriverMessage<GeckoExtensionRoute>, + browser: &Browser, +) -> WebDriverResult<Option<Command>> { + use self::GeckoExtensionCommand::*; + use self::WebDriverCommand::*; + + Ok(match msg.command { + AcceptAlert => Some(Command::WebDriver(MarionetteWebDriverCommand::AcceptAlert)), + AddCookie(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::AddCookie( + x.to_marionette()?, + ))), + CloseWindow => Some(Command::WebDriver(MarionetteWebDriverCommand::CloseWindow)), + DeleteCookie(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::DeleteCookie(x.clone()), + )), + DeleteCookies => Some(Command::WebDriver( + MarionetteWebDriverCommand::DeleteCookies, + )), + DeleteSession => match browser { + Browser::Local(_) | Browser::Remote(_) => Some(Command::Marionette( + marionette_rs::marionette::Command::DeleteSession { + flags: vec![AppStatus::eForceQuit], + }, + )), + Browser::Existing(_) => Some(Command::WebDriver( + MarionetteWebDriverCommand::DeleteSession, + )), + }, + DismissAlert => Some(Command::WebDriver(MarionetteWebDriverCommand::DismissAlert)), + ElementClear(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::ElementClear { + id: e.clone().to_string(), + }, + )), + ElementClick(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::ElementClick { + id: e.clone().to_string(), + }, + )), + ElementSendKeys(ref e, ref x) => { + let keys = x.to_marionette()?; + Some(Command::WebDriver( + MarionetteWebDriverCommand::ElementSendKeys { + id: e.clone().to_string(), + text: keys.text.clone(), + value: keys.value, + }, + )) + } + ExecuteAsyncScript(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::ExecuteAsyncScript(x.to_marionette()?), + )), + ExecuteScript(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::ExecuteScript(x.to_marionette()?), + )), + FindElement(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::FindElement( + x.to_marionette()?, + ))), + FindElements(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::FindElements(x.to_marionette()?), + )), + FindElementElement(ref e, ref x) => { + let locator = x.to_marionette()?; + Some(Command::WebDriver( + MarionetteWebDriverCommand::FindElementElement { + element: e.clone().to_string(), + using: locator.using.clone(), + value: locator.value, + }, + )) + } + FindElementElements(ref e, ref x) => { + let locator = x.to_marionette()?; + Some(Command::WebDriver( + MarionetteWebDriverCommand::FindElementElements { + element: e.clone().to_string(), + using: locator.using.clone(), + value: locator.value, + }, + )) + } + FindShadowRootElement(ref s, ref x) => { + let locator = x.to_marionette()?; + Some(Command::WebDriver( + MarionetteWebDriverCommand::FindShadowRootElement { + shadow_root: s.clone().to_string(), + using: locator.using.clone(), + value: locator.value, + }, + )) + } + FindShadowRootElements(ref s, ref x) => { + let locator = x.to_marionette()?; + Some(Command::WebDriver( + MarionetteWebDriverCommand::FindShadowRootElements { + shadow_root: s.clone().to_string(), + using: locator.using.clone(), + value: locator.value, + }, + )) + } + FullscreenWindow => Some(Command::WebDriver( + MarionetteWebDriverCommand::FullscreenWindow, + )), + Get(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::Get( + x.to_marionette()?, + ))), + GetActiveElement => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetActiveElement, + )), + GetAlertText => Some(Command::WebDriver(MarionetteWebDriverCommand::GetAlertText)), + GetComputedLabel(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetComputedLabel { + id: e.clone().to_string(), + }, + )), + GetComputedRole(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetComputedRole { + id: e.clone().to_string(), + }, + )), + GetCookies | GetNamedCookie(_) => { + Some(Command::WebDriver(MarionetteWebDriverCommand::GetCookies)) + } + GetCSSValue(ref e, ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetCSSValue { + id: e.clone().to_string(), + property: x.clone(), + }, + )), + GetCurrentUrl => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetCurrentUrl, + )), + GetElementAttribute(ref e, ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementAttribute { + id: e.clone().to_string(), + name: x.clone(), + }, + )), + GetElementProperty(ref e, ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementProperty { + id: e.clone().to_string(), + name: x.clone(), + }, + )), + GetElementRect(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementRect { + id: e.clone().to_string(), + }, + )), + GetElementTagName(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementTagName { + id: e.clone().to_string(), + }, + )), + GetElementText(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetElementText { + id: e.clone().to_string(), + }, + )), + GetPageSource => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetPageSource, + )), + GetShadowRoot(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetShadowRoot { + id: e.clone().to_string(), + }, + )), + GetTitle => Some(Command::WebDriver(MarionetteWebDriverCommand::GetTitle)), + GetWindowHandle => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetWindowHandle, + )), + GetWindowHandles => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetWindowHandles, + )), + GetWindowRect => Some(Command::WebDriver( + MarionetteWebDriverCommand::GetWindowRect, + )), + GetTimeouts => Some(Command::WebDriver(MarionetteWebDriverCommand::GetTimeouts)), + GoBack => Some(Command::WebDriver(MarionetteWebDriverCommand::GoBack)), + GoForward => Some(Command::WebDriver(MarionetteWebDriverCommand::GoForward)), + IsDisplayed(ref e) => Some(Command::WebDriver( + MarionetteWebDriverCommand::IsDisplayed { + id: e.clone().to_string(), + }, + )), + IsEnabled(ref e) => Some(Command::WebDriver(MarionetteWebDriverCommand::IsEnabled { + id: e.clone().to_string(), + })), + IsSelected(ref e) => Some(Command::WebDriver(MarionetteWebDriverCommand::IsSelected { + id: e.clone().to_string(), + })), + MaximizeWindow => Some(Command::WebDriver( + MarionetteWebDriverCommand::MaximizeWindow, + )), + MinimizeWindow => Some(Command::WebDriver( + MarionetteWebDriverCommand::MinimizeWindow, + )), + NewWindow(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::NewWindow( + x.to_marionette()?, + ))), + Print(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::Print( + x.to_marionette()?, + ))), + Refresh => Some(Command::WebDriver(MarionetteWebDriverCommand::Refresh)), + ReleaseActions => Some(Command::WebDriver( + MarionetteWebDriverCommand::ReleaseActions, + )), + SendAlertText(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::SendAlertText(x.to_marionette()?), + )), + SetTimeouts(ref x) => Some(Command::WebDriver(MarionetteWebDriverCommand::SetTimeouts( + x.to_marionette()?, + ))), + SetWindowRect(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::SetWindowRect(x.to_marionette()?), + )), + SwitchToFrame(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::SwitchToFrame(x.to_marionette()?), + )), + SwitchToParentFrame => Some(Command::WebDriver( + MarionetteWebDriverCommand::SwitchToParentFrame, + )), + SwitchToWindow(ref x) => Some(Command::WebDriver( + MarionetteWebDriverCommand::SwitchToWindow(x.to_marionette()?), + )), + TakeElementScreenshot(ref e) => { + let screenshot = ScreenshotOptions { + id: Some(e.clone().to_string()), + highlights: vec![], + full: false, + }; + Some(Command::WebDriver( + MarionetteWebDriverCommand::TakeElementScreenshot(screenshot), + )) + } + TakeScreenshot => { + let screenshot = ScreenshotOptions { + id: None, + highlights: vec![], + full: false, + }; + Some(Command::WebDriver( + MarionetteWebDriverCommand::TakeScreenshot(screenshot), + )) + } + Extension(TakeFullScreenshot) => { + let screenshot = ScreenshotOptions { + id: None, + highlights: vec![], + full: true, + }; + Some(Command::WebDriver( + MarionetteWebDriverCommand::TakeFullScreenshot(screenshot), + )) + } + _ => None, + }) +} + +#[derive(Debug, PartialEq)] +struct MarionetteCommand { + id: MessageId, + name: String, + params: Map<String, Value>, +} + +impl Serialize for MarionetteCommand { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let data = (&0, &self.id, &self.name, &self.params); + data.serialize(serializer) + } +} + +impl MarionetteCommand { + fn new(id: MessageId, name: String, params: Map<String, Value>) -> MarionetteCommand { + MarionetteCommand { id, name, params } + } + + fn encode_msg<T>(msg: T) -> WebDriverResult<String> + where + T: serde::Serialize, + { + let data = serde_json::to_string(&msg)?; + + Ok(format!("{}:{}", data.len(), data)) + } + + fn from_webdriver_message( + id: MessageId, + capabilities: &Map<String, Value>, + browser: &Browser, + msg: &WebDriverMessage<GeckoExtensionRoute>, + ) -> WebDriverResult<String> { + use self::GeckoExtensionCommand::*; + + if let Some(cmd) = try_convert_to_marionette_message(msg, browser)? { + let req = Message::Incoming(Request(id, cmd)); + MarionetteCommand::encode_msg(req) + } else { + let (opt_name, opt_parameters) = match msg.command { + Status => panic!("Got status command that should already have been handled"), + NewSession(_) => { + let mut data = Map::new(); + for (k, v) in capabilities.iter() { + data.insert(k.to_string(), serde_json::to_value(v)?); + } + + (Some("WebDriver:NewSession"), Some(Ok(data))) + } + PerformActions(ref x) => { + (Some("WebDriver:PerformActions"), Some(x.to_marionette())) + } + Extension(ref extension) => match extension { + GetContext => (Some("Marionette:GetContext"), None), + InstallAddon(x) => (Some("Addon:Install"), Some(x.to_marionette())), + SetContext(x) => (Some("Marionette:SetContext"), Some(x.to_marionette())), + UninstallAddon(x) => (Some("Addon:Uninstall"), Some(x.to_marionette())), + _ => (None, None), + }, + _ => (None, None), + }; + + let name = try_opt!( + opt_name, + ErrorStatus::UnsupportedOperation, + "Operation not supported" + ); + let parameters = opt_parameters.unwrap_or_else(|| Ok(Map::new()))?; + + let req = MarionetteCommand::new(id, name.into(), parameters); + MarionetteCommand::encode_msg(req) + } + } +} + +#[derive(Debug, PartialEq)] +struct MarionetteResponse { + id: MessageId, + error: Option<MarionetteError>, + result: Value, +} + +impl<'de> Deserialize<'de> for MarionetteResponse { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct ResponseWrapper { + msg_type: u64, + id: MessageId, + error: Option<MarionetteError>, + result: Value, + } + + let wrapper: ResponseWrapper = Deserialize::deserialize(deserializer)?; + + if wrapper.msg_type != 1 { + return Err(de::Error::custom( + "Expected '1' in first element of response", + )); + }; + + Ok(MarionetteResponse { + id: wrapper.id, + error: wrapper.error, + result: wrapper.result, + }) + } +} + +impl MarionetteResponse { + fn into_value_response(self, value_required: bool) -> WebDriverResult<ValueResponse> { + let value: &Value = if value_required { + try_opt!( + self.result.get("value"), + ErrorStatus::UnknownError, + "Failed to find value field" + ) + } else { + &self.result + }; + + Ok(ValueResponse(value.clone())) + } +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct MarionetteError { + #[serde(rename = "error")] + code: String, + message: String, + stacktrace: Option<String>, +} + +impl From<MarionetteError> for WebDriverError { + fn from(error: MarionetteError) -> WebDriverError { + let status = ErrorStatus::from(error.code); + let message = error.message; + + if let Some(stack) = error.stacktrace { + WebDriverError::new_with_stack(status, message, stack) + } else { + WebDriverError::new(status, message) + } + } +} + +fn get_free_port(host: &str) -> IoResult<u16> { + TcpListener::bind((host, 0)) + .and_then(|stream| stream.local_addr()) + .map(|x| x.port()) +} + +struct MarionetteConnection { + browser: Browser, + session: MarionetteSession, + stream: TcpStream, +} + +impl MarionetteConnection { + fn new( + host: String, + mut browser: Browser, + session: MarionetteSession, + ) -> WebDriverResult<MarionetteConnection> { + let stream = match MarionetteConnection::connect(&host, &mut browser) { + Ok(stream) => stream, + Err(e) => { + if let Err(e) = browser.close(true) { + error!("Failed to stop browser: {:?}", e); + } + return Err(e); + } + }; + Ok(MarionetteConnection { + browser, + session, + stream, + }) + } + + fn connect(host: &str, browser: &mut Browser) -> WebDriverResult<TcpStream> { + let timeout = time::Duration::from_secs(60); + let poll_interval = time::Duration::from_millis(100); + let now = time::Instant::now(); + + debug!( + "Waiting {}s to connect to browser on {}", + timeout.as_secs(), + host, + ); + + loop { + // immediately abort connection attempts if process disappears + if let Browser::Local(browser) = browser { + if let Some(status) = browser.check_status() { + return Err(WebDriverError::new( + ErrorStatus::UnknownError, + format!("Process unexpectedly closed with status {}", status), + )); + } + } + + let last_err; + + if let Some(port) = browser.marionette_port()? { + match MarionetteConnection::try_connect(host, port) { + Ok(stream) => { + debug!("Connection to Marionette established on {}:{}.", host, port); + browser.update_marionette_port(port); + return Ok(stream); + } + Err(e) => { + let err_str = e.to_string(); + last_err = Some(err_str); + } + } + } else { + last_err = Some("Failed to read marionette port".into()); + } + if now.elapsed() < timeout { + trace!("Retrying in {:?}", poll_interval); + thread::sleep(poll_interval); + } else { + return Err(WebDriverError::new( + ErrorStatus::Timeout, + last_err.unwrap_or_else(|| "Unknown error".into()), + )); + } + } + } + + fn try_connect(host: &str, port: u16) -> WebDriverResult<TcpStream> { + let mut stream = TcpStream::connect((host, port))?; + MarionetteConnection::handshake(&mut stream)?; + Ok(stream) + } + + fn handshake(stream: &mut TcpStream) -> WebDriverResult<MarionetteHandshake> { + let resp = (match stream.read_timeout() { + Ok(timeout) => { + // If platform supports changing the read timeout of the stream, + // use a short one only for the handshake with Marionette. Don't + // make it shorter as 1000ms to not fail on slow connections. + stream + .set_read_timeout(Some(time::Duration::from_millis(1000))) + .ok(); + let data = MarionetteConnection::read_resp(stream); + stream.set_read_timeout(timeout).ok(); + + data + } + _ => MarionetteConnection::read_resp(stream), + }) + .map_err(|e| { + WebDriverError::new( + ErrorStatus::UnknownError, + format!("Socket timeout reading Marionette handshake data: {}", e), + ) + })?; + + let data = serde_json::from_str::<MarionetteHandshake>(&resp)?; + + if data.application_type != "gecko" { + return Err(WebDriverError::new( + ErrorStatus::UnknownError, + format!("Unrecognized application type {}", data.application_type), + )); + } + + if data.protocol != 3 { + return Err(WebDriverError::new( + ErrorStatus::UnknownError, + format!( + "Unsupported Marionette protocol version {}, required 3", + data.protocol + ), + )); + } + + Ok(data) + } + + fn close(self, wait_for_shutdown: bool) -> WebDriverResult<()> { + self.stream.shutdown(Shutdown::Both)?; + self.browser.close(wait_for_shutdown)?; + Ok(()) + } + + fn send_command( + &mut self, + msg: &WebDriverMessage<GeckoExtensionRoute>, + ) -> WebDriverResult<WebDriverResponse> { + let id = self.session.next_command_id(); + let enc_cmd = MarionetteCommand::from_webdriver_message( + id, + &self.session.capabilities, + &self.browser, + msg, + )?; + let resp_data = self.send(enc_cmd)?; + let data: MarionetteResponse = serde_json::from_str(&resp_data)?; + + self.session.response(msg, data) + } + + fn send(&mut self, data: String) -> WebDriverResult<String> { + if self.stream.write(data.as_bytes()).is_err() { + let mut err = WebDriverError::new( + ErrorStatus::UnknownError, + "Failed to write request to stream", + ); + err.delete_session = true; + return Err(err); + } + + match MarionetteConnection::read_resp(&mut self.stream) { + Ok(resp) => Ok(resp), + Err(_) => { + let mut err = WebDriverError::new( + ErrorStatus::UnknownError, + "Failed to decode response from marionette", + ); + err.delete_session = true; + Err(err) + } + } + } + + fn read_resp(stream: &mut TcpStream) -> IoResult<String> { + let mut bytes = 0usize; + + loop { + let buf = &mut [0u8]; + let num_read = stream.read(buf)?; + let byte = match num_read { + 0 => { + return Err(IoError::new( + ErrorKind::Other, + "EOF reading marionette message", + )) + } + 1 => buf[0], + _ => panic!("Expected one byte got more"), + } as char; + match byte { + '0'..='9' => { + bytes *= 10; + bytes += byte as usize - '0' as usize; + } + ':' => break, + _ => {} + } + } + + let buf = &mut [0u8; 8192]; + let mut payload = Vec::with_capacity(bytes); + let mut total_read = 0; + while total_read < bytes { + let num_read = stream.read(buf)?; + if num_read == 0 { + return Err(IoError::new( + ErrorKind::Other, + "EOF reading marionette message", + )); + } + total_read += num_read; + for x in &buf[..num_read] { + payload.push(*x); + } + } + + // TODO(jgraham): Need to handle the error here + Ok(String::from_utf8(payload).unwrap()) + } +} + +trait ToMarionette<T> { + fn to_marionette(&self) -> WebDriverResult<T>; +} + +impl ToMarionette<Map<String, Value>> for AddonInstallParameters { + fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> { + let mut data = Map::new(); + data.insert("path".to_string(), serde_json::to_value(&self.path)?); + if self.temporary.is_some() { + data.insert( + "temporary".to_string(), + serde_json::to_value(self.temporary)?, + ); + } + Ok(data) + } +} + +impl ToMarionette<Map<String, Value>> for AddonUninstallParameters { + fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> { + let mut data = Map::new(); + data.insert("id".to_string(), Value::String(self.id.clone())); + Ok(data) + } +} + +impl ToMarionette<Map<String, Value>> for GeckoContextParameters { + fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> { + let mut data = Map::new(); + data.insert( + "value".to_owned(), + serde_json::to_value(self.context.clone())?, + ); + Ok(data) + } +} + +impl ToMarionette<MarionettePrintParameters> for PrintParameters { + fn to_marionette(&self) -> WebDriverResult<MarionettePrintParameters> { + Ok(MarionettePrintParameters { + orientation: self.orientation.to_marionette()?, + scale: self.scale, + background: self.background, + page: self.page.to_marionette()?, + margin: self.margin.to_marionette()?, + page_ranges: self.page_ranges.clone(), + shrink_to_fit: self.shrink_to_fit, + }) + } +} + +impl ToMarionette<MarionettePrintOrientation> for PrintOrientation { + fn to_marionette(&self) -> WebDriverResult<MarionettePrintOrientation> { + Ok(match self { + PrintOrientation::Landscape => MarionettePrintOrientation::Landscape, + PrintOrientation::Portrait => MarionettePrintOrientation::Portrait, + }) + } +} + +impl ToMarionette<MarionettePrintPage> for PrintPage { + fn to_marionette(&self) -> WebDriverResult<MarionettePrintPage> { + Ok(MarionettePrintPage { + width: self.width, + height: self.height, + }) + } +} + +impl ToMarionette<MarionettePrintMargins> for PrintMargins { + fn to_marionette(&self) -> WebDriverResult<MarionettePrintMargins> { + Ok(MarionettePrintMargins { + top: self.top, + bottom: self.bottom, + left: self.left, + right: self.right, + }) + } +} + +impl ToMarionette<Map<String, Value>> for ActionsParameters { + fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> { + Ok(try_opt!( + serde_json::to_value(self)?.as_object(), + ErrorStatus::UnknownError, + "Expected an object" + ) + .clone()) + } +} + +impl ToMarionette<MarionetteCookie> for AddCookieParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteCookie> { + Ok(MarionetteCookie { + name: self.name.clone(), + value: self.value.clone(), + path: self.path.clone(), + domain: self.domain.clone(), + secure: self.secure, + http_only: self.httpOnly, + expiry: match &self.expiry { + Some(date) => Some(date.to_marionette()?), + None => None, + }, + same_site: self.sameSite.clone(), + }) + } +} + +impl ToMarionette<MarionetteDate> for Date { + fn to_marionette(&self) -> WebDriverResult<MarionetteDate> { + Ok(MarionetteDate(self.0)) + } +} + +impl ToMarionette<Map<String, Value>> for GetNamedCookieParameters { + fn to_marionette(&self) -> WebDriverResult<Map<String, Value>> { + Ok(try_opt!( + serde_json::to_value(self)?.as_object(), + ErrorStatus::UnknownError, + "Expected an object" + ) + .clone()) + } +} + +impl ToMarionette<MarionetteUrl> for GetParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteUrl> { + Ok(MarionetteUrl { + url: self.url.clone(), + }) + } +} + +impl ToMarionette<MarionetteScript> for JavascriptCommandParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteScript> { + Ok(MarionetteScript { + script: self.script.clone(), + args: self.args.clone(), + }) + } +} + +impl ToMarionette<MarionetteLocator> for LocatorParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteLocator> { + Ok(MarionetteLocator { + using: self.using.to_marionette()?, + value: self.value.clone(), + }) + } +} + +impl ToMarionette<MarionetteSelector> for LocatorStrategy { + fn to_marionette(&self) -> WebDriverResult<MarionetteSelector> { + use self::LocatorStrategy::*; + match self { + CSSSelector => Ok(MarionetteSelector::Css), + LinkText => Ok(MarionetteSelector::LinkText), + PartialLinkText => Ok(MarionetteSelector::PartialLinkText), + TagName => Ok(MarionetteSelector::TagName), + XPath => Ok(MarionetteSelector::XPath), + } + } +} + +impl ToMarionette<MarionetteNewWindow> for NewWindowParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteNewWindow> { + Ok(MarionetteNewWindow { + type_hint: self.type_hint.clone(), + }) + } +} + +impl ToMarionette<MarionetteKeys> for SendKeysParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteKeys> { + Ok(MarionetteKeys { + text: self.text.clone(), + value: self + .text + .chars() + .map(|x| x.to_string()) + .collect::<Vec<String>>(), + }) + } +} + +impl ToMarionette<MarionetteFrame> for SwitchToFrameParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteFrame> { + Ok(match &self.id { + Some(x) => match x { + FrameId::Short(n) => MarionetteFrame::Index(*n), + FrameId::Element(el) => MarionetteFrame::Element(el.0.clone()), + }, + None => MarionetteFrame::Parent, + }) + } +} + +impl ToMarionette<Window> for SwitchToWindowParameters { + fn to_marionette(&self) -> WebDriverResult<Window> { + Ok(Window { + handle: self.handle.clone(), + }) + } +} + +impl ToMarionette<MarionetteTimeouts> for TimeoutsParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteTimeouts> { + Ok(MarionetteTimeouts { + implicit: self.implicit, + page_load: self.page_load, + script: self.script, + }) + } +} + +impl ToMarionette<MarionetteWebElement> for WebElement { + fn to_marionette(&self) -> WebDriverResult<MarionetteWebElement> { + Ok(MarionetteWebElement { + element: self.to_string(), + }) + } +} + +impl ToMarionette<MarionetteWindowRect> for WindowRectParameters { + fn to_marionette(&self) -> WebDriverResult<MarionetteWindowRect> { + Ok(MarionetteWindowRect { + x: self.x, + y: self.y, + width: self.width, + height: self.height, + }) + } +} diff --git a/testing/geckodriver/src/prefs.rs b/testing/geckodriver/src/prefs.rs new file mode 100644 index 0000000000..4407cbccaa --- /dev/null +++ b/testing/geckodriver/src/prefs.rs @@ -0,0 +1,150 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use mozprofile::preferences::Pref; + +// ALL CHANGES TO THIS FILE MUST HAVE REVIEW FROM A GECKODRIVER PEER! +// +// Please refer to INSTRUCTIONS TO ADD A NEW PREFERENCE in +// remote/shared/RecommendedPreferences.sys.mjs +// +// Note: geckodriver is used out-of-tree with various builds of Firefox. +// Removing a preference from this file will cause regressions, +// so please be careful and get review from a Testing :: geckodriver peer +// before you make any changes to this file. +lazy_static! { + pub static ref DEFAULT: Vec<(&'static str, Pref)> = vec![ + // Make sure Shield doesn't hit the network. + ("app.normandy.api_url", Pref::new("")), + + // Disable Firefox old build background check + ("app.update.checkInstallTime", Pref::new(false)), + + // Disable automatically upgrading Firefox + // + // Note: Possible update tests could reset or flip the value to allow + // updates to be downloaded and applied. + ("app.update.disabledForTesting", Pref::new(true)), + + // Enable the dump function, which sends messages to the system + // console + ("browser.dom.window.dump.enabled", Pref::new(true)), + ("devtools.console.stdout.chrome", Pref::new(true)), + + // Do not restore the last open set of tabs if the browser crashed + ("browser.sessionstore.resume_from_crash", Pref::new(false)), + + // Skip check for default browser on startup + ("browser.shell.checkDefaultBrowser", Pref::new(false)), + + // Do not redirect user when a milestone upgrade of Firefox + // is detected + ("browser.startup.homepage_override.mstone", Pref::new("ignore")), + + // Start with a blank page (about:blank) + ("browser.startup.page", Pref::new(0)), + + // Disable page translations, causing timeouts for wdspec tests in early + // beta. See Bug 1836093. + ("browser.translations.enable", Pref::new(false)), + + // Disable the UI tour + ("browser.uitour.enabled", Pref::new(false)), + + // Do not warn on quitting Firefox + ("browser.warnOnQuit", Pref::new(false)), + + // Defensively disable data reporting systems + ("datareporting.healthreport.documentServerURI", Pref::new("http://%(server)s/dummy/healthreport/")), + ("datareporting.healthreport.logging.consoleEnabled", Pref::new(false)), + ("datareporting.healthreport.service.enabled", Pref::new(false)), + ("datareporting.healthreport.service.firstRun", Pref::new(false)), + ("datareporting.healthreport.uploadEnabled", Pref::new(false)), + + // Do not show datareporting policy notifications which can + // interfere with tests + ("datareporting.policy.dataSubmissionEnabled", Pref::new(false)), + ("datareporting.policy.dataSubmissionPolicyBypassNotification", Pref::new(true)), + + // Disable the ProcessHangMonitor + ("dom.ipc.reportProcessHangs", Pref::new(false)), + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + ("extensions.autoDisableScopes", Pref::new(0)), + ("extensions.enabledScopes", Pref::new(5)), + + // Disable intalling any distribution extensions or add-ons + ("extensions.installDistroAddons", Pref::new(false)), + + // Turn off extension updates so they do not bother tests + ("extensions.update.enabled", Pref::new(false)), + ("extensions.update.notifyUser", Pref::new(false)), + + // Allow the application to have focus even it runs in the + // background + ("focusmanager.testmode", Pref::new(true)), + + // Disable useragent updates + ("general.useragent.updates.enabled", Pref::new(false)), + + // Always use network provider for geolocation tests so we bypass + // the macOS dialog raised by the corelocation provider + ("geo.provider.testing", Pref::new(true)), + + // Do not scan wi-fi + ("geo.wifi.scan", Pref::new(false)), + + // No hang monitor + ("hangmonitor.timeout", Pref::new(0)), + + // Disable idle-daily notifications to avoid expensive operations + // that may cause unexpected test timeouts. + ("idle.lastDailyNotification", Pref::new(-1)), + + // Disable download and usage of OpenH264, and Widevine plugins + ("media.gmp-manager.updateEnabled", Pref::new(false)), + + // Disable the GFX sanity window + ("media.sanity-test.disabled", Pref::new(true)), + + // Do not automatically switch between offline and online + ("network.manage-offline-status", Pref::new(false)), + + // Make sure SNTP requests do not hit the network + ("network.sntp.pools", Pref::new("%(server)s")), + + // Don't do network connections for mitm priming + ("security.certerrors.mitm.priming.enabled", Pref::new(false)), + + // Ensure remote settings do not hit the network + ("services.settings.server", Pref::new("data:,#remote-settings-dummy/v1")), + + // Disable first run pages + ("startup.homepage_welcome_url", Pref::new("about:blank")), + ("startup.homepage_welcome_url.additional", Pref::new("")), + + // asrouter expects a plain object or null + ("browser.newtabpage.activity-stream.asrouter.providers.cfr", Pref::new("null")), + // TODO: Remove once minimum supported Firefox release is 93. + ("browser.newtabpage.activity-stream.asrouter.providers.cfr-fxa", Pref::new("null")), + ("browser.newtabpage.activity-stream.asrouter.providers.snippets", Pref::new("null")), + ("browser.newtabpage.activity-stream.asrouter.providers.message-groups", Pref::new("null")), + ("browser.newtabpage.activity-stream.asrouter.providers.whats-new-panel", Pref::new("null")), + ("browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments", Pref::new("null")), + ("browser.newtabpage.activity-stream.feeds.system.topstories", Pref::new(false)), + ("browser.newtabpage.activity-stream.feeds.snippets", Pref::new(false)), + ("browser.newtabpage.activity-stream.tippyTop.service.endpoint", Pref::new("")), + ("browser.newtabpage.activity-stream.discoverystream.config", Pref::new("[]")), + + // For Activity Stream firstrun page, use an empty string to avoid fetching. + ("browser.newtabpage.activity-stream.fxaccounts.endpoint", Pref::new("")), + + // Prevent starting into safe mode after application crashes + ("toolkit.startup.max_resumed_crashes", Pref::new(-1)), + + // Disable webapp updates. + ("browser.webapps.checkForUpdates", Pref::new(0)), + ]; +} diff --git a/testing/geckodriver/src/test.rs b/testing/geckodriver/src/test.rs new file mode 100644 index 0000000000..e664aadf08 --- /dev/null +++ b/testing/geckodriver/src/test.rs @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +pub fn assert_de<T>(data: &T, json: serde_json::Value) +where + T: std::fmt::Debug, + T: std::cmp::PartialEq, + T: serde::de::DeserializeOwned, +{ + assert_eq!(data, &serde_json::from_value::<T>(json).unwrap()); +} diff --git a/testing/geckodriver/src/tests/profile.zip b/testing/geckodriver/src/tests/profile.zip Binary files differnew file mode 100644 index 0000000000..286b118183 --- /dev/null +++ b/testing/geckodriver/src/tests/profile.zip |