Files
arr/crates/arr-subs/tests/command.rs
T
Miguel Palhas 73d3f04398 fix(arr): cache the remote-command probe verdict
Reused for 5 minutes (#225) instead of running the configured
command on every /api/health poll.
2026-08-25 06:40:50 +01:00

321 lines
10 KiB
Rust

//! Remote-command backend tests (`DESIGN.md` §15, issue #193). Everything
//! runs against stub shell scripts in a tempdir — no remote host is touched.
//!
//! Built only with the `translate-command` feature (`required-features` in
//! `Cargo.toml`), same as the module itself: with the feature off, cargo
//! skips this target rather than building an empty one.
// Same per-target quirk as the crate's own tests: an integration test links
// the library's dependencies without using them all directly.
use {
arr_parse as _, chardetng as _, encoding_rs as _, reqwest as _, serde as _, serde_json as _,
thiserror as _, tracing as _, wiremock as _, zip as _,
};
use std::{
fs,
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
time::Duration,
};
use arr_core::Language;
use arr_subs::translate::{translate, Error};
use arr_subs::{Backend as _, Command, CommandConfig};
fn cues(texts: &[&str]) -> Vec<arr_subs::Cue> {
texts
.iter()
.enumerate()
.map(|(index, text)| arr_subs::Cue {
start: Duration::from_secs(index as u64),
end: Duration::from_secs(index as u64 + 1),
text: (*text).to_owned(),
})
.collect()
}
/// A stub command in `dir`, executable, with `body` as its shell script.
fn stub(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, format!("#!/bin/sh\n{body}")).expect("stub writes");
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
path
}
fn config(template: &str, timeout: Duration) -> CommandConfig {
CommandConfig {
template: template.to_owned(),
timeout,
}
}
#[tokio::test]
async fn a_batch_round_trips_through_the_command() {
let dir = tempfile::tempdir().expect("tempdir");
// Save stdin to $1 and echo everything after the prompt line straight
// back — a valid reply, since numbers and count then match by
// construction.
let script = stub(dir.path(), "echo.sh", "tee \"$1\" | tail -n +2\n");
let backend = Command::new(config(
&format!(
"{} {}/received.json",
script.display(),
dir.path().display()
),
DEFAULT,
))
.expect("backend constructs");
let translated = translate(
&backend,
&cues(&["hello", "world"]),
&Language::Other("en".to_owned()),
&Language::PortuguesePortugal,
)
.await
.expect("the echo stub answers");
assert_eq!(translated.len(), 2);
assert_eq!(translated[0].text, "hello");
assert_eq!(translated[1].end, cues(&["x", "y"])[1].end);
// What reached the command's stdin: prompt first, batch after.
let sent = fs::read_to_string(dir.path().join("received.json")).expect("stub saved stdin");
assert!(sent.contains("European Portuguese"));
assert!(sent.contains(r#""number":2"#));
}
#[tokio::test]
async fn template_placeholders_reach_the_command() {
let dir = tempfile::tempdir().expect("tempdir");
let script = stub(dir.path(), "dump.sh", "cat > \"$1\"\nprintf '[]'\n");
// The dump file is named after the {target} placeholder, so a successful
// write proves substitution happened before spawn.
let backend = Command::new(config(
&format!(
"{} {}/{{target}}.json",
script.display(),
dir.path().display()
),
DEFAULT,
))
.expect("backend constructs");
translate(
&backend,
&cues(&["hi"]),
&Language::Other("en".to_owned()),
&Language::PortuguesePortugal,
)
.await
.expect_err("an empty reply is malformed");
assert!(dir.path().join("pt-PT.json").exists());
}
#[tokio::test]
async fn a_fenced_reply_is_still_accepted() {
let dir = tempfile::tempdir().expect("tempdir");
let script = stub(
dir.path(),
"fence.sh",
"cat >/dev/null\nprintf '```json\\n[{\"number\":1,\"text\":\"ola\"}]\\n```\\n'\n",
);
let backend =
Command::new(config(&script.display().to_string(), DEFAULT)).expect("backend constructs");
let translated = translate(
&backend,
&cues(&["hello"]),
&Language::Other("en".to_owned()),
&Language::PortuguesePortugal,
)
.await
.expect("a fenced reply unwraps");
assert_eq!(translated[0].text, "ola");
}
#[tokio::test]
async fn a_non_zero_exit_is_a_transport_error() {
let dir = tempfile::tempdir().expect("tempdir");
let script = stub(
dir.path(),
"fail.sh",
"echo connection refused >&2\nexit 7\n",
);
let backend =
Command::new(config(&script.display().to_string(), DEFAULT)).expect("backend constructs");
let error = translate(
&backend,
&cues(&["hello"]),
&Language::Other("en".to_owned()),
&Language::PortuguesePortugal,
)
.await
.expect_err("exit 7 must fail");
match error {
Error::Transport { ref source, .. } => {
assert!(source.to_string().contains("connection refused"));
assert!(error.is_transient());
}
other => panic!("expected Transport, got {other:?}"),
}
}
#[tokio::test]
async fn garbage_output_is_malformed() {
let dir = tempfile::tempdir().expect("tempdir");
let script = stub(dir.path(), "garbage.sh", "cat >/dev/null\necho I refuse\n");
let backend =
Command::new(config(&script.display().to_string(), DEFAULT)).expect("backend constructs");
let error = translate(
&backend,
&cues(&["hello"]),
&Language::Other("en".to_owned()),
&Language::PortuguesePortugal,
)
.await
.expect_err("prose is not a reply");
assert!(matches!(error, Error::Malformed { .. }));
assert!(!error.is_transient());
}
#[tokio::test]
async fn a_short_reply_fails_validation() {
let dir = tempfile::tempdir().expect("tempdir");
let script = stub(
dir.path(),
"short.sh",
"cat >/dev/null\nprintf '[{\"number\":1,\"text\":\"so\"}]'\n",
);
let backend =
Command::new(config(&script.display().to_string(), DEFAULT)).expect("backend constructs");
let error = translate(
&backend,
&cues(&["one", "two"]),
&Language::Other("en".to_owned()),
&Language::PortuguesePortugal,
)
.await
.expect_err("two cues went out, one came back");
assert!(matches!(error, Error::CueMismatch { .. }));
}
#[tokio::test]
async fn a_hanging_command_is_killed_at_the_timeout() {
let dir = tempfile::tempdir().expect("tempdir");
let script = stub(dir.path(), "hang.sh", "sleep 30\n");
let backend = Command::new(config(
&script.display().to_string(),
Duration::from_millis(150),
))
.expect("backend constructs");
let error = translate(
&backend,
&cues(&["hello"]),
&Language::Other("en".to_owned()),
&Language::PortuguesePortugal,
)
.await
.expect_err("a hung command must not wait forever");
match error {
Error::Transport { ref source, .. } => {
assert!(source.to_string().contains("timed out"));
assert!(error.is_transient());
}
other => panic!("expected Transport, got {other:?}"),
}
}
/// Issue #219: the timeout is not frozen at construction. The settings API
/// stores the row's value into [`Command::timeout_cell`] on every edit, and
/// the next batch must already run under it.
#[tokio::test]
async fn an_edited_timeout_reaches_the_next_call() {
let dir = tempfile::tempdir().expect("tempdir");
let script = stub(dir.path(), "hang.sh", "sleep 30\n");
let backend =
Command::new(config(&script.display().to_string(), DEFAULT)).expect("backend constructs");
// The edit that a PUT of remote_command_timeout_seconds performs.
backend
.timeout_cell()
.store(150, std::sync::atomic::Ordering::Relaxed);
let error = translate(
&backend,
&cues(&["hello"]),
&Language::Other("en".to_owned()),
&Language::PortuguesePortugal,
)
.await
.expect_err("the edited timeout must apply without a restart");
match error {
Error::Transport { source, .. } => {
assert!(source.to_string().contains("150ms"), "{source}");
}
other => panic!("expected Transport, got {other:?}"),
}
}
const DEFAULT: Duration = arr_subs::COMMAND_DEFAULT_TIMEOUT;
/// The lamp (#200) is the command starting and exiting cleanly — not what it
/// says. An empty batch goes in; only the exit status is judged.
#[tokio::test]
async fn a_probe_runs_the_template_and_wants_a_clean_exit() {
let dir = tempfile::tempdir().expect("tempdir");
let ok = stub(dir.path(), "ok.sh", "cat > /dev/null\n");
Command::new(config(&ok.display().to_string(), DEFAULT))
.expect("backend constructs")
.probe()
.await
.expect("a clean exit is a lit lamp");
let failing = stub(dir.path(), "fail.sh", "echo nope >&2\nexit 3\n");
let error = Command::new(config(&failing.display().to_string(), DEFAULT))
.expect("backend constructs")
.probe()
.await
.expect_err("non-zero exit");
assert!(matches!(error, Error::Transport { .. }), "got {error:?}");
}
/// #225: `/api/health` polls every 15 seconds, and unlike the other lamps
/// this one is a real command. A second call within the TTL must not spawn
/// it again. TTL expiry itself is a unit test in `command.rs` — it needs
/// [`Command`]'s private cache to back-date it, rather than a real sleep.
#[tokio::test]
async fn a_probe_within_the_ttl_is_not_rerun() {
let dir = tempfile::tempdir().expect("tempdir");
let counter = dir.path().join("count");
let script = stub(
dir.path(),
"count.sh",
&format!("cat > /dev/null\necho x >> {}\n", counter.display()),
);
let backend =
Command::new(config(&script.display().to_string(), DEFAULT)).expect("backend constructs");
backend.probe().await.expect("first probe runs the command");
backend.probe().await.expect("second probe hits the cache");
let runs = fs::read_to_string(&counter)
.expect("counter written")
.lines()
.count();
assert_eq!(runs, 1, "the cached call must not re-run the template");
}