use std::collections::BTreeMap;
use std::path::Path;
use bynk_emit::project::{RemoteSkip, TestTarget};
pub const DEV: &str = "dev";
pub const DEV_COMMAND_ENV: &str = "BYNK_DEV_COMMAND";
pub const DEV_URLS_ENV: &str = "BYNK_TEST_DEV_URLS";
pub const DEV_ENV_ENV: &str = "BYNK_TEST_DEV_ENV";
pub const DEV_SECRETS_ENV: &str = "BYNK_TEST_DEV_SECRETS";
pub const SECRETS_ENV: &str = "BYNK_TEST_SECRETS";
pub const RUN_ID_ENV: &str = "BYNK_TEST_RUN_ID";
pub const DEV_RUN_ID_ENV: &str = "BYNK_TEST_DEV_RUN_ID";
pub fn fresh_run_id() -> String {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis());
format!("r{millis:x}{:x}", std::process::id())
}
pub const DEFAULT_SETTLE_MS: u64 = 5_000;
pub fn parse_settle(value: &str) -> Result<u64, String> {
let bad =
|| format!("`--settle {value}` is not a duration; write one such as `5s`, `500ms` or `0`");
let v = value.trim();
if v == "0" {
return Ok(0);
}
let (digits, factor) = if let Some(n) = v.strip_suffix("ms") {
(n, 1)
} else if let Some(n) = v.strip_suffix('s') {
(n, 1_000)
} else if let Some(n) = v.strip_suffix('m') {
(n, 60_000)
} else {
return Err(bad());
};
digits
.parse::<u64>()
.ok()
.and_then(|n| n.checked_mul(factor))
.ok_or_else(bad)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Against {
Run(RemoteRun),
StartDev(std::path::PathBuf),
}
pub fn is_loopback_http(url: &str) -> bool {
let Some(rest) = url.strip_prefix("http://") else {
return false;
};
let authority = rest.split('/').next().unwrap_or_default();
let host = match authority.rsplit_once(':') {
Some((h, port)) if !h.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
_ => authority,
};
matches!(host, "localhost" | "127.0.0.1" | "[::1]")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteRun {
pub target: String,
pub urls: BTreeMap<String, String>,
pub vars: BTreeMap<String, String>,
pub secrets: Vec<String>,
pub dev_env: Option<BTreeMap<String, String>>,
pub secret_values: BTreeMap<String, String>,
pub settle_ms: u64,
pub run_id: String,
pub skipped: Vec<(String, RemoteSkip)>,
}
impl RemoteRun {
pub fn targets_json(&self) -> String {
serde_json::to_string(&self.urls).expect("a string map serialises")
}
pub fn secrets_json(&self) -> String {
serde_json::to_string(&self.secret_values).expect("a string map serialises")
}
pub fn transport(&self) -> String {
format!("remote:{}", self.target)
}
pub fn banner(&self) -> String {
let mut out = format!(
"running the `system` suites against target `{}`:\n",
self.target
);
let width = self.urls.keys().map(String::len).max().unwrap_or(0);
for (worker, url) in &self.urls {
out.push_str(&format!(" {worker:<width$} {url}\n"));
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
Dev(String),
NotLoopback(String),
BareUrl(String),
Unknown { name: String, known: Vec<String> },
Production(String),
MissingSecrets { target: String, names: Vec<String> },
Manifest(String),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::Dev(why) => write!(f, "`--against dev` {why}"),
Refusal::NotLoopback(url) => write!(
f,
"`--against dev` runs only against a local `bynk dev`, but was given `{url}`, which isn't plain HTTP on localhost"
),
Refusal::BareUrl(url) => write!(
f,
"`--against {url}` names a URL; a run against a deployment must use a target named under `[test.targets]` in `bynk.toml`, so a URL can't be pointed at by accident"
),
Refusal::Unknown { name, known } if known.is_empty() => write!(
f,
"`--against {name}` names no target: `bynk.toml` has no `[test.targets]`. Add `[test.targets.{name}]` with each Worker's `url`, or use `--against dev`"
),
Refusal::Unknown { name, known } => write!(
f,
"`--against {name}` names no target under `[test.targets]`; the targets are {}",
known
.iter()
.map(|k| format!("`{k}`"))
.collect::<Vec<_>>()
.join(", ")
),
Refusal::Production(name) => write!(
f,
"target `{name}` is marked `production = true`; the test runner never runs against production"
),
Refusal::MissingSecrets { target, names } => write!(
f,
"target `{target}` needs the secret{} {} from the environment, and {} not set",
if names.len() == 1 { "" } else { "s" },
names
.iter()
.map(|n| format!("`{n}`"))
.collect::<Vec<_>>()
.join(", "),
if names.len() == 1 {
"it is"
} else {
"they are"
}
),
Refusal::Manifest(e) => write!(f, "{e}"),
}
}
}
fn looks_like_url(value: &str) -> bool {
value.contains("://")
|| value.starts_with("localhost")
|| value.contains('/')
|| value.parse::<std::net::SocketAddr>().is_ok()
|| value.contains(':')
}
pub fn resolve(
value: &str,
project_root: &Path,
env: impl Fn(&str) -> Option<String>,
dev_refusal: &str,
) -> Result<Against, Refusal> {
if value == DEV {
if let Some(urls) = env(DEV_URLS_ENV) {
let urls: BTreeMap<String, String> = serde_json::from_str(&urls).map_err(|e| {
Refusal::Manifest(format!("{DEV_URLS_ENV} is not a JSON object: {e}"))
})?;
if let Some(bad) = urls.values().find(|u| !is_loopback_http(u)) {
return Err(Refusal::NotLoopback(bad.clone()));
}
let json_map = |name: &str| -> Result<BTreeMap<String, String>, Refusal> {
match env(name) {
Some(e) => serde_json::from_str(&e).map_err(|e| {
Refusal::Manifest(format!("{name} is not a JSON object: {e}"))
}),
None => Ok(BTreeMap::new()),
}
};
let dev_env = json_map(DEV_ENV_ENV)?;
let secret_values = json_map(DEV_SECRETS_ENV)?;
let run_id = env(DEV_RUN_ID_ENV)
.filter(|id| !id.is_empty())
.unwrap_or_else(fresh_run_id);
return Ok(Against::Run(RemoteRun {
target: DEV.to_string(),
urls,
vars: BTreeMap::new(),
secrets: Vec::new(),
dev_env: Some(dev_env),
secret_values,
settle_ms: DEFAULT_SETTLE_MS,
run_id,
skipped: Vec::new(),
}));
}
if let Some(bynk) = env(DEV_COMMAND_ENV).filter(|p| !p.is_empty()) {
return Ok(Against::StartDev(bynk.into()));
}
return Err(Refusal::Dev(dev_refusal.to_string()));
}
if looks_like_url(value) {
return Err(Refusal::BareUrl(value.to_string()));
}
let targets = bynk_emit::project::try_read_test_targets(project_root)
.map_err(|e| Refusal::Manifest(e.to_string()))?;
let Some(target) = targets.get(value) else {
return Err(Refusal::Unknown {
name: value.to_string(),
known: targets.keys().cloned().collect(),
});
};
check_target(value, target, env).map(Against::Run)
}
pub fn check_target(
name: &str,
target: &TestTarget,
env: impl Fn(&str) -> Option<String>,
) -> Result<RemoteRun, Refusal> {
if target.production {
return Err(Refusal::Production(name.to_string()));
}
let mut secret_values = BTreeMap::new();
let mut missing: Vec<String> = Vec::new();
for name in &target.secrets {
match env(name).filter(|v| !v.is_empty()) {
Some(value) => {
secret_values.insert(name.clone(), value);
}
None => missing.push(name.clone()),
}
}
if !missing.is_empty() {
return Err(Refusal::MissingSecrets {
target: name.to_string(),
names: missing,
});
}
Ok(RemoteRun {
target: name.to_string(),
urls: target.urls.clone(),
vars: target.vars.clone(),
secrets: target.secrets.clone(),
dev_env: None,
secret_values,
settle_ms: DEFAULT_SETTLE_MS,
run_id: fresh_run_id(),
skipped: Vec::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn target(production: bool, secrets: &[&str]) -> TestTarget {
TestTarget {
urls: BTreeMap::from([("shop-orders".to_string(), "http://127.0.0.1:9".to_string())]),
secrets: secrets.iter().map(|s| s.to_string()).collect(),
production,
vars: BTreeMap::new(),
}
}
fn manifest(text: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("bynk-remote-{}-{}", std::process::id(), text.len()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("bynk.toml"), text).unwrap();
dir
}
#[test]
fn settle_durations_parse() {
assert_eq!(parse_settle("0"), Ok(0));
assert_eq!(parse_settle("500ms"), Ok(500));
assert_eq!(parse_settle("5s"), Ok(5_000));
assert_eq!(parse_settle("2m"), Ok(120_000));
for bad in ["5", "fast", "-1s", "1.5s", "ms"] {
assert!(parse_settle(bad).is_err(), "{bad}");
}
}
#[test]
fn a_bare_url_is_refused() {
for url in [
"https://shop.example.workers.dev",
"localhost:8787",
"127.0.0.1:8787",
"shop.example/x",
] {
assert!(
matches!(
resolve(url, Path::new("/nonexistent"), |_| None, "x"),
Err(Refusal::BareUrl(_))
),
"{url}"
);
}
}
#[test]
fn dev_is_reserved() {
let r = resolve("dev", Path::new("/nonexistent"), |_| None, "is not here");
assert_eq!(r, Err(Refusal::Dev("is not here".to_string())));
}
#[test]
fn dev_urls_must_be_loopback() {
let env = |urls: &'static str| move |n: &str| (n == DEV_URLS_ENV).then(|| urls.to_string());
let ok = resolve(
"dev",
Path::new("/x"),
env(r#"{"a":"http://localhost:8787","b":"http://127.0.0.1:9"}"#),
"x",
);
assert!(
matches!(ok, Ok(Against::Run(ref r)) if r.dev_env.is_some()),
"{ok:?}"
);
for bad in [
r#"{"a":"https://localhost:8787"}"#,
r#"{"a":"http://shop.example.workers.dev"}"#,
r#"{"a":"http://localhost.example.com:80"}"#,
] {
assert!(
matches!(
resolve("dev", Path::new("/x"), env(bad), "x"),
Err(Refusal::NotLoopback(_))
),
"{bad}"
);
}
}
#[test]
fn a_dev_group_s_secrets_arrive_in_one_json_variable() {
let env = |n: &str| match n {
DEV_URLS_ENV => Some(r#"{"a":"http://localhost:8787"}"#.to_string()),
DEV_SECRETS_ENV => Some(r#"{"PATH":"not-a-path","AUTH":"s"}"#.to_string()),
_ => None,
};
let Ok(Against::Run(run)) = resolve("dev", Path::new("/x"), env, "x") else {
panic!("a dev group run resolves");
};
assert_eq!(run.secret_values["PATH"], "not-a-path");
assert!(run.secrets_json().contains("\"AUTH\":\"s\""));
}
#[test]
fn each_run_gets_its_own_run_id_and_dev_groups_share_one() {
let group = |shared: Option<&'static str>| {
move |n: &str| match n {
DEV_URLS_ENV => Some(r#"{"a":"http://localhost:8787"}"#.to_string()),
DEV_RUN_ID_ENV => shared.map(str::to_string),
RUN_ID_ENV => Some("stale".to_string()),
_ => None,
}
};
let Ok(Against::Run(run)) = resolve("dev", Path::new("/x"), group(Some("shared")), "x")
else {
panic!("a dev group run resolves");
};
assert_eq!(run.run_id, "shared");
let Ok(Against::Run(run)) = resolve("dev", Path::new("/x"), group(None), "x") else {
panic!("a dev group run resolves");
};
assert!(
run.run_id.starts_with('r') && run.run_id != "stale",
"{}",
run.run_id
);
}
#[test]
fn dev_starts_only_with_a_bynk_that_can() {
let r = resolve(
"dev",
Path::new("/x"),
|n| (n == DEV_COMMAND_ENV).then(|| "/bin/bynk".to_string()),
"x",
);
assert_eq!(r, Ok(Against::StartDev("/bin/bynk".into())));
}
#[test]
fn an_unknown_name_lists_the_targets() {
let dir = manifest("[test.targets.preview]\nurl.shop-orders = \"http://127.0.0.1:9\"\n");
let r = resolve("staging", &dir, |_| None, "x");
assert_eq!(
r,
Err(Refusal::Unknown {
name: "staging".to_string(),
known: vec!["preview".to_string()]
})
);
let Ok(Against::Run(ok)) = resolve("preview", &dir, |_| None, "x") else {
panic!("preview resolves");
};
assert_eq!(ok.urls["shop-orders"], "http://127.0.0.1:9");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_production_target_is_refused() {
assert_eq!(
check_target("prod", &target(true, &[]), |_| None),
Err(Refusal::Production("prod".to_string()))
);
}
#[test]
fn secrets_come_from_the_environment_and_must_be_set() {
let t = target(false, &["AUTH_JWT_SECRET", "HOOK_SECRET"]);
let r = check_target("preview", &t, |n| {
(n == "AUTH_JWT_SECRET").then(|| "s".to_string())
});
assert_eq!(
r,
Err(Refusal::MissingSecrets {
target: "preview".to_string(),
names: vec!["HOOK_SECRET".to_string()]
})
);
assert!(check_target("preview", &t, |_| Some("s".to_string())).is_ok());
}
#[test]
fn the_banner_names_the_target_and_each_url() {
let run = check_target("preview", &target(false, &[]), |_| None).unwrap();
let banner = run.banner();
assert!(banner.contains("target `preview`"), "{banner}");
assert!(
banner.contains("shop-orders http://127.0.0.1:9"),
"{banner}"
);
}
}