Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/tauri-driver-send-keys-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"tauri-driver": patch:bug
---

Ensure Element Send Keys requests carry the W3C `text` field by synthesizing it from the legacy JSON Wire Protocol `value` when absent.
WebKitWebDriver 2.52+ rejects bodies without `text` ("Missing text parameter").
6 changes: 6 additions & 0 deletions .changes/tauri-driver-strip-bidi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"tauri-driver": patch:bug
---

Strip the WebDriver BiDi `webSocketUrl` capability from `alwaysMatch` and every `firstMatch` entry before forwarding a new session to the native driver.
Clients like WebdriverIO 9+ auto-inject it, but tauri-driver does not proxy the BiDi websocket and native drivers without BiDi support reject such sessions.
6 changes: 6 additions & 0 deletions .changes/tauri-driver-wait-native.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"tauri-driver": patch:bug
---

Wait until the native WebDriver server accepts connections before serving clients, instead of accepting connections that cannot be proxied yet.
Fails fast when the native driver exits during startup or does not come up within 30 seconds.
6 changes: 6 additions & 0 deletions crates/tauri-driver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ _note: the (probably) items haven't been proof-of-concept'd yet, and if it is
not possible to use the listed native webdriver, then a custom implementation
will be used that wraps around [wry]._

## WebDriver BiDi

`tauri-driver` speaks classic WebDriver and does not proxy the [WebDriver BiDi] websocket.
The BiDi `webSocketUrl` capability (auto-injected by clients like WebdriverIO 9+) is stripped before forwarding the session to the native driver, so clients fall back to classic WebDriver automatically.
With WebdriverIO you can also opt out explicitly by setting `'wdio:enforceWebDriverClassic': true` in your capabilities.

## Installation

You can install tauri-driver using Cargo:
Expand Down
49 changes: 48 additions & 1 deletion crates/tauri-driver/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,60 @@ fn main() {

// start the native webdriver on the port specified in args
let mut driver = webdriver::native(&args);
let driver = driver
let mut driver = driver
.spawn()
.expect("error while running native webdriver");

// wait until the native webdriver accepts connections, so that we never
// accept client connections we cannot serve yet
if let Err(e) = wait_for_native_driver(
&mut driver,
&args.native_host,
args.native_port,
std::time::Duration::from_secs(30),
) {
eprintln!("error while waiting for the native webdriver to start: {e}");
let _ = driver.kill();
std::process::exit(1);
}

// start our webdriver intermediary node
if let Err(e) = server::run(args, driver) {
eprintln!("error while running server: {e}");
std::process::exit(1);
}
}

#[cfg(any(target_os = "linux", windows))]
fn wait_for_native_driver(
driver: &mut std::process::Child,
host: &str,
port: u16,
timeout: std::time::Duration,
) -> std::io::Result<()> {
use std::io::{Error, ErrorKind};

let start = std::time::Instant::now();
loop {
if let Some(status) = driver.try_wait()? {
return Err(Error::other(format!(
"native webdriver exited before accepting connections: {status}"
)));
}

match std::net::TcpStream::connect((host, port)) {
Ok(_) => return Ok(()),
Err(e) if matches!(e.kind(), ErrorKind::ConnectionRefused | ErrorKind::TimedOut) => {}
Err(e) => return Err(e),
}

if start.elapsed() >= timeout {
return Err(Error::new(
ErrorKind::TimedOut,
format!("native webdriver did not accept connections on {host}:{port} within {timeout:?}"),
));
}

std::thread::sleep(std::time::Duration::from_millis(100));
}
}
166 changes: 166 additions & 0 deletions crates/tauri-driver/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ use tokio::net::TcpListener;

const TAURI_OPTIONS: &str = "tauri:options";

// WebDriver BiDi capability auto-injected by clients like WebdriverIO 9+.
// tauri-driver does not proxy the BiDi websocket and native drivers without
// BiDi support (WebKitGTK < 2.46) reject sessions requesting it, so it is
// stripped before forwarding. BiDi is additive; clients fall back to classic
// WebDriver.
const BIDI_CAPABILITY: &str = "webSocketUrl";

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TauriOptions {
Expand Down Expand Up @@ -90,6 +97,18 @@ async fn handle(
let bytes = serde_json::to_vec(&json)?;
parts.headers.insert(CONTENT_LENGTH, bytes.len().into());

Request::from_parts(parts, Full::new(bytes.into()))
} else if is_element_send_keys(req.method(), req.uri().path()) {
let (mut parts, body) = req.into_parts();

let mut bytes = body.collect().await?.to_bytes().to_vec();
if let Ok(mut json) = serde_json::from_slice::<Value>(&bytes) {
if ensure_send_keys_text(&mut json) {
bytes = serde_json::to_vec(&json)?;
}
}
parts.headers.insert(CONTENT_LENGTH, bytes.len().into());

Request::from_parts(parts, Full::new(bytes.into()))
} else {
let (parts, body) = req.into_parts();
Expand Down Expand Up @@ -152,6 +171,8 @@ fn map_capabilities(mut json: Value) -> Value {
}
}
}

strip_bidi_capabilities(capabilities);
}

if let Some(native) = native {
Expand All @@ -166,6 +187,72 @@ fn map_capabilities(mut json: Value) -> Value {
json
}

/// Removes WebDriver BiDi capabilities the native driver may not honor,
/// from both `alwaysMatch` and every `firstMatch` entry.
fn strip_bidi_capabilities(capabilities: &mut Value) {
if let Some(always_match) = capabilities
.get_mut("alwaysMatch")
.and_then(Value::as_object_mut)
{
always_match.remove(BIDI_CAPABILITY);
}

if let Some(first_match) = capabilities
.get_mut("firstMatch")
.and_then(Value::as_array_mut)
{
for entry in first_match.iter_mut() {
if let Some(entry) = entry.as_object_mut() {
entry.remove(BIDI_CAPABILITY);
}
}
}
}

/// `true` for the Element Send Keys endpoint (`POST /session/{id}/element/{id}/value`).
fn is_element_send_keys(method: &Method, path: &str) -> bool {
method == Method::POST && {
let mut segments = path.trim_start_matches('/').split('/');
matches!(
(
segments.next(),
segments.next(),
segments.next(),
segments.next(),
segments.next(),
segments.next(),
),
(
Some("session"),
Some(_),
Some("element"),
Some(_),
Some("value"),
None
)
)
}
}

/// Adds the W3C `text` field to an Element Send Keys body that only carries
/// the legacy JSON Wire Protocol `value`. WebKitWebDriver 2.52+ rejects
/// bodies without `text`. Returns `true` if the body was modified.
fn ensure_send_keys_text(json: &mut Value) -> bool {
let Some(obj) = json.as_object_mut() else {
return false;
};
if obj.contains_key("text") {
return false;
}
let text = match obj.get("value") {
Some(Value::Array(chunks)) => chunks.iter().filter_map(Value::as_str).collect::<String>(),
Some(Value::String(s)) => s.clone(),
_ => return false,
};
obj.insert("text".into(), Value::String(text));
true
}

#[tokio::main(flavor = "current_thread")]
pub async fn run(args: Args, mut _driver: Child) -> Result<(), Error> {
#[cfg(unix)]
Expand Down Expand Up @@ -243,3 +330,82 @@ pub async fn run(args: Args, mut _driver: Child) -> Result<(), Error> {

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn strips_bidi_from_always_match_and_first_match() {
let json = json!({
"capabilities": {
"alwaysMatch": { "browserName": "wry", "webSocketUrl": true },
"firstMatch": [{ "webSocketUrl": true }, { "browserName": "wry" }]
}
});

let mapped = map_capabilities(json);
let capabilities = &mapped["capabilities"];

assert!(capabilities["alwaysMatch"].get("webSocketUrl").is_none());
assert_eq!(capabilities["alwaysMatch"]["browserName"], "wry");
for entry in capabilities["firstMatch"].as_array().unwrap() {
assert!(entry.get("webSocketUrl").is_none());
}
}

#[test]
fn strip_ignores_non_object_entries() {
let mut capabilities = json!({
"alwaysMatch": true,
"firstMatch": [1, "x", { "webSocketUrl": true }]
});
strip_bidi_capabilities(&mut capabilities);
assert!(capabilities["firstMatch"][2].get("webSocketUrl").is_none());
}

#[test]
fn matches_element_send_keys_endpoint() {
assert!(is_element_send_keys(
&Method::POST,
"/session/abc/element/def/value"
));
assert!(!is_element_send_keys(
&Method::GET,
"/session/abc/element/def/value"
));
assert!(!is_element_send_keys(
&Method::POST,
"/session/abc/element/def/value/extra"
));
assert!(!is_element_send_keys(&Method::POST, "/session/abc/value"));
assert!(!is_element_send_keys(&Method::POST, "/status"));
}

#[test]
fn send_keys_text_synthesis() {
// W3C body stays untouched
let mut body = json!({ "text": "hello" });
assert!(!ensure_send_keys_text(&mut body));
assert_eq!(body["text"], "hello");

// legacy value array gets a text field
let mut body = json!({ "value": ["h", "i"] });
assert!(ensure_send_keys_text(&mut body));
assert_eq!(body["text"], "hi");
assert_eq!(body["value"], json!(["h", "i"]));

// both fields present stays untouched
let mut body = json!({ "text": "hi", "value": ["h", "i"] });
assert!(!ensure_send_keys_text(&mut body));

// value as plain string
let mut body = json!({ "value": "hello" });
assert!(ensure_send_keys_text(&mut body));
assert_eq!(body["text"], "hello");

// nothing usable
assert!(!ensure_send_keys_text(&mut json!({})));
assert!(!ensure_send_keys_text(&mut json!([1, 2])));
}
}
Loading