use std::path::{Path, PathBuf};
use std::process::Output;
use assert_cmd::Command;
use serde_json::Value;
struct LiveEnv {
url: String,
token: String,
}
fn live_url() -> Option<String> {
std::env::var("IGNITION_LIVE_URL")
.ok()
.filter(|value| !value.trim().is_empty())
}
fn live_token() -> Option<String> {
std::env::var("IGNITION_LIVE_TOKEN")
.ok()
.filter(|value| !value.trim().is_empty())
}
fn skip(message: &str) {
eprintln!("skipping: {message}");
}
fn live_env_mutations() -> Option<LiveEnv> {
if std::env::var("IGNITION_LIVE_MUTATIONS").as_deref() != Ok("1") {
return None;
}
match (live_url(), live_token()) {
(Some(url), Some(token)) => Some(LiveEnv { url, token }),
_ => None,
}
}
fn isolated_live_config(env: &LiveEnv) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
std::fs::write(
&config,
format!(
"active = \"dev\"\n\n[profiles.dev]\nurl = \"{}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n",
env.url
),
)
.expect("write config");
(dir, config)
}
fn ign(config: &Path, env: &LiveEnv, args: &[&str]) -> Output {
spawn_ign(config, env, args, None, None)
}
fn ign_stdin(config: &Path, env: &LiveEnv, args: &[&str], stdin: &str) -> Output {
spawn_ign(config, env, args, Some(stdin), None)
}
fn spawn_ign(
config: &Path,
env: &LiveEnv,
args: &[&str],
stdin: Option<&str>,
cwd: Option<&Path>,
) -> Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_TOKEN", &env.token)
.env("IGNITION_URL", &env.url);
if let Some(dir) = cwd {
command.current_dir(dir);
}
command.args(args);
if let Some(text) = stdin {
command.write_stdin(text);
}
command.output().expect("spawn ign")
}
fn err_envelope(out: &Output) -> Value {
let stderr = String::from_utf8_lossy(&out.stderr);
let from = stderr.find('{').expect("envelope starts somewhere");
serde_json::from_str(&stderr[from..]).expect("envelope parses")
}
fn expect_ok(what: &str, out: &Output) {
assert!(
out.status.success(),
"{what} failed (exit {:?}):\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
fn data_envelope(out: &Output) -> Value {
let stdout = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(stdout.trim())
.unwrap_or_else(|err| panic!("stdout envelope parses ({err}): {stdout}"))
}
fn status_row<'a>(envelope: &'a Value, route: &str) -> &'a Value {
envelope["data"]["routes"]
.as_array()
.unwrap_or_else(|| panic!("routes[] present: {envelope}"))
.iter()
.find(|row| row["route"] == route)
.unwrap_or_else(|| panic!("{route} row: {envelope}"))
}
fn stored_secret(config: &Path) -> String {
ignition_core::config::load(config)
.expect("config reloads")
.profiles
.get("dev")
.and_then(|profile| profile.webdev_secret.clone())
.expect("the --with-script-exec deploy persisted a secret")
}
async fn raw_scriptexec_probe(env: &LiveEnv, secret: Option<&str>) -> Result<(), String> {
let client = reqwest::Client::new();
let url = format!("{}/system/webdev/ign-cli/cli/scriptExec", env.url);
let mut request = client
.post(&url)
.header("X-Ignition-API-Token", &env.token)
.json(&serde_json::json!({"action": "version"}));
if let Some(secret) = secret {
request = request.header("X-Ignition-CLI-Secret", secret);
}
let response = request.send().await.expect("probe reaches the gateway");
assert_eq!(
response.status().as_u16(),
200,
"the webdev servlet answers 200 (denials ride the body)"
);
let body: Value = response.json().await.expect("envelope parses");
if body["ok"] == Value::Bool(true) {
assert_eq!(
body["data"]["routeVersion"],
ignition_core::webdev::ROUTE_BUNDLE_VERSION,
"the handshake version: {body}"
);
Ok(())
} else {
Err(body["error"]["code"]
.as_str()
.unwrap_or("(no code)")
.to_string())
}
}
const ALWAYS_ON: [&str; 4] = ["tags", "tagConfig", "alarms", "tagHistory"];
#[tokio::test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1"]
async fn live_webdev_deploy_status_scriptexec_loop() {
let Some(env) = live_env_mutations() else {
skip(
"IGNITION_LIVE_MUTATIONS=1 (with URL+TOKEN) not set — refusing to touch a live gateway",
);
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE.lock().await;
let mut all_output = String::new();
let out = ign(&config, &env, &["webdev", "status", "--compact"]);
all_output.push_str(&String::from_utf8_lossy(&out.stdout));
all_output.push_str(&String::from_utf8_lossy(&out.stderr));
expect_ok("status before deploy (a read — degradation is data)", &out);
let envelope = data_envelope(&out);
for route in ALWAYS_ON {
let row = status_row(&envelope, route);
let status = row["status"].as_str().unwrap_or_default();
assert!(
[
"present",
"absent",
"unlicensed",
"auth_gated",
"version_mismatch"
]
.contains(&status),
"{route}: a known matrix state, got {row}"
);
}
let out = ign(&config, &env, &["webdev", "deploy", "--compact"]);
all_output.push_str(&String::from_utf8_lossy(&out.stdout));
all_output.push_str(&String::from_utf8_lossy(&out.stderr));
expect_ok("deploy", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["routes"].as_array().map(Vec::len), Some(4));
assert_eq!(envelope["data"]["script_exec"], Value::Bool(false));
let out = ign(&config, &env, &["webdev", "status", "--compact"]);
all_output.push_str(&String::from_utf8_lossy(&out.stdout));
all_output.push_str(&String::from_utf8_lossy(&out.stderr));
expect_ok("status after deploy", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["ok"], Value::Bool(true), "{envelope}");
for route in ALWAYS_ON {
let row = status_row(&envelope, route);
assert_eq!(row["status"], "present", "{route}: {row}");
assert_eq!(
row["deployed_version"], row["expected_version"],
"{route}: the handshake matches: {row}"
);
}
let out = ign(&config, &env, &["webdev", "deploy", "--compact"]);
all_output.push_str(&String::from_utf8_lossy(&out.stdout));
all_output.push_str(&String::from_utf8_lossy(&out.stderr));
expect_ok("redeploy (clean replace)", &out);
let out = ign(&config, &env, &["webdev", "status", "--compact"]);
all_output.push_str(&String::from_utf8_lossy(&out.stdout));
all_output.push_str(&String::from_utf8_lossy(&out.stderr));
expect_ok("status after redeploy", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["ok"], Value::Bool(true), "{envelope}");
let out = ign(
&config,
&env,
&["webdev", "deploy", "--with-script-exec", "--compact"],
);
all_output.push_str(&String::from_utf8_lossy(&out.stdout));
all_output.push_str(&String::from_utf8_lossy(&out.stderr));
expect_ok("deploy --with-script-exec", &out);
let envelope = data_envelope(&out);
assert_eq!(
envelope["data"]["routes"].as_array().map(Vec::len),
Some(5),
"{envelope}"
);
assert_eq!(envelope["data"]["script_exec"], Value::Bool(true));
assert_eq!(envelope["data"]["secret_rotated"], Value::Bool(true));
let out = ign(&config, &env, &["webdev", "status", "--compact"]);
all_output.push_str(&String::from_utf8_lossy(&out.stdout));
all_output.push_str(&String::from_utf8_lossy(&out.stderr));
expect_ok("status after scriptExec deploy", &out);
let envelope = data_envelope(&out);
let script_exec = status_row(&envelope, "scriptExec");
assert_eq!(script_exec["status"], "present", "{script_exec}");
let code = raw_scriptexec_probe(&env, None)
.await
.expect_err("no header refuses");
assert_eq!(code, "secret_required", "the fail-closed gate");
let code = raw_scriptexec_probe(&env, Some("deadbeef-wrong-secret"))
.await
.expect_err("wrong secret refuses");
assert_eq!(code, "secret_mismatch", "constant-time mismatch refusal");
raw_scriptexec_probe(&env, Some(&stored_secret(&config)))
.await
.expect("the stored secret opens the version handshake");
let secret = stored_secret(&config);
assert!(
!all_output.contains(&secret),
"redaction: the secret leaked into a spawn's output"
);
}
fn clean_provider(config: &Path, env: &LiveEnv, provider: &str) {
let _ = ign(
config,
env,
&["tags", "provider", "delete", provider, "--yes", "--compact"],
);
}
fn clean_tag_configs(config: &Path, env: &LiveEnv, paths: &[&str]) {
let mut args: Vec<&str> = vec!["tags", "config", "delete"];
args.extend_from_slice(paths);
args.push("--yes");
args.push("--compact");
let _ = ign(config, env, &args);
}
static LIVE_GATE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[tokio::test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1"]
async fn live_tags_provider_browse_read_write_loop() {
let Some(env) = live_env_mutations() else {
skip(
"IGNITION_LIVE_MUTATIONS=1 (with URL+TOKEN) not set — refusing to touch a live gateway",
);
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE.lock().await;
let out = ign(&config, &env, &["webdev", "deploy", "--compact"]);
expect_ok("deploy (the tags route's precondition)", &out);
clean_provider(&config, &env, "p5e2e");
let out = ign(
&config,
&env,
&["tags", "provider", "create", "p5e2e", "--compact"],
);
expect_ok("provider create p5e2e", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["name"], "p5e2e");
let out = ign(&config, &env, &["tags", "provider", "list", "--compact"]);
expect_ok("provider list", &out);
let envelope = data_envelope(&out);
assert!(
envelope["data"]["providers"]
.as_array()
.expect("providers[]")
.iter()
.any(|row| row["name"] == "p5e2e"),
"p5e2e in the native list: {envelope}"
);
let out = ign(&config, &env, &["tags", "browse", "--compact"]);
expect_ok("browse root", &out);
let envelope = data_envelope(&out);
assert!(
envelope["data"]["entries"]
.as_array()
.expect("entries[]")
.iter()
.any(|entry| entry["path"] == "[p5e2e]"),
"browse root contains the fresh provider: {envelope}"
);
let out = ign(
&config,
&env,
&["tags", "read", "[p5e2e]DoesNotExist", "--compact"],
);
expect_ok("read nonexistent path (quality is data)", &out);
let envelope = data_envelope(&out);
assert!(
envelope["data"]["results"][0]["quality"]
.as_str()
.unwrap_or_default()
.starts_with("Bad_NotFound"),
"quality is data with embedded detail: {envelope}"
);
let out = ign(
&config,
&env,
&[
"tags",
"write",
"[p5e2e]DoesNotExist",
"--value",
"7",
"--compact",
],
);
expect_ok("write nonexistent path (quality is data)", &out);
let envelope = data_envelope(&out);
assert!(
envelope["data"]["quality"]
.as_str()
.expect("quality present")
.starts_with("Bad"),
"a nonexistent target writes Bad quality: {envelope}"
);
let out = ign(
&config,
&env,
&["tags", "provider", "delete", "p5e2e", "--yes", "--compact"],
);
expect_ok("provider delete p5e2e (guarded)", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["deleted"], "p5e2e");
}
#[tokio::test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1"]
async fn live_tags_config_export_import_roundtrip() {
let Some(env) = live_env_mutations() else {
skip(
"IGNITION_LIVE_MUTATIONS=1 (with URL+TOKEN) not set — refusing to touch a live gateway",
);
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE.lock().await;
let out = ign(&config, &env, &["webdev", "deploy", "--compact"]);
expect_ok("deploy (the tagConfig route's precondition)", &out);
for provider in ["p5e2e", "p5import"] {
clean_provider(&config, &env, provider);
}
for provider in ["p5e2e", "p5import"] {
let out = ign(
&config,
&env,
&["tags", "provider", "create", provider, "--compact"],
);
expect_ok(&format!("provider create {provider}"), &out);
}
let out = ign_stdin(
&config,
&env,
&[
"tags",
"config",
"create",
"[p5e2e]T1",
"--file",
"-",
"--compact",
],
r#"{"tagType": "AtomicTag", "dataType": "Int4", "value": 123}"#,
);
expect_ok("config create [p5e2e]T1", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["quality"], "Good");
let out = ign(&config, &env, &["tags", "read", "[p5e2e]T1", "--compact"]);
expect_ok("read [p5e2e]T1", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["results"][0]["quality"], "Good");
assert_eq!(envelope["data"]["results"][0]["value"], 123);
let tmp = tempfile::tempdir().expect("tempdir");
let export_file = tmp.path().join("p5e2e.json");
let out = ign(
&config,
&env,
&[
"tags",
"export",
"[p5e2e]",
"-o",
export_file.to_str().expect("path"),
"--compact",
],
);
expect_ok("export [p5e2e]", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["tag_count"], 1, "one top-level subtree");
assert_eq!(envelope["data"]["file"], export_file.display().to_string());
let out = ign(
&config,
&env,
&[
"tags",
"import",
"--file",
export_file.to_str().expect("path"),
"--provider",
"p5import",
"--compact",
],
);
expect_ok("import #1 (abort, clean target)", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["imported"], 2, "{envelope}");
let out = ign(
&config,
&env,
&[
"tags",
"import",
"--file",
export_file.to_str().expect("path"),
"--provider",
"p5import",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(6),
"collision refuses exit 6; stdout: {} stderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let envelope = err_envelope(&out);
assert_eq!(envelope["error"]["code"], "tag_collision");
let out = ign(
&config,
&env,
&[
"tags",
"import",
"--file",
export_file.to_str().expect("path"),
"--provider",
"p5import",
"--collision-policy",
"overwrite",
"--yes",
"--compact",
],
);
expect_ok("import #3 (overwrite --yes)", &out);
let out = ign(
&config,
&env,
&["tags", "read", "[p5import]T1", "--compact"],
);
expect_ok("read [p5import]T1 (the round-trip oracle)", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["results"][0]["quality"], "Good");
assert_eq!(envelope["data"]["results"][0]["value"], 123);
let out = ign(
&config,
&env,
&[
"tags",
"config",
"delete",
"[p5e2e]T1",
"[p5import]T1",
"--yes",
"--compact",
],
);
expect_ok("config delete cleanup", &out);
for provider in ["p5e2e", "p5import"] {
let out = ign(
&config,
&env,
&["tags", "provider", "delete", provider, "--yes", "--compact"],
);
expect_ok(&format!("provider delete {provider} (guarded)"), &out);
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after epoch")
.as_millis() as i64
}
const HISTORIAN_PROVIDER_PATH: &str =
"/data/api/v1/resources/com.inductiveautomation.historian/historian-provider";
async fn provision_internal_historian(env: &LiveEnv, name: &str) {
let client = reqwest::Client::new();
let url = format!("{}{HISTORIAN_PROVIDER_PATH}", env.url);
let body = serde_json::json!([{
"name": name,
"type": "com.inductiveautomation.historian/historian-provider",
"collection": "core",
"enabled": true,
"config": {"profile": {"type": "InternalHistorian"}, "settings": {}}
}]);
let response = client
.post(&url)
.header("X-Ignition-API-Token", &env.token)
.json(&body)
.send()
.await
.expect("historian create reaches the gateway");
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
assert!(
status == 200
|| text.to_lowercase().contains("exist")
|| text.to_lowercase().contains("duplicate"),
"InternalHistorian create failed (HTTP {status}): {text}"
);
println!("historian {name} provisioned (HTTP {status})");
}
async fn delete_internal_historian(env: &LiveEnv, name: &str) {
let client = reqwest::Client::new();
let find_url = format!("{}{HISTORIAN_PROVIDER_PATH}/find/{name}", env.url);
let response = client
.get(&find_url)
.header("X-Ignition-API-Token", &env.token)
.send()
.await
.expect("historian find reaches the gateway");
if response.status().as_u16() != 200 {
println!(
"historian {name} already gone (find → {})",
response.status()
);
return;
}
let record: Value = response.json().await.expect("find record parses");
let Some(signature) = record.get("signature").and_then(Value::as_str) else {
println!("historian {name} find record carried no signature — leaving it (manual cleanup)");
return;
};
let delete_url = format!("{}{HISTORIAN_PROVIDER_PATH}/{name}/{signature}", env.url);
let response = client
.delete(&delete_url)
.header("X-Ignition-API-Token", &env.token)
.send()
.await
.expect("historian delete reaches the gateway");
println!(
"historian {name} deleted (HTTP {})",
response.status().as_u16()
);
}
fn history_has_data(envelope: &Value) -> bool {
let Some(rows) = envelope["data"]["rows"].as_array() else {
return false;
};
rows.iter().any(|row| {
row.as_array()
.map(|cells| cells.iter().skip(1).any(|cell| !cell.is_null()))
.unwrap_or(false)
})
}
#[tokio::test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1"]
async fn live_tags_history_historian_and_binding_spike() {
let Some(env) = live_env_mutations() else {
skip(
"IGNITION_LIVE_MUTATIONS=1 (with URL+TOKEN) not set — refusing to touch a live gateway",
);
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE.lock().await;
let tag = "[default]P5H/T1";
clean_tag_configs(&config, &env, &[tag]);
delete_internal_historian(&env, "p5hist").await;
let out = ign(&config, &env, &["webdev", "deploy", "--compact"]);
expect_ok("deploy (the tagHistory route's precondition)", &out);
provision_internal_historian(&env, "p5hist").await;
let out = ign_stdin(
&config,
&env,
&["tags", "config", "create", tag, "--file", "-", "--compact"],
r#"{"tagType": "AtomicTag", "dataType": "Int4", "value": 0, "historyEnabled": true, "historicalProvider": "p5hist"}"#,
);
expect_ok("config create the history-enabled tag", &out);
let start_ms = now_ms() - 5_000;
for value in [7, 8, 9] {
let out = ign(
&config,
&env,
&[
"tags",
"write",
tag,
"--value",
&value.to_string(),
"--compact",
],
);
expect_ok("write a history value", &out);
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
}
let end_ms = now_ms() + 5_000;
let query_args = |start_ms: i64, end_ms: i64, aggregation: Option<&str>| {
let mut args: Vec<String> = vec![
"tags".into(),
"history".into(),
"query".into(),
tag.into(),
"--start".into(),
start_ms.to_string(),
"--end".into(),
end_ms.to_string(),
];
if let Some(mode) = aggregation {
args.push("--aggregation".into());
args.push(mode.into());
}
args.push("--compact".into());
args
};
let query = |config: &std::path::Path,
env: &LiveEnv,
start_ms: i64,
end_ms: i64,
aggregation: Option<&str>| {
let args = query_args(start_ms, end_ms, aggregation);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
ign(config, env, &refs)
};
let out = query(&config, &env, start_ms, end_ms, None);
expect_ok("history query (base shape)", &out);
let envelope = data_envelope(&out);
let columns = envelope["data"]["columns"]
.as_array()
.cloned()
.unwrap_or_default();
assert_eq!(
columns.first().and_then(Value::as_str),
Some("t_stamp"),
"{envelope}"
);
let relative = tag
.trim_start_matches('[')
.split_once(']')
.map(|(_, rest)| rest);
assert!(
columns
.iter()
.any(|column| column.as_str() == relative && relative.is_some()),
"the tag column rides provider-relative: {envelope}"
);
if history_has_data(&envelope) {
println!(
"SPIKE RESOLVED (base shape): historyEnabled + historicalProvider on an Int4 tag \
with a REST-provisioned InternalHistorian produces DATA — pin this shape \
(see the comment at this fixture in e2e_webdev.rs)"
);
} else {
println!("SPIKE: base shape returned null values — trying the documented candidates");
let mut resolved: Option<String> = None;
let out = ign_stdin(
&config,
&env,
&["tags", "config", "edit", tag, "--file", "-", "--compact"],
r#"{"tagType": "AtomicTag", "dataType": "Int4", "value": 9, "historyEnabled": true, "historicalProvider": "p5hist", "execution": {"mode": "TagGroup", "rate": 1000}}"#,
);
expect_ok("candidate 1: execution scan-class edit", &out);
let out = ign(
&config,
&env,
&["tags", "write", tag, "--value", "11", "--compact"],
);
expect_ok("candidate 1: write", &out);
tokio::time::sleep(std::time::Duration::from_millis(2_500)).await;
let out = query(&config, &env, start_ms, end_ms, None);
expect_ok("candidate 1: query", &out);
let envelope = data_envelope(&out);
if history_has_data(&envelope) {
resolved = Some("candidate 1: execution scan-class keys in the configure shape".into());
}
if resolved.is_none() {
let wide_start = now_ms() - 600_000;
let wide_end = now_ms() + 5_000;
for mode in ["Average", "MinMax", "DurationOn"] {
let out = query(&config, &env, wide_start, wide_end, Some(mode));
expect_ok(&format!("candidate 2: query ({mode})"), &out);
let envelope = data_envelope(&out);
if history_has_data(&envelope) {
resolved = Some(format!(
"candidate 2: aggregationMode {mode} on a 10-minute window"
));
break;
}
}
}
let out = ign(
&config,
&env,
&["tags", "browse", "histprov:p5hist", "--compact"],
);
let historian_registered = out.status.success()
&& data_envelope(&out)["data"]["entries"]
.as_array()
.is_some_and(|entries| !entries.is_empty());
println!(
"candidate 3 cross-check: histprov:p5hist browse {}",
if historian_registered {
"ANSWERS (the historian is registered)"
} else {
"answered empty (the historian is registered but holds no tags)"
}
);
match resolved {
Some(winner) => println!("SPIKE RESOLVED: {winner} — pin the winning shape"),
None => {
println!(
"SPIKE OUTCOME (no winner): history query is structurally working \
(t_stamp + tag columns, exit 0) but the tag↔historian DATA binding \
remains unresolved after all documented candidates — recorded as a \
documented limitation (README 'Tag history'); the Designer-diff \
follow-up (create one history tag by hand in the Designer, getConfig \
it via this CLI, diff the shapes) is the resolution path"
);
}
}
}
let out = ign(
&config,
&env,
&["tags", "config", "delete", tag, "--yes", "--compact"],
);
expect_ok("config delete the history tag", &out);
delete_internal_historian(&env, "p5hist").await;
}
#[tokio::test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1"]
async fn live_tags_alarm_lifecycle() {
let Some(env) = live_env_mutations() else {
skip(
"IGNITION_LIVE_MUTATIONS=1 (with URL+TOKEN) not set — refusing to touch a live gateway",
);
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE.lock().await;
let tag = "[p5alarm]AlarmTag";
let out = ign(&config, &env, &["webdev", "deploy", "--compact"]);
expect_ok("deploy (the alarms route's precondition)", &out);
clean_provider(&config, &env, "p5alarm");
let out = ign(
&config,
&env,
&["tags", "provider", "create", "p5alarm", "--compact"],
);
expect_ok("provider create p5alarm", &out);
let out = ign_stdin(
&config,
&env,
&["tags", "config", "create", tag, "--file", "-", "--compact"],
r#"{"tagType": "AtomicTag", "dataType": "Int4", "value": 0, "alarms": [{"name": "HighLimit", "enabled": true, "mode": "AboveValue", "setpointA": 100, "priority": "High"}]}"#,
);
expect_ok("config create the alarmed tag", &out);
let out = ign(
&config,
&env,
&["tags", "write", tag, "--value", "150", "--compact"],
);
expect_ok("write past the setpoint", &out);
let find_event = |envelope: &Value| -> Option<Value> {
envelope["data"]["alarms"].as_array().and_then(|alarms| {
alarms
.iter()
.find(|alarm| {
alarm["source"]
.as_str()
.is_some_and(|source| source.contains("p5alarm"))
})
.cloned()
})
};
let mut event = None;
for _ in 0..20 {
let out = ign(&config, &env, &["tags", "alarms", "active", "--compact"]);
expect_ok("alarms active poll", &out);
let envelope = data_envelope(&out);
if let Some(found) = find_event(&envelope) {
event = Some(found);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
let event = event.expect("the alarm event appeared (bounded retries)");
let event_id = event["event_id"]
.as_str()
.expect("event_id present")
.to_string();
assert!(
event["state"]
.as_str()
.unwrap_or_default()
.contains("Unacknowledged"),
"the fresh event is Active, Unacknowledged: {event}"
);
assert_eq!(event["priority"], "High", "{event}");
println!(
"alarm active: {event_id} {}",
event["state"].as_str().unwrap_or_default()
);
let out = ign(
&config,
&env,
&[
"tags",
"alarms",
"ack",
&event_id,
"--note",
"e2e lifecycle ack",
"--username",
"ign-e2e",
"--compact",
],
);
expect_ok("alarms ack (3-arg form)", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["acknowledged"], 1, "{envelope}");
assert_eq!(
envelope["data"]["unacknowledged"].as_array().map(Vec::len),
Some(0),
"empty remainder: {envelope}"
);
let mut acknowledged = false;
for _ in 0..20 {
let out = ign(&config, &env, &["tags", "alarms", "active", "--compact"]);
expect_ok("alarms active poll (ack)", &out);
let envelope = data_envelope(&out);
if let Some(event) = find_event(&envelope)
&& event["state"]
.as_str()
.is_some_and(|state| state.contains("Acknowledged"))
{
assert!(
event["state"]
.as_str()
.unwrap_or_default()
.contains("Active"),
"the acked alarm stays active until cleared: {event}"
);
acknowledged = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
assert!(acknowledged, "the event flipped to 'Active, Acknowledged'");
println!("alarm acked: state flipped to Active, Acknowledged");
let out = ign(
&config,
&env,
&[
"tags",
"alarms",
"history",
"--start",
&(now_ms() - 600_000).to_string(),
"--end",
&now_ms().to_string(),
"--compact",
],
);
if out.status.code() == Some(6) {
let envelope = err_envelope(&out);
assert_eq!(
envelope["error"]["code"], "alarm_journal_missing",
"the default-rig refusal is the journal slug: {envelope}"
);
println!(
"alarm history: default-rig honest refusal pinned live \
(alarm_journal_missing, exit 6 — the actionable journal-chain hint)"
);
} else {
expect_ok("alarms history (journal-provisioned rig)", &out);
let envelope = data_envelope(&out);
println!(
"alarm history: journal answered ({} row(s)) — the provisioned path",
envelope["data"]["count"]
);
}
println!(
"journal provisioning stretch: skipped — the chain (sidecar postgres container → \
database-connection resource → alarm-journal profile → general-alarm-settings \
singleton) is documented in the README 'Alarm history' section; provision it \
manually and re-run to walk the provisioned path above"
);
let _ = ign(
&config,
&env,
&["tags", "write", tag, "--value", "0", "--compact"],
);
let out = ign(
&config,
&env,
&["tags", "config", "delete", tag, "--yes", "--compact"],
);
expect_ok("config delete the alarmed tag", &out);
let out = ign(
&config,
&env,
&[
"tags",
"provider",
"delete",
"p5alarm",
"--yes",
"--compact",
],
);
expect_ok("provider delete p5alarm (guarded)", &out);
}