73d3f04398
Reused for 5 minutes (#225) instead of running the configured command on every /api/health poll.
321 lines
10 KiB
Rust
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");
|
|
}
|