mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
163 lines
7.1 KiB
Rust
163 lines
7.1 KiB
Rust
//! End-to-end test for the webview_apis bridge.
|
|
//!
|
|
//! Proves the full chain without the Tauri shell:
|
|
//!
|
|
//! ```text
|
|
//! client::request ← core-side code we ship
|
|
//! → ws://127.0.0.1:$OPENHUMAN_WEBVIEW_APIS_PORT
|
|
//! → mock WS server (this test) ← stands in for Tauri
|
|
//! → JSON response
|
|
//! → decoded back into typed GmailLabel Vec
|
|
//! ```
|
|
//!
|
|
//! Tests are serial because they all mutate the `OPENHUMAN_WEBVIEW_APIS_PORT`
|
|
//! env var and share the lazy global `CLIENT` inside
|
|
//! `openhuman_core::openhuman::webview_apis::client`.
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use serde_json::{json, Value};
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::Mutex;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
|
|
use openhuman_core::openhuman::webview_apis::{client, types::GmailLabel};
|
|
|
|
/// The webview_apis client caches its WebSocket connection (and the
|
|
/// reader/writer tasks that service it) in a process-global `OnceLock`.
|
|
/// Those tasks are pinned to the tokio runtime that opens the
|
|
/// connection first, so running two `#[tokio::test]`s in a row races
|
|
/// runtime teardown against the cached reader and produces the 15s
|
|
/// `[webview_apis] gmail.list_labels: timed out after 15s` panic we
|
|
/// saw in CI. We fuse the scenarios into one async test and guard
|
|
/// against incidental parallel `client::request` callers with a lock.
|
|
static MOCK_SERVER_PORT: once_cell::sync::Lazy<std::sync::Mutex<Option<u16>>> =
|
|
once_cell::sync::Lazy::new(|| std::sync::Mutex::new(None));
|
|
static REQUEST_LOCK: once_cell::sync::Lazy<Mutex<()>> =
|
|
once_cell::sync::Lazy::new(|| Mutex::new(()));
|
|
|
|
async fn ensure_mock_server() -> u16 {
|
|
let mut guard = MOCK_SERVER_PORT.lock().unwrap();
|
|
if let Some(port) = *guard {
|
|
return port;
|
|
}
|
|
let listener = TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
|
|
.await
|
|
.expect("bind");
|
|
let port = listener.local_addr().unwrap().port();
|
|
std::env::set_var("OPENHUMAN_WEBVIEW_APIS_PORT", port.to_string());
|
|
*guard = Some(port);
|
|
tokio::spawn(async move {
|
|
loop {
|
|
tracing::debug!("[webview_apis_bridge:test] waiting for mock ws client");
|
|
let (stream, _peer) = match listener.accept().await {
|
|
Ok(v) => v,
|
|
Err(_) => continue,
|
|
};
|
|
tokio::spawn(async move {
|
|
let ws = match tokio_tungstenite::accept_async(stream).await {
|
|
Ok(w) => w,
|
|
Err(_) => return,
|
|
};
|
|
let (mut sink, mut stream) = ws.split();
|
|
tracing::debug!("[webview_apis_bridge:test] mock ws client connected");
|
|
while let Some(msg) = stream.next().await {
|
|
match msg {
|
|
Ok(Message::Text(text)) => {
|
|
let req: Value = serde_json::from_str(&text).unwrap();
|
|
let id = req["id"].as_str().unwrap().to_string();
|
|
let method = req["method"].as_str().unwrap().to_string();
|
|
let redacted_id = if id.len() <= 4 {
|
|
"***".to_string()
|
|
} else {
|
|
format!("***{}", &id[id.len() - 4..])
|
|
};
|
|
tracing::debug!(
|
|
id = %redacted_id,
|
|
method = %method,
|
|
"[webview_apis_bridge:test] handling text rpc message"
|
|
);
|
|
let resp = match method.as_str() {
|
|
"gmail.list_labels" => json!({
|
|
"kind": "response",
|
|
"id": id,
|
|
"ok": true,
|
|
"result": [
|
|
{"id": "INBOX", "name": "Inbox", "kind": "system", "unread": 3},
|
|
{"id": "Receipts", "name": "Receipts", "kind": "user", "unread": null}
|
|
],
|
|
}),
|
|
"gmail.trash" => json!({
|
|
"kind": "response",
|
|
"id": id,
|
|
"ok": false,
|
|
"error": "simulated failure from mock bridge",
|
|
}),
|
|
_ => json!({
|
|
"kind": "response",
|
|
"id": id,
|
|
"ok": false,
|
|
"error": format!("mock bridge: unhandled method '{method}'"),
|
|
}),
|
|
};
|
|
if sink.send(Message::Text(resp.to_string())).await.is_err() {
|
|
tracing::warn!(
|
|
id = %redacted_id,
|
|
method = %method,
|
|
"[webview_apis_bridge:test] failed to send mock response"
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
Ok(Message::Close(_)) => {
|
|
tracing::debug!("[webview_apis_bridge:test] client closed connection");
|
|
break;
|
|
}
|
|
Ok(_) => {
|
|
tracing::debug!(
|
|
"[webview_apis_bridge:test] ignoring non-text ws message"
|
|
);
|
|
continue;
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
error = %err,
|
|
"[webview_apis_bridge:test] websocket receive error"
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
port
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn request_round_trips_and_surfaces_errors_through_mock_server() {
|
|
let _request_guard = REQUEST_LOCK.lock().await;
|
|
let _port = ensure_mock_server().await;
|
|
let labels: Vec<GmailLabel> = client::request(
|
|
"gmail.list_labels",
|
|
serde_json::from_value(json!({"account_id": "gmail"})).unwrap(),
|
|
)
|
|
.await
|
|
.expect("mock bridge call");
|
|
assert_eq!(labels.len(), 2);
|
|
assert_eq!(labels[0].id, "INBOX");
|
|
assert_eq!(labels[0].unread, Some(3));
|
|
assert_eq!(labels[1].kind, "user");
|
|
let err: Result<Vec<GmailLabel>, String> = client::request(
|
|
"gmail.trash",
|
|
serde_json::from_value(json!({"account_id": "gmail", "message_id": "m1"})).unwrap(),
|
|
)
|
|
.await;
|
|
let e = err.expect_err("expected bridge-side error");
|
|
assert!(
|
|
e.contains("simulated failure from mock bridge"),
|
|
"unexpected error: {e}"
|
|
);
|
|
}
|