test(arr): drive the command backend via stub scripts
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user