mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
357 lines
12 KiB
Rust
357 lines
12 KiB
Rust
//! Round 18 raw/E2E coverage for local inference ops and Piper installer branches.
|
|
//!
|
|
//! This suite uses temp workspaces, temp PATH scripts, and loopback HTTP mocks only.
|
|
//! It must not call host Ollama, MLX, Python, Piper, or model binaries.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use axum::body::Body;
|
|
use axum::extract::State;
|
|
use axum::http::{Response, StatusCode};
|
|
use axum::response::IntoResponse;
|
|
use axum::routing::get;
|
|
use axum::Router;
|
|
use flate2::write::GzEncoder;
|
|
use flate2::Compression;
|
|
use openhuman_core::core::all::RegisteredController;
|
|
use openhuman_core::openhuman::config::Config;
|
|
use openhuman_core::openhuman::inference::local::{
|
|
all_local_inference_registered_controllers, local_ai_transcribe_bytes,
|
|
};
|
|
use serde_json::{json, Value};
|
|
use tempfile::{tempdir, TempDir};
|
|
|
|
#[derive(Clone)]
|
|
struct PiperMockState {
|
|
requests: Arc<Mutex<Vec<String>>>,
|
|
mode: Arc<Mutex<PiperMockMode>>,
|
|
archive: Arc<Vec<u8>>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum PiperMockMode {
|
|
Valid,
|
|
SmallVoice,
|
|
InvalidArchive,
|
|
}
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
previous: Option<std::ffi::OsString>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
|
|
let previous = std::env::var_os(key);
|
|
// SAFETY: validation runs this integration test with --test-threads=1.
|
|
unsafe { std::env::set_var(key, value) };
|
|
Self { key, previous }
|
|
}
|
|
|
|
fn unset(key: &'static str) -> Self {
|
|
let previous = std::env::var_os(key);
|
|
// SAFETY: validation runs this integration test with --test-threads=1.
|
|
unsafe { std::env::remove_var(key) };
|
|
Self { key, previous }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
match &self.previous {
|
|
Some(value) => {
|
|
// SAFETY: validation runs this integration test with --test-threads=1.
|
|
unsafe { std::env::set_var(self.key, value) }
|
|
}
|
|
None => {
|
|
// SAFETY: validation runs this integration test with --test-threads=1.
|
|
unsafe { std::env::remove_var(self.key) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
ENV_LOCK
|
|
.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn piper_controller_installs_skips_existing_and_records_failures_from_mock_downloads() {
|
|
let _lock = env_lock();
|
|
let (base, state) = serve_piper_mock().await;
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut config = temp_config(&tmp);
|
|
config.local_ai.runtime_enabled = false;
|
|
config.local_ai.tts_voice_id = "en_US-lessac-medium".to_string();
|
|
config.save().await.expect("save config");
|
|
|
|
let scripts = tempdir().expect("scripts");
|
|
write_stub_script(scripts.path(), "ollama", "#!/bin/sh\nexit 42\n");
|
|
write_stub_script(scripts.path(), "python", "#!/bin/sh\nexit 42\n");
|
|
write_stub_script(scripts.path(), "python3", "#!/bin/sh\nexit 42\n");
|
|
write_stub_script(scripts.path(), "mlx_lm.generate", "#!/bin/sh\nexit 42\n");
|
|
write_stub_script(scripts.path(), "piper", "#!/bin/sh\nexit 42\n");
|
|
|
|
let _path = EnvVarGuard::set("PATH", scripts.path());
|
|
let _workspace = EnvVarGuard::set("OPENHUMAN_WORKSPACE", tmp.path().join(".openhuman"));
|
|
let _release = EnvVarGuard::set("OPENHUMAN_PIPER_RELEASE_BASE_URL", &base);
|
|
let _voices = EnvVarGuard::set("OPENHUMAN_PIPER_VOICES_BASE_URL", format!("{base}/voices"));
|
|
let _ollama_bin = EnvVarGuard::unset("OLLAMA_BIN");
|
|
let _piper_bin = EnvVarGuard::unset("PIPER_BIN");
|
|
let _whisper_bin = EnvVarGuard::unset("WHISPER_BIN");
|
|
|
|
let controllers = all_local_inference_registered_controllers();
|
|
let install = controller(&controllers, "install_piper");
|
|
let status = controller(&controllers, "piper_install_status");
|
|
|
|
#[cfg(not(windows))]
|
|
{
|
|
set_mode(&state, PiperMockMode::Valid);
|
|
let queued = call(
|
|
install,
|
|
json!({"voice_id": "en_US-lessac-medium", "force": true}),
|
|
)
|
|
.await
|
|
.expect("queue install");
|
|
assert_eq!(queued["state"], "installing");
|
|
|
|
let installed = wait_for_piper_state(status, "installed").await;
|
|
assert_eq!(installed["progress"], 100);
|
|
assert_eq!(installed["stage"], "install complete");
|
|
let piper_bin = tmp.path().join(".openhuman/bin/piper/piper/piper");
|
|
assert!(piper_bin.is_file(), "workspace piper binary extracted");
|
|
|
|
call(
|
|
install,
|
|
json!({"voice_id": "en_US-lessac-medium", "force": false}),
|
|
)
|
|
.await
|
|
.expect("queue skip");
|
|
let skipped = wait_for_piper_stage(status, "already installed").await;
|
|
assert_eq!(skipped["state"], "installed");
|
|
}
|
|
|
|
set_mode(&state, PiperMockMode::SmallVoice);
|
|
call(
|
|
install,
|
|
json!({"voice_id": "en_US-lessac-smallfail-medium", "force": true}),
|
|
)
|
|
.await
|
|
.expect("queue small voice failure");
|
|
let failed = wait_for_piper_state(status, "error").await;
|
|
assert!(failed["error_detail"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("downloaded payload too small"));
|
|
|
|
set_mode(&state, PiperMockMode::InvalidArchive);
|
|
call(
|
|
install,
|
|
json!({"voice_id": "en_US-lessac-archivefail", "force": true}),
|
|
)
|
|
.await
|
|
.expect("queue invalid archive failure");
|
|
let failed = wait_for_piper_state(status, "error").await;
|
|
let detail = failed["error_detail"].as_str().unwrap_or_default();
|
|
assert!(
|
|
detail.contains("inflate tar.gz")
|
|
|| detail.contains("parse zip")
|
|
|| detail.contains("unpack tar"),
|
|
"unexpected archive error: {detail}"
|
|
);
|
|
|
|
let requests = state.requests.lock().expect("requests").clone();
|
|
assert!(requests.iter().any(|path| path.ends_with(".onnx")));
|
|
assert!(requests.iter().any(|path| path.ends_with(".onnx.json")));
|
|
assert!(requests
|
|
.iter()
|
|
.any(|path| path.ends_with(".tar.gz") || path.ends_with(".zip")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_transcribe_bytes_covers_temp_file_path_and_extension_validation() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let mut config = temp_config(&tmp);
|
|
config.local_ai.runtime_enabled = false;
|
|
|
|
let invalid = local_ai_transcribe_bytes(&config, b"audio", Some("../wav".to_string()))
|
|
.await
|
|
.expect_err("invalid extension");
|
|
assert_eq!(invalid, "Invalid audio extension");
|
|
|
|
let disabled = local_ai_transcribe_bytes(&config, b"audio", Some(".WEBM".to_string()))
|
|
.await
|
|
.expect_err("disabled runtime after temp file write");
|
|
assert!(disabled.contains("local ai is disabled"));
|
|
}
|
|
|
|
async fn serve_piper_mock() -> (String, PiperMockState) {
|
|
let state = PiperMockState {
|
|
requests: Arc::new(Mutex::new(Vec::new())),
|
|
mode: Arc::new(Mutex::new(PiperMockMode::Valid)),
|
|
archive: Arc::new(valid_tar_gz_archive()),
|
|
};
|
|
let app = Router::new()
|
|
.route("/{*path}", get(piper_download))
|
|
.with_state(state.clone());
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind mock");
|
|
let addr = listener.local_addr().expect("mock addr");
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.expect("serve mock");
|
|
});
|
|
(format!("http://{addr}"), state)
|
|
}
|
|
|
|
async fn piper_download(
|
|
State(state): State<PiperMockState>,
|
|
axum::extract::Path(path): axum::extract::Path<String>,
|
|
) -> Response<Body> {
|
|
state
|
|
.requests
|
|
.lock()
|
|
.expect("requests")
|
|
.push(format!("/{path}"));
|
|
let mode = *state.mode.lock().expect("mode");
|
|
if path.ends_with(".onnx.json") {
|
|
return bytes_response(synthetic_voice_json());
|
|
}
|
|
if path.ends_with(".onnx") {
|
|
return match mode {
|
|
PiperMockMode::SmallVoice => bytes_response(vec![b'x'; 128]),
|
|
PiperMockMode::Valid | PiperMockMode::InvalidArchive => {
|
|
bytes_response(vec![b'v'; 31 * 1024 * 1024])
|
|
}
|
|
};
|
|
}
|
|
if path.ends_with(".tar.gz") || path.ends_with(".zip") {
|
|
return match mode {
|
|
PiperMockMode::InvalidArchive => bytes_response(vec![b'!'; 2 * 1024 * 1024]),
|
|
PiperMockMode::Valid | PiperMockMode::SmallVoice => {
|
|
bytes_response((*state.archive).clone())
|
|
}
|
|
};
|
|
}
|
|
(StatusCode::NOT_FOUND, "not found").into_response()
|
|
}
|
|
|
|
fn bytes_response(bytes: Vec<u8>) -> Response<Body> {
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.body(Body::from(bytes))
|
|
.expect("response")
|
|
}
|
|
|
|
fn set_mode(state: &PiperMockState, mode: PiperMockMode) {
|
|
*state.mode.lock().expect("mode") = mode;
|
|
}
|
|
|
|
fn valid_tar_gz_archive() -> Vec<u8> {
|
|
let encoder = GzEncoder::new(Vec::new(), Compression::none());
|
|
let mut archive = tar::Builder::new(encoder);
|
|
append_tar_file(
|
|
&mut archive,
|
|
"piper/piper",
|
|
b"#!/bin/sh\nprintf piper\n".to_vec(),
|
|
0o755,
|
|
);
|
|
let pad: Vec<u8> = (0..(2 * 1024 * 1024))
|
|
.map(|i| ((i * 31 + 17) % 251) as u8)
|
|
.collect();
|
|
append_tar_file(&mut archive, "piper/pad.bin", pad, 0o644);
|
|
archive.finish().expect("finish tar");
|
|
let encoder = archive.into_inner().expect("tar encoder");
|
|
encoder.finish().expect("finish gzip")
|
|
}
|
|
|
|
fn append_tar_file(
|
|
archive: &mut tar::Builder<GzEncoder<Vec<u8>>>,
|
|
path: &str,
|
|
bytes: Vec<u8>,
|
|
mode: u32,
|
|
) {
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_size(bytes.len() as u64);
|
|
header.set_mode(mode);
|
|
header.set_cksum();
|
|
archive
|
|
.append_data(&mut header, path, bytes.as_slice())
|
|
.expect("append tar file");
|
|
}
|
|
|
|
fn synthetic_voice_json() -> Vec<u8> {
|
|
let mut body = br#"{"audio":{"sample_rate":22050},"phoneme_id_map":{},"#.to_vec();
|
|
body.extend_from_slice(br#""filler":""#);
|
|
body.extend(std::iter::repeat_n(b'x', 512));
|
|
body.extend_from_slice(br#""}"#);
|
|
body
|
|
}
|
|
|
|
async fn wait_for_piper_state(status: &RegisteredController, wanted: &str) -> Value {
|
|
wait_for_piper(status, |value| value["state"] == wanted).await
|
|
}
|
|
|
|
async fn wait_for_piper_stage(status: &RegisteredController, wanted: &str) -> Value {
|
|
wait_for_piper(status, |value| value["stage"] == wanted).await
|
|
}
|
|
|
|
async fn wait_for_piper(status: &RegisteredController, done: impl Fn(&Value) -> bool) -> Value {
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
let mut last = Value::Null;
|
|
while Instant::now() < deadline {
|
|
last = call(status, json!({})).await.expect("status");
|
|
if done(&last) {
|
|
return last;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
panic!("timed out waiting for piper status, last={last}");
|
|
}
|
|
|
|
fn controller<'a>(
|
|
controllers: &'a [RegisteredController],
|
|
function: &str,
|
|
) -> &'a RegisteredController {
|
|
controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == function)
|
|
.unwrap_or_else(|| panic!("controller {function} registered"))
|
|
}
|
|
|
|
async fn call(controller: &RegisteredController, params: Value) -> Result<Value, String> {
|
|
let params = params.as_object().cloned().unwrap_or_default();
|
|
(controller.handler)(params).await
|
|
}
|
|
|
|
fn temp_config(tmp: &TempDir) -> Config {
|
|
let root = tmp.path().join(".openhuman");
|
|
std::fs::create_dir_all(root.join("workspace")).expect("workspace dir");
|
|
let mut config = Config::default();
|
|
config.config_path = root.join("config.toml");
|
|
config.workspace_dir = root.join("workspace");
|
|
config.secrets.encrypt = false;
|
|
config.api_url = Some("http://127.0.0.1:9".to_string());
|
|
config
|
|
}
|
|
|
|
fn write_stub_script(dir: &Path, name: &str, body: &str) -> PathBuf {
|
|
let path = dir.join(name);
|
|
std::fs::write(&path, body).expect("write stub");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(&path).expect("metadata").permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(&path, perms).expect("chmod");
|
|
}
|
|
path
|
|
}
|