storebound/apps/desktop/src-tauri/src/selftest.rs

99 lines
4 KiB
Rust

//! `storebound-desktop --self-test`: the release smoke test of the exact artifact. It starts the bundled sidecar the
//! same way the app does (next to the executable, policy::SIDECAR_ARGS), sends `session.hello` with a fresh secret,
//! checks the protocol, prints one JSON line and exits. No window, no webview, no Tauri runtime: it runs headless
//! (a CI runner, a mounted DMG, an SSH session) and never brings anything to the front.
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use crate::{confirm, policy};
pub const FLAG: &str = "--self-test";
const PROTOCOL: &str = "storebound-desktop/1";
/// The bundled sidecar: Tauri puts `externalBin` next to the main executable without its target triple
/// (Contents/MacOS on macOS, the install folder on Windows, usr/bin in the AppImage and the deb).
pub fn sidecar_path() -> Result<PathBuf, String> {
let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
let dir = exe.parent().ok_or("the executable has no folder")?;
Ok(dir.join(format!("{}{}", policy::SIDECAR, std::env::consts::EXE_SUFFIX)))
}
/// Exit code of the self-test (0 = the sidecar answered with the expected protocol).
pub fn run() -> i32 {
match check(Duration::from_secs(30)) {
Ok(v) => {
println!("{v}");
0
}
Err(e) => {
eprintln!("self-test failed: {e}");
1
}
}
}
pub fn check(timeout: Duration) -> Result<Value, String> {
let bin = sidecar_path()?;
if !bin.is_file() {
return Err(format!("no sidecar next to the executable ({})", bin.file_name().unwrap_or_default().to_string_lossy()));
}
let started = Instant::now();
let mut child = Command::new(&bin)
.args(policy::SIDECAR_ARGS)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("sidecar did not start: {e}"))?;
let secret = confirm::new_secret()?;
let hello = json!({ "jsonrpc": "2.0", "id": 1, "method": "session.hello",
"params": { "secret": confirm::hex(&secret), "client": { "name": "storebound-desktop-self-test", "version": env!("CARGO_PKG_VERSION") } } });
let mut stdin = child.stdin.take().ok_or("no sidecar stdin")?;
let line = format!("{hello}\n");
let stdout = child.stdout.take().ok_or("no sidecar stdout")?;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for l in BufReader::new(stdout).lines().map_while(Result::ok) {
if let Ok(v) = serde_json::from_str::<Value>(&l) {
if v.get("id").and_then(Value::as_u64) == Some(1) {
let _ = tx.send(v);
return;
}
}
}
});
let wrote = stdin.write_all(line.as_bytes()).and_then(|_| stdin.flush());
let answer = rx.recv_timeout(timeout);
drop(stdin);
let _ = child.kill();
let _ = child.wait();
wrote.map_err(|e| format!("sidecar stdin: {e}"))?;
let answer = answer.map_err(|_| format!("no answer to session.hello within {} s", timeout.as_secs()))?;
if let Some(e) = answer.get("error") {
return Err(format!("session.hello failed: {e}"));
}
let r = &answer["result"];
let protocol = r["protocol"].as_str().unwrap_or_default();
if protocol != PROTOCOL {
return Err(format!("unsupported sidecar protocol '{protocol}'"));
}
let methods = r["methods"].as_array().map(Vec::len).unwrap_or(0);
if methods == 0 {
return Err("the sidecar announced no methods".into());
}
Ok(json!({
"ok": true,
"app": env!("CARGO_PKG_VERSION"),
"sidecar": r["server"]["version"],
"protocol": protocol,
"methods": methods,
"ms": started.elapsed().as_millis() as u64,
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
}))
}