#![cfg(all(feature = "a2a", unix))]
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use protocol_droid::adapters::secondary::a2a::A2aClient;
use protocol_droid::domain::a2a::list_tasks::{ListTasksRequest, ListTasksResponse};
use protocol_droid::domain::a2a::message::{Message, MessageRole, Part, TextPart};
use protocol_droid::domain::a2a::send_message::SendMessageRequest;
use protocol_droid::domain::a2a::send_message::SendMessageResponse;
use protocol_droid::domain::a2a::task::TaskState;
use protocol_droid::domain::a2a::task_lifecycle::CancelTaskRequest;
use protocol_droid::domain::a2a::task_lifecycle::GetTaskRequest;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
fn reference_server_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/a2a_reference_server.mjs")
}
struct ChildGuard {
child: tokio::process::Child,
}
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.child.start_kill();
}
}
fn find_node() -> Option<String> {
let path = std::env::var_os("PATH")?;
let candidates: &[&str] = if cfg!(windows) {
&["node.exe", "node.cmd", "node.bat"]
} else {
&["node"]
};
for directory in std::env::split_paths(&path) {
for candidate in candidates {
let full = directory.join(candidate);
if full.is_file() {
return Some(full.to_string_lossy().into_owned());
}
}
}
None
}
fn reserve_port() -> std::io::Result<u16> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
Ok(port)
}
async fn spawn_reference_server(node: &str) -> Result<(String, ChildGuard), String> {
let port = reserve_port().map_err(|error| format!("reserving a port: {error}"))?;
let server_path = reference_server_path();
if !server_path.is_file() {
return Err(format!(
"the reference server is missing: {}",
server_path.display()
));
}
let mut command = Command::new(node);
command
.arg(&server_path)
.env("PORT", port.to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = command
.spawn()
.map_err(|error| format!("spawning node {}: {error}", server_path.display()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "the child's stderr was not piped".to_owned())?;
let mut reader = BufReader::new(stderr).lines();
let expected_prefix = "listening on ";
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let now = tokio::time::Instant::now();
if now >= deadline {
return Err("the reference server did not start within 5 seconds".to_owned());
}
let remaining = deadline - now;
let line = match tokio::time::timeout(remaining, reader.next_line()).await {
Ok(Ok(Some(line))) => line,
Ok(Ok(None)) => {
return Err("the reference server closed stderr before listening".to_owned());
}
Ok(Err(error)) => return Err(format!("reading stderr: {error}")),
Err(_) => return Err("the reference server did not start within 5 seconds".to_owned()),
};
if let Some(stripped) = line.strip_prefix(expected_prefix) {
let bound_port: u16 = stripped
.trim()
.parse()
.map_err(|error| format!("the listening line is malformed: {error}"))?;
if bound_port != port {
return Err(format!(
"the server bound to {bound_port} but the test reserved {port}"
));
}
let base = format!("http://127.0.0.1:{port}");
return Ok((base, ChildGuard { child }));
}
}
}
fn user_message(text: &str) -> Message {
Message::new(MessageRole::User).with_part(Part::Text(TextPart {
text: text.to_owned(),
metadata: std::collections::BTreeMap::new(),
}))
}
fn assert_field_equals(value: &Value, pointer: &str, expected: &Value) -> Result<(), String> {
let actual = value
.pointer(pointer)
.ok_or_else(|| format!("missing field {pointer}"))?;
if actual != expected {
return Err(format!(
"field {pointer}: expected {expected}, got {actual}"
));
}
Ok(())
}
fn ensure_node() -> Result<String, String> {
find_node().map_or_else(
|| {
eprintln!(
"a2a-js interop: node is not on PATH; the in-tree reference \
server is a JS script and the test is skipped. Install Node 18+ \
to run the interop test."
);
Err("node is not on PATH; skipping".to_owned())
},
Ok,
)
}
#[tokio::test]
async fn card_deserialises_against_the_vanilla_js_server() -> Result<(), Box<dyn std::error::Error>>
{
let node = match ensure_node() {
Ok(node) => node,
Err(_skip) => return Ok(()),
};
let (base, _guard) = spawn_reference_server(&node).await?;
let client = A2aClient::new(&base)?;
let card = client.card().await?;
assert_eq!(card.name.as_ref(), "a2a-reference-server");
Ok(())
}
#[tokio::test]
async fn send_message_round_trips_against_the_vanilla_js_server()
-> Result<(), Box<dyn std::error::Error>> {
let node = match ensure_node() {
Ok(node) => node,
Err(_skip) => return Ok(()),
};
let (base, _guard) = spawn_reference_server(&node).await?;
let client = A2aClient::new(&base)?;
let request = SendMessageRequest::new(user_message("hi"));
let response = client.send_message(request).await?;
let SendMessageResponse::Task(task) = response else {
return Err("the reference server returns a Task, not a Message".into());
};
let value = serde_json::to_value(&task).map_err(|e| format!("to_value: {e}"))?;
assert_field_equals(
&value,
"/status/state",
&Value::String("completed".to_owned()),
)?;
let text = value
.pointer("/status/message/parts/0/text")
.and_then(Value::as_str)
.ok_or("the reference server did not echo the user message")?;
assert!(
text.contains("hi"),
"the response should mention the input: {text}"
);
Ok(())
}
#[tokio::test]
async fn get_task_reads_back_what_send_message_wrote() -> Result<(), Box<dyn std::error::Error>> {
let node = match ensure_node() {
Ok(node) => node,
Err(_skip) => return Ok(()),
};
let (base, _guard) = spawn_reference_server(&node).await?;
let client = A2aClient::new(&base)?;
let request = SendMessageRequest::new(user_message("hi"));
let response = client.send_message(request).await?;
let SendMessageResponse::Task(task) = response else {
return Err("the reference server returns a Task, not a Message".into());
};
let task_id = task.id.to_string();
let get = client
.get_task(GetTaskRequest::new(task_id.clone()))
.await?;
assert_eq!(get.0.id.as_ref(), task_id.as_str());
assert_eq!(get.0.status.state, TaskState::Completed);
Ok(())
}
#[tokio::test]
async fn list_tasks_returns_the_paginated_envelope() -> Result<(), Box<dyn std::error::Error>> {
let node = match ensure_node() {
Ok(node) => node,
Err(_skip) => return Ok(()),
};
let (base, _guard) = spawn_reference_server(&node).await?;
let client = A2aClient::new(&base)?;
let _ = client
.send_message(SendMessageRequest::new(user_message("hi")))
.await?;
let list = client.list_tasks(ListTasksRequest::new()).await?;
let ListTasksResponse {
tasks,
next_page_token,
page_size,
total_size,
} = list;
assert_eq!(total_size, 1);
assert!(
page_size >= 1,
"pageSize must be at least 1, got {page_size}"
);
assert!(
next_page_token.is_empty(),
"the absent signal is the empty string"
);
assert_eq!(tasks.len(), 1);
Ok(())
}
#[tokio::test]
async fn cancel_task_flips_state_to_cancelled() -> Result<(), Box<dyn std::error::Error>> {
let node = match ensure_node() {
Ok(node) => node,
Err(_skip) => return Ok(()),
};
let (base, _guard) = spawn_reference_server(&node).await?;
let client = A2aClient::new(&base)?;
let request = SendMessageRequest::new(user_message("hi"));
let response = client.send_message(request).await?;
let SendMessageResponse::Task(task) = response else {
return Err("the reference server returns a Task, not a Message".into());
};
let task_id = task.id.to_string();
let cancelled = client
.cancel_task(CancelTaskRequest::new(task_id.clone()))
.await?;
assert_eq!(cancelled.0.id.as_ref(), task_id.as_str());
assert_eq!(cancelled.0.status.state, TaskState::Cancelled);
Ok(())
}