test(arr): drive the command backend via stub scripts

This commit is contained in:
Miguel Palhas
2026-08-25 01:44:52 +01:00
parent 8b92a68edd
commit 07921c9e1e
+240
View File
@@ -0,0 +1,240 @@
//! 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::{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:?}"),
}
}
const DEFAULT: Duration = arr_subs::COMMAND_DEFAULT_TIMEOUT;