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 mut envelope = data_envelope(&out);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(240);
loop {
let all_present = envelope["data"]["ok"] == Value::Bool(true)
&& ALWAYS_ON
.iter()
.all(|route| status_row(&envelope, route)["status"] == "present");
if all_present {
break;
}
if std::time::Instant::now() >= deadline {
panic!("status after deploy: routes never became visible within 240s: {envelope}");
}
eprintln!("status after deploy: servlet mount not yet visible, retrying (bounded 240s)…");
std::thread::sleep(std::time::Duration::from_secs(3));
let out = ign(&config, &env, &["webdev", "status", "--compact"]);
expect_ok("status after deploy (retry)", &out);
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);
}
fn import_with_mount_tolerance(config: &Path, env: &LiveEnv, args: &[&str]) -> Output {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut out = ign(config, env, args);
loop {
let clean = out.status.success()
&& data_envelope(&out)["data"]["failed"]
.as_array()
.map(Vec::len)
== Some(0);
if clean {
return out;
}
let transient = out.status.success() && {
let envelope = data_envelope(&out);
let failed = envelope["data"]["failed"]
.as_array()
.cloned()
.unwrap_or_default();
!failed.is_empty()
&& failed
.iter()
.all(|f| f.as_str().map(|s| s.contains("cleanPath")).unwrap_or(false))
};
if !transient || std::time::Instant::now() >= deadline {
return out;
}
eprintln!("import: provider mount race (cleanPath), retrying (bounded 30s)…");
std::thread::sleep(std::time::Duration::from_secs(3));
out = ign(config, env, args);
}
}
fn read_with_provider_tolerance(config: &Path, env: &LiveEnv, args: &[&str]) -> Output {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut out = ign(config, env, args);
while !out.status.success() {
let transient = String::from_utf8_lossy(&out.stderr).contains("Provider not found:");
if !transient || std::time::Instant::now() >= deadline {
return out;
}
eprintln!("read: provider resolution race, retrying (bounded 30s)…");
std::thread::sleep(std::time::Duration::from_secs(3));
out = ign(config, env, args);
}
out
}
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]T1",
"-o",
export_file.to_str().expect("path"),
"--compact",
],
);
expect_ok("export [p5e2e]T1", &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"], 1, "{envelope}");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let out = read_with_provider_tolerance(
&config,
&env,
&["tags", "read", "[p5import]T1", "--compact"],
);
let landed =
out.status.success() && data_envelope(&out)["data"]["results"][0]["quality"] == "Good";
if landed {
break;
}
assert!(
std::time::Instant::now() < deadline,
"import #1 never landed (the read-back kept missing T1)"
);
eprintln!("import: clean answer but nothing landed, re-importing (bounded 30s)…");
let out = ign(
&config,
&env,
&[
"tags",
"import",
"--file",
export_file.to_str().expect("path"),
"--provider",
"p5import",
"--compact",
],
);
if out.status.code() == Some(6) {
break;
}
expect_ok("import #1 (re-import)", &out);
}
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!(
"{}/data/api/v1/resources/find/com.inductiveautomation.historian/historian-provider/{name}",
env.url
);
let response = client
.get(&find_url)
.header("X-Ignition-API-Token", &env.token)
.send()
.await
.expect("historian find reaches the gateway");
let status = response.status().as_u16();
if status == 404 {
println!("historian {name} already gone (find → 404)");
return;
}
assert_eq!(status, 200, "historian find answered HTTP {status}");
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");
let status = response.status().as_u16();
assert_eq!(
status, 200,
"historian {name} delete answered HTTP {status}"
);
let response = client
.get(&find_url)
.header("X-Ignition-API-Token", &env.token)
.send()
.await
.expect("historian re-find reaches the gateway");
assert_eq!(
response.status().as_u16(),
404,
"historian {name} still findable after delete — the teardown was NOT clean"
);
println!("historian {name} deleted + verified gone (find → 404)");
}
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)
})
}
fn history_rows_contain(envelope: &Value, value: i64) -> bool {
envelope["data"]["rows"]
.as_array()
.map(|rows| {
rows.iter().any(|row| {
row.as_array()
.map(|cells| {
cells.iter().skip(1).any(|cell| {
cell.as_i64() == Some(value) || cell.as_f64() == Some(value as f64)
})
})
.unwrap_or(false)
})
})
.unwrap_or(false)
}
fn ensure_ok(what: &str, out: &Output) -> Result<(), String> {
if out.status.success() {
return Ok(());
}
Err(format!(
"{what} FAILED (exit {:?}):\n--- stdout ---\n{}\n--- stderr ---\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
))
}
#[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_history_bindings() {
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 suffix = now_ms();
let historian = format!("p13hist{suffix}");
let tag = format!("[default]P13H/T{suffix}");
clean_tag_configs(&config, &env, &[&tag]);
delete_internal_historian(&env, &historian).await;
let out = ign(&config, &env, &["webdev", "deploy", "--compact"]);
expect_ok("deploy (the tagHistory route's precondition)", &out);
let sweep_all_present = |config: &Path, env: &LiveEnv| -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let out = ign(config, env, &["webdev", "status", "--compact"]);
let healthy = out.status.success()
&& ["tags", "tagConfig", "alarms", "tagHistory"]
.iter()
.all(|route| {
data_envelope(&out)["data"]["routes"]
.as_array()
.is_some_and(|rows| {
rows.iter().any(|row| {
row["route"].as_str() == Some(*route)
&& row["status"].as_str() == Some("present")
})
})
});
if healthy || std::time::Instant::now() >= deadline {
return healthy;
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
};
if !sweep_all_present(&config, &env) {
eprintln!("deploy: mount race (a core route absent) — redeploying once (11-06 lesson)");
let out = ign(&config, &env, &["webdev", "deploy", "--compact"]);
expect_ok("redeploy (mount-race heal)", &out);
assert!(
sweep_all_present(&config, &env),
"core routes still absent after the heal redeploy"
);
}
let outcome = historian_binding_body(&config, &env, &historian, &tag).await;
clean_tag_configs(&config, &env, &[&tag]);
delete_internal_historian(&env, &historian).await;
if let Err(message) = outcome {
panic!("{message}");
}
}
async fn historian_binding_body(
config: &Path,
env: &LiveEnv,
historian: &str,
tag: &str,
) -> Result<(), String> {
provision_internal_historian(env, historian).await;
let mut last_miss = String::new();
for cycle in 1..=3 {
if cycle > 1 {
eprintln!(
"bind cycle {cycle}: prior cycle stored nothing — delete → settle → recreate"
);
}
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
clean_tag_configs(config, env, std::slice::from_ref(&tag));
let out = ign_stdin(
config,
env,
&["tags", "config", "create", tag, "--file", "-", "--compact"],
&format!(
r#"{{"tagType": "AtomicTag", "dataType": "Int4", "value": 0, "historyEnabled": true, "historyProvider": "{historian}", "sampleMode": "TagGroup"}}"#
),
);
ensure_ok("config create the bound tag (closure recipe)", &out)?;
let out = ign(config, env, &["tags", "config", "get", tag]);
ensure_ok("config get the bound tag", &out)?;
let stdout = String::from_utf8_lossy(&out.stdout);
for needle in [
"history:",
" enabled: true",
&format!(" provider: {historian}"),
" sample mode: TagGroup",
] {
if !stdout.contains(needle) {
return Err(format!("TAGS-13 live render missing {needle:?}:\n{stdout}"));
}
}
println!("TAGS-13 live: history block present for {tag} (provider {historian})");
let start_ms = now_ms() - 60_000;
for value in [42, 43, 44] {
let out = ign(
config,
env,
&[
"tags",
"write",
tag,
"--value",
&value.to_string(),
"--compact",
],
);
ensure_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| {
vec![
"tags".to_string(),
"history".to_string(),
"query".to_string(),
tag.to_string(),
"--start".to_string(),
start_ms.to_string(),
"--end".to_string(),
end_ms.to_string(),
"--compact".to_string(),
]
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(75);
loop {
let args = query_args(start_ms, end_ms);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let out = ign(config, env, &refs);
ensure_ok("history query", &out)?;
let envelope = data_envelope(&out);
let columns = envelope["data"]["columns"]
.as_array()
.cloned()
.unwrap_or_default();
if columns.first().and_then(Value::as_str) != Some("t_stamp") {
return Err(format!("history query lost the t_stamp column: {envelope}"));
}
let relative = tag
.trim_start_matches('[')
.split_once(']')
.map(|(_, rest)| rest)
.unwrap_or(tag);
if !columns
.iter()
.any(|column| column.as_str() == Some(relative))
{
return Err(format!(
"the tag column rides provider-relative: {envelope}"
));
}
if history_rows_contain(&envelope, 44) {
println!("SC-4 closure evidence: written value 44 proven in history ({historian})");
return Ok(());
}
last_miss = serde_json::to_string(&envelope["data"]["rows"]).unwrap_or_default();
if std::time::Instant::now() >= deadline {
break;
}
eprintln!("history: no 44 yet (scan cadence 40–50 s) — rows so far: {last_miss}");
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
}
}
Err(format!(
"CLOSURE DRIFT FINDING: the 13-01 recipe live-proved data flow, but 3 self-healing \
cycles of polling never surfaced the written 44 (last rows: {last_miss}) — record \
in 13-LIVE-GATE.md (capture wins; never code around it)"
))
}
#[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 provider = format!(
"p5alarm{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_secs()
);
let tag = format!("[{provider}]AlarmTag");
let out = ign(&config, &env, &["webdev", "deploy", "--compact"]);
expect_ok("deploy (the alarms route's precondition)", &out);
for _ in 0..3 {
clean_provider(&config, &env, &provider);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let out = ign(&config, &env, &["tags", "provider", "list", "--compact"]);
expect_ok("provider list (settle poll)", &out);
let envelope = data_envelope(&out);
let gone = !envelope["data"]["providers"]
.as_array()
.expect("providers[]")
.iter()
.any(|p| p["name"] == provider);
if gone || std::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
let out = ign(
&config,
&env,
&["tags", "provider", "create", &provider, "--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);
if await_tag_ready(&config, &env, &tag, std::time::Duration::from_secs(60)).await {
break;
}
eprintln!("setup: tag model stuck Error_Configuration, tearing down and retrying…");
}
assert!(
await_tag_ready(&config, &env, &tag, std::time::Duration::from_secs(1)).await,
"the alarmed tag never reached a Good model after 3 setup cycles"
);
async fn await_tag_ready(
config: &Path,
env: &LiveEnv,
tag: &str,
budget: std::time::Duration,
) -> bool {
let deadline = std::time::Instant::now() + budget;
loop {
let out = ign(config, env, &["tags", "read", tag, "--compact"]);
expect_ok("read (model-ready poll)", &out);
let envelope = data_envelope(&out);
let quality = envelope["data"]["results"][0]["quality"]
.as_str()
.unwrap_or_default();
if quality == "Good" {
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
async fn write_verified(config: &Path, env: &LiveEnv, tag: &str, value: &str, what: &str) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let out = ign(
config,
env,
&["tags", "write", tag, "--value", value, "--compact"],
);
expect_ok(what, &out);
let out = ign(config, env, &["tags", "read", tag, "--compact"]);
expect_ok("read-back after write", &out);
let envelope = data_envelope(&out);
let landed = envelope["data"]["results"][0]["value"].as_i64();
if landed == value.parse::<i64>().ok() {
return;
}
assert!(
std::time::Instant::now() < deadline,
"{what}: the write never landed (read-back kept disagreeing)"
);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
write_verified(&config, &env, &tag, "150", "write past the setpoint").await;
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(&provider))
})
.cloned()
})
};
let mut event = None;
for attempt in 0..10 {
if attempt > 0 {
write_verified(
&config,
&env,
&tag,
"0",
"write below the setpoint (re-trigger clear)",
)
.await;
write_verified(
&config,
&env,
&tag,
"150",
"write past the setpoint (re-trigger set)",
)
.await;
}
for _ in 0..8 {
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_secs(1)).await;
}
if event.is_some() {
break;
}
}
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",
&provider,
"--yes",
"--compact",
],
);
expect_ok("provider delete (guarded)", &out);
}
use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
use quick_xml::reader::Reader;
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Canon {
name: String,
attrs: Vec<(String, String)>,
children: Vec<Canon>,
text: String,
}
fn canon_attrs(element: &BytesStart) -> Vec<(String, String)> {
let mut attrs: Vec<(String, String)> = element
.attributes()
.flatten()
.filter_map(|attr| {
attr.normalized_value(XmlVersion::Implicit1_0)
.ok()
.map(|v| {
(
String::from_utf8_lossy(attr.key.as_ref()).into_owned(),
v.into_owned(),
)
})
})
.collect();
attrs.sort();
attrs
}
fn serialize_canonical(node: &Canon) -> String {
let mut out = format!("<{}", node.name);
for (key, value) in &node.attrs {
out.push_str(&format!(" {key}=\"{value}\""));
}
let mut kids: Vec<String> = node.children.iter().map(serialize_canonical).collect();
kids.sort();
if kids.is_empty() && node.text.is_empty() {
out.push_str("/>");
return out;
}
out.push('>');
out.push_str(&node.text);
for kid in kids {
out.push_str(&kid);
}
out.push_str(&format!("</{}>", node.name));
out
}
fn parse_canonical(raw: &[u8]) -> Canon {
let mut reader = Reader::from_reader(raw);
let mut stack: Vec<Canon> = Vec::new();
let mut buf = Vec::new();
loop {
buf.clear();
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => stack.push(Canon {
name: String::from_utf8_lossy(e.name().as_ref()).into_owned(),
attrs: canon_attrs(e),
children: Vec::new(),
text: String::new(),
}),
Ok(Event::Empty(ref e)) => {
let node = Canon {
name: String::from_utf8_lossy(e.name().as_ref()).into_owned(),
attrs: canon_attrs(e),
children: Vec::new(),
text: String::new(),
};
stack
.last_mut()
.expect("Empty event always has a parent")
.children
.push(node);
}
Ok(Event::End(_)) => {
let node = stack.pop().expect("balanced document");
match stack.last_mut() {
Some(parent) => parent.children.push(node),
None => return node,
}
}
Ok(Event::Text(ref t)) => {
let Ok(text) = t.decode() else { continue };
if !text.trim().is_empty()
&& let Some(parent) = stack.last_mut()
{
parent.text.push_str(text.trim());
}
}
Ok(Event::Eof) => break,
Ok(_) => {}
Err(err) => panic!("canonicalizer: oracle inputs are gateway exports: {err}"),
}
}
panic!("canonicalizer: unbalanced document (no root End event)");
}
fn structurally_identical(a: &[u8], b: &[u8]) -> bool {
serialize_canonical(&parse_canonical(a)) == serialize_canonical(&parse_canonical(b))
}
async fn raw_tagconfig_export_xml(env: &LiveEnv, paths: &[&str]) -> Vec<u8> {
let client = reqwest::Client::new();
let url = format!("{}/system/webdev/ign-cli/cli/tagConfig", env.url);
let response = client
.post(&url)
.header("X-Ignition-API-Token", &env.token)
.json(&serde_json::json!({
"action": "exportTags",
"paths": paths,
"format": "xml"
}))
.send()
.await
.expect("tagConfig exportTags reaches the gateway");
assert_eq!(
response.status().as_u16(),
200,
"the webdev servlet answers 200"
);
let body: Value = response.json().await.expect("envelope parses");
assert_eq!(body["ok"], Value::Bool(true), "export answered ok: {body}");
let payload_b64 = body["data"]["payload_b64"]
.as_str()
.unwrap_or_else(|| panic!("payload_b64 present: {body}"));
use base64::Engine as _;
base64::engine::general_purpose::STANDARD
.decode(payload_b64)
.expect("payload_b64 decodes")
}
#[tokio::test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1"]
async fn live_tags_xml_fidelity_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", "--with-script-exec", "--compact"],
);
expect_ok("deploy (the gate's own precondition)", &out);
let out = ign(&config, &env, &["webdev", "status", "--compact"]);
expect_ok("status after self-deploy", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["ok"], Value::Bool(true), "{envelope}");
for route in ["tags", "tagConfig", "alarms", "tagHistory", "scriptExec"] {
let row = status_row(&envelope, route);
assert_eq!(row["status"], "present", "{route}: {row}");
assert_eq!(
row["deployed_version"],
ignition_core::webdev::ROUTE_BUNDLE_VERSION,
"{route}: the current-bundle handshake pin: {row}"
);
}
clean_tag_configs(
&config,
&env,
&["[default]P11Live", "[default]_types_/MotorType"],
);
clean_provider(&config, &env, "p11live");
let tmp = tempfile::tempdir().expect("tempdir");
let type_def = tmp.path().join("motor-type.json");
std::fs::write(
&type_def,
serde_json::json!({
"tagType": "UdtType",
"parameters": {"MotorNumber": {"dataType": "Integer", "value": 1}},
"tags": [
{
"name": "Amps",
"tagType": "AtomicTag",
"valueSource": "opc",
"opcServer": "Ignition OPC-UA Server",
"opcItemPath": {
"bindType": "parameter",
"binding": "ns=1;s=[Dairy]Motor {MotorNumber}/Amps"
},
"alarms": [
{"name": "Low Amps", "priority": "High", "setpointA": 25}
]
},
{
"name": "Doubled",
"tagType": "AtomicTag",
"valueSource": "expression",
"expression": {"bindType": "parameter", "binding": "{MotorNumber} * 2"}
}
]
})
.to_string(),
)
.expect("write type def");
let out = ign(
&config,
&env,
&[
"tags",
"config",
"create",
"[default]_types_/MotorType",
"--file",
type_def.to_str().expect("path"),
"--compact",
],
);
expect_ok("config create the MotorType UDT definition", &out);
let folder_def = tmp.path().join("p11live.json");
std::fs::write(
&folder_def,
serde_json::json!({
"tagType": "Folder",
"tags": [
{
"name": "M1",
"tagType": "UdtInstance",
"udtParentType": "[default]_types_/MotorType",
"parameters": {"MotorNumber": {"dataType": "Integer", "value": 1}}
},
{
"name": "M2",
"tagType": "UdtInstance",
"udtParentType": "[default]_types_/MotorType",
"parameters": {"MotorNumber": {"dataType": "Integer", "value": 2}}
},
{
"name": "Sub",
"tagType": "Folder",
"tags": [
{
"name": "M3",
"tagType": "UdtInstance",
"udtParentType": "[default]_types_/MotorType",
"parameters": {"MotorNumber": {"dataType": "Integer", "value": 3}}
}
]
}
]
})
.to_string(),
)
.expect("write folder def");
let out = ign(
&config,
&env,
&[
"tags",
"config",
"create",
"[default]P11Live",
"--file",
folder_def.to_str().expect("path"),
"--compact",
],
);
expect_ok("config create the P11Live folder", &out);
let a_path = tmp.path().join("a.xml");
let out = ign(
&config,
&env,
&[
"tags",
"export",
"[default]P11Live",
"--format",
"xml",
"-o",
a_path.to_str().expect("path"),
"--compact",
],
);
expect_ok("export [default]P11Live as xml", &out);
let a_bytes = std::fs::read(&a_path).expect("a.xml readable");
assert!(!a_bytes.is_empty(), "the export is not empty");
let sha_a = sha256_hex(&a_bytes);
let route_bytes = raw_tagconfig_export_xml(&env, &["[default]P11Live"]).await;
let sha_route = sha256_hex(&route_bytes);
assert_eq!(
sha_a, sha_route,
"TRANSPORT FIDELITY: sha256(export file) == sha256(route payload_b64)"
);
println!(
"xml transport fidelity: sha256 {sha_a} ({} bytes)",
a_bytes.len()
);
let out = ign(
&config,
&env,
&[
"tags",
"import",
"--file",
a_path.to_str().expect("path"),
"--format",
"xml",
"--provider",
"p11live",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(2),
"the loss gate refuses exit 2; stdout: {} stderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let envelope = err_envelope(&out);
assert_eq!(envelope["error"]["code"], "invalid_input", "{envelope}");
assert!(
envelope["profile"].is_null(),
"the refusal is PRE-resolution — no profile resolved: {envelope}"
);
let message = envelope["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("loss report (xml)") && message.contains("re-run with --yes"),
"the refusal message IS the report prose: {envelope}"
);
let a2_path = tmp.path().join("a2.xml");
let out = ign(
&config,
&env,
&[
"tags",
"export",
"[default]P11Live",
"--format",
"xml",
"-o",
a2_path.to_str().expect("path"),
"--compact",
],
);
expect_ok("re-export after refusal (the zero-mutation read)", &out);
let a2_bytes = std::fs::read(&a2_path).expect("a2.xml readable");
assert_eq!(
sha256_hex(&a2_bytes),
sha_a,
"ZERO MUTATION: the refusal left the seeded subtree byte-identical"
);
let out = ign(
&config,
&env,
&["tags", "provider", "create", "p11live", "--compact"],
);
expect_ok("provider create p11live", &out);
let out = ign(
&config,
&env,
&[
"tags",
"config",
"create",
"[p11live]_types_/MotorType",
"--file",
type_def.to_str().expect("path"),
"--compact",
],
);
expect_ok("config create the MotorType at the import target", &out);
let import_args = [
"tags",
"import",
"--file",
a_path.to_str().expect("path"),
"--format",
"xml",
"--provider",
"p11live",
"--yes",
"--compact",
];
let out = import_with_mount_tolerance(&config, &env, &import_args);
expect_ok("import --yes (the guarded commit)", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["format"], "xml", "{envelope}");
assert_eq!(
envelope["data"]["failed"].as_array().map(Vec::len),
Some(0),
"the QualityCode backstop stays empty: {envelope}"
);
assert!(
envelope["data"]["loss_report"].is_object(),
"--yes attaches data.loss_report: {envelope}"
);
assert!(
envelope["data"]["top_level_names"]
.as_array()
.expect("top_level_names[]")
.iter()
.any(|name| name == "P11Live"),
"the scan named the landing subtree: {envelope}"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let probe = read_with_provider_tolerance(
&config,
&env,
&["tags", "browse", "[p11live]", "--compact"],
);
let landed = probe.status.success()
&& data_envelope(&probe)["data"]["entries"]
.as_array()
.map(|entries| entries.iter().any(|row| row["name"] == "P11Live"))
.unwrap_or(false);
if landed {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the xml import never landed (browse kept showing an empty root)"
);
eprintln!("import: clean answer but nothing landed, re-importing (bounded 30s)…");
let out = import_with_mount_tolerance(&config, &env, &import_args);
expect_ok("import --yes (re-import)", &out);
}
let b_path = tmp.path().join("b.xml");
let out = read_with_provider_tolerance(
&config,
&env,
&[
"tags",
"export",
"[p11live]P11Live",
"--format",
"xml",
"-o",
b_path.to_str().expect("path"),
"--compact",
],
);
expect_ok("export the imported [p11live]P11Live", &out);
let b_bytes = std::fs::read(&b_path).expect("b.xml readable");
assert_eq!(
a_bytes.len(),
b_bytes.len(),
"LENGTH pre-check (capture (b): 1128 == 1128): a={} b={}",
a_bytes.len(),
b_bytes.len()
);
let sha_b = sha256_hex(&b_bytes);
assert!(
structurally_identical(&a_bytes, &b_bytes),
"ROUND-TRIP ORACLE: order-normalized structural identity failed \
(sha_a {sha_a} vs sha_b {sha_b})"
);
println!(
"xml round-trip: sha_a {sha_a} / sha_b {sha_b} ({} bytes each) — \
structurally identical (byte-{})",
a_bytes.len(),
if sha_a == sha_b {
"identical too"
} else {
"different: sibling order permuted on rebuild, per capture (b)"
}
);
let out = ign(
&config,
&env,
&[
"tags",
"config",
"delete",
"[default]P11Live",
"[default]_types_/MotorType",
"--yes",
"--compact",
],
);
expect_ok("config delete cleanup (seeded paths + type)", &out);
let out = ign(
&config,
&env,
&[
"tags",
"provider",
"delete",
"p11live",
"--yes",
"--compact",
],
);
expect_ok("provider delete p11live (guarded)", &out);
}
#[tokio::test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1"]
async fn live_tags_csv_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", "--with-script-exec", "--compact"],
);
expect_ok("deploy (the gate's own precondition)", &out);
clean_tag_configs(&config, &env, &["[default]P11CsvSeed"]);
clean_provider(&config, &env, "p11csv");
let tmp = tempfile::tempdir().expect("tempdir");
let seed = tmp.path().join("seed.json");
std::fs::write(
&seed,
serde_json::json!({
"tagType": "Folder",
"tags": [
{
"name": "TMem",
"tagType": "AtomicTag",
"dataType": "Int4",
"value": 42,
"tooltip": "e2e csv",
"engUnit": "PSI"
},
{
"name": "TExpr",
"tagType": "AtomicTag",
"valueSource": "expression",
"expression": "1+1"
},
{
"name": "TAlarmed",
"tagType": "AtomicTag",
"dataType": "Int4",
"value": 0,
"alarms": [
{"name": "HighLimit", "mode": "AboveValue", "setpointA": 100, "priority": "High"}
]
}
]
})
.to_string(),
)
.expect("write seed");
let out = ign(
&config,
&env,
&[
"tags",
"config",
"create",
"[default]P11CsvSeed",
"--file",
seed.to_str().expect("path"),
"--compact",
],
);
expect_ok("config create the P11CsvSeed folder", &out);
let orig_path = tmp.path().join("orig.json");
let out = ign(
&config,
&env,
&[
"tags",
"export",
"[default]P11CsvSeed/TMem",
"[default]P11CsvSeed/TExpr",
"[default]P11CsvSeed/TAlarmed",
"--format",
"json",
"-o",
orig_path.to_str().expect("path"),
"--compact",
],
);
expect_ok("export the original as json", &out);
let orig: Vec<Value> =
serde_json::from_slice(&std::fs::read(&orig_path).expect("orig.json readable"))
.expect("orig.json parses as a subtree list");
let orig_tmem = orig
.iter()
.find(|tag| tag["name"] == "TMem")
.expect("TMem in the original export");
for sheet_key in ["AlertAckMode", "deadband", "rawHigh", "tagGroup"] {
assert!(
orig_tmem.get(sheet_key).is_none(),
"baseline: configure-created tags carry no '{sheet_key}': {orig_tmem}"
);
}
let c_path = tmp.path().join("c.csv");
let out = ign(
&config,
&env,
&[
"tags",
"export",
"[default]P11CsvSeed",
"--format",
"csv",
"-o",
c_path.to_str().expect("path"),
"--compact",
],
);
expect_ok("export the seed as generated csv", &out);
let csv_text = std::fs::read_to_string(&c_path).expect("c.csv readable");
assert!(
csv_text.starts_with("Path,Name,") && csv_text.contains("# version=1"),
"the generated CSV carries the legacy header + version marker"
);
let out = ign(
&config,
&env,
&["tags", "provider", "create", "p11csv", "--compact"],
);
expect_ok("provider create p11csv", &out);
let import_args = [
"tags",
"import",
"--file",
c_path.to_str().expect("path"),
"--format",
"csv",
"--provider",
"p11csv",
"--collision-policy",
"overwrite",
"--yes",
"--compact",
];
let out = import_with_mount_tolerance(&config, &env, &import_args);
expect_ok("import the generated csv --yes", &out);
let envelope = data_envelope(&out);
assert_eq!(envelope["data"]["format"], "csv", "{envelope}");
assert_eq!(
envelope["data"]["failed"].as_array().map(Vec::len),
Some(0),
"all-or-nothing import answered clean: {envelope}"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let probe = read_with_provider_tolerance(
&config,
&env,
&["tags", "browse", "[p11csv]", "--compact"],
);
let landed = probe.status.success()
&& data_envelope(&probe)["data"]["entries"]
.as_array()
.map(|entries| entries.iter().any(|row| row["name"] == "TMem"))
.unwrap_or(false);
if landed {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the csv import never landed (browse kept showing an empty root)"
);
eprintln!("import: clean answer but nothing landed, re-importing (bounded 30s)…");
let out = import_with_mount_tolerance(&config, &env, &import_args);
expect_ok("import the generated csv --yes (re-import)", &out);
}
let back_path = tmp.path().join("back.json");
let out = read_with_provider_tolerance(
&config,
&env,
&[
"tags",
"export",
"[p11csv]TMem",
"[p11csv]TExpr",
"[p11csv]TAlarmed",
"--format",
"json",
"-o",
back_path.to_str().expect("path"),
"--compact",
],
);
expect_ok("export the imported tags as json", &out);
let back: Vec<Value> =
serde_json::from_slice(&std::fs::read(&back_path).expect("back.json readable"))
.expect("back.json parses as a subtree list");
let find = |name: &str| -> Value {
back.iter()
.find(|tag| tag["name"] == name)
.unwrap_or_else(|| panic!("{name} in the imported export: {back:?}"))
.clone()
};
let tmem = find("TMem");
assert_eq!(tmem["value"], 42, "CSV Value lands: {tmem}");
assert_eq!(tmem["tooltip"], "e2e csv", "CSV Tooltip lands: {tmem}");
assert_eq!(tmem["engUnit"], "PSI", "CSV EngUnit lands: {tmem}");
assert_eq!(find("TExpr")["expression"], "1+1", "CSV Expression lands");
let tal = find("TAlarmed");
assert!(
tal.get("alarms").is_none(),
"CSV alarms are SILENTLY dropped — none may arrive: {tal}"
);
for sheet_key in [
"deadband",
"rawHigh",
"scaledHigh",
"engHigh",
"formatString",
"historicalDeadband",
"historicalDeadbandStyle",
"historyMaxAge",
"historyTagGroup",
"tagGroup",
] {
assert!(
tmem.get(sheet_key).is_some(),
"CSV import materializes '{sheet_key}' (column present in the header): {tmem}"
);
}
assert!(
tmem.get("AlertAckMode").is_none(),
"no Alert* column in the header → no Alert key materializes: {tmem}"
);
println!(
"csv round-trip diff vs coverage table: value/tooltip/engUnit/expression LANDED; \
alarms SILENTLY absent; per-column legacy sheet materialized (no Alert keys — \
no Alert columns) — live re-proven"
);
let out = ign(
&config,
&env,
&[
"tags",
"config",
"delete",
"[default]P11CsvSeed",
"[p11csv]TMem",
"[p11csv]TExpr",
"[p11csv]TAlarmed",
"--yes",
"--compact",
],
);
expect_ok("config delete cleanup (both sides)", &out);
let out = ign(
&config,
&env,
&["tags", "provider", "delete", "p11csv", "--yes", "--compact"],
);
expect_ok("provider delete p11csv (guarded)", &out);
}
#[tokio::test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN (read-only — the refusal is pre-resolution, no mutations env needed)"]
async fn live_tags_loss_gate_refusal() {
let Some(env) = ({
match (live_url(), live_token()) {
(Some(url), Some(token)) => Some(LiveEnv { url, token }),
_ => None,
}
}) else {
skip(
"IGNITION_LIVE_URL/IGNITION_LIVE_TOKEN not set — nothing to point the read-only refusal at",
);
return;
};
let (_dir, config) = isolated_live_config(&env);
let _gate = LIVE_GATE.lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
let fixture = tmp.path().join("lossy.xml");
std::fs::write(
&fixture,
r#"<Tags MinVersion="8.0.0" locale="en_US">
<Tag name="MotorType" type="UdtType">
<Parameters>
<Property name="MotorNumber" type="Integer">1</Property>
</Parameters>
<Tags>
<Tag name="Amps" type="AtomicTag">
<Property name="valueSource">opc</Property>
<CompoundProperty name="alarms">
<PropertySet>
<Property name="setpointA">25</Property>
<Property name="name">Low Amps</Property>
<Property name="priority">3</Property>
</PropertySet>
</CompoundProperty>
</Tag>
</Tags>
</Tag>
</Tags>
"#,
)
.expect("write fixture");
let out = ign(
&config,
&env,
&[
"tags",
"import",
"--file",
fixture.to_str().expect("path"),
"--format",
"xml",
"--provider",
"default",
"--compact",
],
);
assert_eq!(
out.status.code(),
Some(2),
"the loss gate refuses exit 2; stdout: {} stderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let envelope = err_envelope(&out);
assert_eq!(envelope["error"]["code"], "invalid_input", "{envelope}");
assert!(
envelope["profile"].is_null(),
"PRE-RESOLUTION: no profile resolved, zero wire work: {envelope}"
);
let message = envelope["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("loss report (xml)"),
"the report prose leads the refusal: {envelope}"
);
assert!(
message.contains("[xml_export_edited_only]"),
"the MinVersion fact is named: {envelope}"
);
assert!(
message.contains("[xml_udt_type_definition]"),
"the UdtType-definition fact is named: {envelope}"
);
assert!(
message.contains("re-run with --yes to import anyway"),
"the --yes hint closes the prose: {envelope}"
);
assert!(
!message.contains("route_error") && !message.contains("gateway_client_error"),
"ZERO route calls: no route-layer error shape anywhere: {envelope}"
);
println!(
"loss-gate refusal: exit 2 pre-resolution, report prose carried \
xml_export_edited_only + xml_udt_type_definition, zero route calls"
);
}