diff --git a/crates/arr-subs/tests/command.rs b/crates/arr-subs/tests/command.rs new file mode 100644 index 0000000..e956af4 --- /dev/null +++ b/crates/arr-subs/tests/command.rs @@ -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 { + 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;