mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 15:03:57 +00:00
test(e2e): deep chat-harness coverage + streaming mock LLM + rust-e2e Linux lane (#1892)
This commit is contained in:
@@ -575,6 +575,19 @@ pub async fn invalidate_thread_sessions(thread_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the IN_FLIGHT map for the test-support introspection RPC.
|
||||
///
|
||||
/// Returned as `(map_key, request_id)` pairs. Not intended for any
|
||||
/// production caller — release builds reach this via the bearer-gated
|
||||
/// `/rpc` endpoint only, and the per-launch token file is debug-only.
|
||||
pub async fn in_flight_entries_for_test() -> Vec<(String, String)> {
|
||||
let guard = IN_FLIGHT.lock().await;
|
||||
guard
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.request_id.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn cancel_chat(client_id: &str, thread_id: &str) -> Result<Option<String>, String> {
|
||||
let client_id = client_id.trim();
|
||||
let thread_id = thread_id.trim();
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Read-only introspection RPCs for E2E specs.
|
||||
//!
|
||||
//! These mirror the access patterns specs need to verify that "the UI did
|
||||
//! something" actually flowed all the way through to disk + the in-process
|
||||
//! Rust state. They are intentionally narrow and read-only — no writes,
|
||||
//! no side effects beyond a single config/file read.
|
||||
//!
|
||||
//! Like `test_reset`, the bearer-token requirement on `/rpc` keeps these
|
||||
//! out of release-build reach (the per-launch token file is only written
|
||||
//! in debug builds).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::openhuman::channels::providers::web::in_flight_entries_for_test;
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
/// Maximum bytes returned by `read_workspace_file`. Specs that need bigger
|
||||
/// reads should chunk through `list_workspace_files` + multiple reads.
|
||||
const READ_FILE_MAX_BYTES: u64 = 1024 * 1024; // 1 MiB
|
||||
|
||||
/// Maximum recursion depth for `list_workspace_files`.
|
||||
const LIST_MAX_DEPTH: u32 = 6;
|
||||
|
||||
/// Reject any relative path containing a `..` component or that resolves
|
||||
/// outside the workspace root. Returns the joined absolute path on success.
|
||||
fn resolve_workspace_relative(workspace: &Path, rel: &str) -> Result<PathBuf, String> {
|
||||
let trimmed = rel.trim_start_matches('/');
|
||||
let candidate = workspace.join(trimmed);
|
||||
let canonical_root = workspace
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace.to_path_buf());
|
||||
let canonical_candidate = candidate
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| candidate.clone());
|
||||
if !canonical_candidate.starts_with(&canonical_root) {
|
||||
return Err(format!(
|
||||
"rel_path {rel:?} escapes workspace root {}",
|
||||
workspace.display()
|
||||
));
|
||||
}
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
async fn current_workspace_dir() -> Result<PathBuf, String> {
|
||||
let config = Config::load_or_init()
|
||||
.await
|
||||
.map_err(|e| format!("load config: {e}"))?;
|
||||
Ok(config.workspace_dir.clone())
|
||||
}
|
||||
|
||||
// ── workspace_root ────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WorkspaceRoot {
|
||||
pub path: String,
|
||||
pub exists: bool,
|
||||
}
|
||||
|
||||
pub async fn workspace_root() -> Result<RpcOutcome<WorkspaceRoot>, String> {
|
||||
let dir = current_workspace_dir().await?;
|
||||
let exists = fs::try_exists(&dir).await.unwrap_or(false);
|
||||
Ok(RpcOutcome::single_log(
|
||||
WorkspaceRoot {
|
||||
path: dir.display().to_string(),
|
||||
exists,
|
||||
},
|
||||
format!("workspace_root: {} (exists={exists})", dir.display()),
|
||||
))
|
||||
}
|
||||
|
||||
// ── list_workspace_files ──────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListEntry {
|
||||
pub rel_path: String,
|
||||
pub size: u64,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListResult {
|
||||
pub root: String,
|
||||
pub entries: Vec<ListEntry>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
async fn walk_dir(
|
||||
root: &Path,
|
||||
start: &Path,
|
||||
max_depth: u32,
|
||||
out: &mut Vec<ListEntry>,
|
||||
limit: usize,
|
||||
) -> Result<bool, String> {
|
||||
// Explicit BFS stack avoids needing the async_recursion crate.
|
||||
let mut stack: Vec<(PathBuf, u32)> = vec![(start.to_path_buf(), 0)];
|
||||
while let Some((cur, depth)) = stack.pop() {
|
||||
if depth > max_depth {
|
||||
continue;
|
||||
}
|
||||
let mut rd = match fs::read_dir(&cur).await {
|
||||
Ok(rd) => rd,
|
||||
Err(_) => continue,
|
||||
};
|
||||
while let Some(entry) = rd
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| format!("read_dir: {e}"))?
|
||||
{
|
||||
if out.len() >= limit {
|
||||
return Ok(true);
|
||||
}
|
||||
let path = entry.path();
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| path.display().to_string());
|
||||
// `symlink_metadata` does NOT follow links — a symlink
|
||||
// inside the workspace that points outside (or anywhere
|
||||
// else) would otherwise be reported, and if it resolved to
|
||||
// a directory we'd recurse into it. That would break the
|
||||
// workspace-only guarantee the RPC promises.
|
||||
let meta = match fs::symlink_metadata(&path).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let is_dir = meta.is_dir();
|
||||
out.push(ListEntry {
|
||||
rel_path: rel,
|
||||
size: if is_dir { 0 } else { meta.len() },
|
||||
is_dir,
|
||||
});
|
||||
if is_dir && depth + 1 <= max_depth {
|
||||
stack.push((path, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn list_workspace_files(
|
||||
rel_root: Option<String>,
|
||||
max_depth: Option<u32>,
|
||||
) -> Result<RpcOutcome<ListResult>, String> {
|
||||
let workspace = current_workspace_dir().await?;
|
||||
let root = match rel_root.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(r) => resolve_workspace_relative(&workspace, r)?,
|
||||
None => workspace.clone(),
|
||||
};
|
||||
let depth = max_depth.unwrap_or(2).min(LIST_MAX_DEPTH);
|
||||
let mut entries = Vec::new();
|
||||
let truncated = walk_dir(&root, &root, depth, &mut entries, 2_000).await?;
|
||||
|
||||
Ok(RpcOutcome::single_log(
|
||||
ListResult {
|
||||
root: root.display().to_string(),
|
||||
entries: entries.iter().cloned().collect(),
|
||||
truncated,
|
||||
},
|
||||
format!(
|
||||
"listed {} entries (depth={depth}, truncated={truncated}) under {}",
|
||||
entries.len(),
|
||||
root.display()
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
// `ListEntry` is `Clone`-able for the log line summarisation above.
|
||||
impl Clone for ListEntry {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
rel_path: self.rel_path.clone(),
|
||||
size: self.size,
|
||||
is_dir: self.is_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── read_workspace_file ───────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReadFileResult {
|
||||
pub rel_path: String,
|
||||
pub size_on_disk: u64,
|
||||
pub returned_bytes: u64,
|
||||
pub truncated: bool,
|
||||
pub content_utf8: String,
|
||||
}
|
||||
|
||||
pub async fn read_workspace_file(
|
||||
rel_path: String,
|
||||
max_bytes: Option<u64>,
|
||||
) -> Result<RpcOutcome<ReadFileResult>, String> {
|
||||
let workspace = current_workspace_dir().await?;
|
||||
let abs = resolve_workspace_relative(&workspace, &rel_path)?;
|
||||
let meta = fs::metadata(&abs)
|
||||
.await
|
||||
.map_err(|e| format!("stat {}: {e}", abs.display()))?;
|
||||
if meta.is_dir() {
|
||||
return Err(format!("read_workspace_file: {rel_path} is a directory"));
|
||||
}
|
||||
let cap = max_bytes
|
||||
.unwrap_or(READ_FILE_MAX_BYTES)
|
||||
.min(READ_FILE_MAX_BYTES);
|
||||
let raw = fs::read(&abs)
|
||||
.await
|
||||
.map_err(|e| format!("read {}: {e}", abs.display()))?;
|
||||
let size_on_disk = raw.len() as u64;
|
||||
let truncated = size_on_disk > cap;
|
||||
let returned = if truncated {
|
||||
raw[..cap as usize].to_vec()
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
// `returned_bytes` is the raw byte count read from disk before
|
||||
// lossy UTF-8 conversion — `from_utf8_lossy` substitutes U+FFFD
|
||||
// for invalid sequences, which can change the byte length. Specs
|
||||
// assert against this value to verify byte-accurate truncation.
|
||||
let returned_bytes = returned.len() as u64;
|
||||
let content_utf8 = String::from_utf8_lossy(&returned).into_owned();
|
||||
Ok(RpcOutcome::single_log(
|
||||
ReadFileResult {
|
||||
rel_path: rel_path.clone(),
|
||||
size_on_disk,
|
||||
returned_bytes,
|
||||
truncated,
|
||||
content_utf8,
|
||||
},
|
||||
format!(
|
||||
"read_workspace_file {rel_path}: size_on_disk={size_on_disk}, truncated={truncated}"
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
// ── in_flight_chats ───────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InFlightEntryView {
|
||||
pub key: String,
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InFlightResult {
|
||||
pub entries: Vec<InFlightEntryView>,
|
||||
}
|
||||
|
||||
pub async fn in_flight_chats() -> Result<RpcOutcome<InFlightResult>, String> {
|
||||
let entries: Vec<InFlightEntryView> = in_flight_entries_for_test()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|(key, request_id)| InFlightEntryView { key, request_id })
|
||||
.collect();
|
||||
let count = entries.len();
|
||||
Ok(RpcOutcome::single_log(
|
||||
InFlightResult { entries },
|
||||
format!("in_flight_chats: {count} entries"),
|
||||
))
|
||||
}
|
||||
@@ -5,7 +5,11 @@
|
||||
//! the process. As new domains add persistent state, extend `rpc::reset` to
|
||||
//! wipe them too — every new domain that survives a `test_reset` is a leak
|
||||
//! that will make specs interfere with each other.
|
||||
//!
|
||||
//! `introspect` adds read-only RPCs that let specs verify state on disk
|
||||
//! and in the live process (workspace tree, files, IN_FLIGHT chat map).
|
||||
|
||||
pub mod introspect;
|
||||
pub mod rpc;
|
||||
mod schemas;
|
||||
|
||||
|
||||
@@ -2,18 +2,42 @@ use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::test_support::rpc;
|
||||
use crate::openhuman::test_support::{introspect, rpc};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![schemas("reset")]
|
||||
vec![
|
||||
schemas("reset"),
|
||||
schemas("workspace_root"),
|
||||
schemas("list_workspace_files"),
|
||||
schemas("read_workspace_file"),
|
||||
schemas("in_flight_chats"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![RegisteredController {
|
||||
schema: schemas("reset"),
|
||||
handler: handle_reset,
|
||||
}]
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schemas("reset"),
|
||||
handler: handle_reset,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("workspace_root"),
|
||||
handler: handle_workspace_root,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("list_workspace_files"),
|
||||
handler: handle_list_workspace_files,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("read_workspace_file"),
|
||||
handler: handle_read_workspace_file,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("in_flight_chats"),
|
||||
handler: handle_in_flight_chats,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
@@ -59,8 +83,140 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"workspace_root" => ControllerSchema {
|
||||
namespace: "test_support",
|
||||
function: "workspace_root",
|
||||
description: "Return the active workspace_dir path and whether it exists on disk.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Object {
|
||||
fields: vec![
|
||||
FieldSchema {
|
||||
name: "path",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Absolute workspace path.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "exists",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Whether the workspace dir exists on disk right now.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
comment: "Workspace root metadata.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"list_workspace_files" => ControllerSchema {
|
||||
namespace: "test_support",
|
||||
function: "list_workspace_files",
|
||||
description:
|
||||
"Recursively list files under the workspace (or a sub-path). Capped at depth 6 \
|
||||
and 2000 entries. Returns relative paths plus byte size and is_dir flag.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "rel_root",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Optional workspace-relative sub-path to list. Defaults to the whole workspace.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "max_depth",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Optional max recursion depth (capped at 6). Defaults to 2.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Object {
|
||||
fields: vec![
|
||||
FieldSchema {
|
||||
name: "root",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Absolute root path that was walked.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "truncated",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the 2000-entry cap was hit.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
comment: "Listing result (entries omitted from schema for brevity).",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"read_workspace_file" => ControllerSchema {
|
||||
namespace: "test_support",
|
||||
function: "read_workspace_file",
|
||||
description:
|
||||
"Read a workspace-relative file (lossy UTF-8). Capped at 1 MiB. Rejects paths \
|
||||
that escape the workspace root via `..` etc.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "rel_path",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Workspace-relative file path.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "max_bytes",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Optional read cap (clamped at 1 MiB).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Object {
|
||||
fields: vec![
|
||||
FieldSchema {
|
||||
name: "content_utf8",
|
||||
ty: TypeSchema::String,
|
||||
comment: "File contents decoded with lossy UTF-8.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "truncated",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the file exceeded max_bytes.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
comment: "File contents.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"in_flight_chats" => ControllerSchema {
|
||||
namespace: "test_support",
|
||||
function: "in_flight_chats",
|
||||
description:
|
||||
"Snapshot the IN_FLIGHT chat map: which (client_id, thread_id) pairs are \
|
||||
currently running a chat turn and their request_id.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Object {
|
||||
fields: vec![FieldSchema {
|
||||
name: "entries",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Array of { key, request_id } entries (serialized).",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
comment: "Snapshot of running chats.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_other => ControllerSchema {
|
||||
namespace: "test",
|
||||
namespace: "test_support",
|
||||
function: "unknown",
|
||||
description: "Unknown test-support controller function.",
|
||||
inputs: vec![FieldSchema {
|
||||
@@ -83,6 +239,40 @@ fn handle_reset(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { to_json(rpc::reset().await?) })
|
||||
}
|
||||
|
||||
fn handle_workspace_root(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { to_json(introspect::workspace_root().await?) })
|
||||
}
|
||||
|
||||
fn handle_list_workspace_files(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let rel_root = params
|
||||
.get("rel_root")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let max_depth = params
|
||||
.get("max_depth")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|d| d as u32);
|
||||
to_json(introspect::list_workspace_files(rel_root, max_depth).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_read_workspace_file(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let rel_path = params
|
||||
.get("rel_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "rel_path is required".to_string())?
|
||||
.to_string();
|
||||
let max_bytes = params.get("max_bytes").and_then(|v| v.as_u64());
|
||||
to_json(introspect::read_workspace_file(rel_path, max_bytes).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_in_flight_chats(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { to_json(introspect::in_flight_chats().await?) })
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user