//! 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 { 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:?}"); }