use std::path::{Path, PathBuf};
use std::process::Output;
use std::time::{SystemTime, UNIX_EPOCH};
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 live_mutations() -> bool {
std::env::var("IGNITION_LIVE_MUTATIONS").is_ok_and(|value| value == "1")
}
fn skip(message: &str) {
eprintln!("skipping: {message}");
}
fn live_env() -> Option<LiveEnv> {
match (live_url(), live_token()) {
(Some(url), Some(token)) => Some(LiveEnv { url, token }),
_ => None,
}
}
fn live_env_mutations() -> Option<LiveEnv> {
if !live_mutations() {
return None;
}
live_env()
}
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 {
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);
command.args(args).output().expect("spawn ign")
}
fn timestamped_name(suffix: &str) -> String {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after the unix epoch")
.as_secs();
format!("ign-e2e-{ts}{suffix}")
}
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 expect_exit(out: &Output, code: i32, slug: &str, what: &str) -> Value {
assert_eq!(
out.status.code(),
Some(code),
"{what} must exit {code} (got {:?}):\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
let envelope: Value = serde_json::from_str(stderr[stderr.find('{').unwrap_or(0)..].trim())
.unwrap_or_else(|err| panic!("stderr envelope parses ({err}): {stderr}"));
assert_eq!(
envelope["error"]["code"], slug,
"{what} must be {slug}: {envelope}"
);
envelope
}
const SCRATCH_PATH: &str = "ignition/script-python/e2e/scratch";
const SCRATCH2_PATH: &str = "ignition/script-python/e2e/scratch2";
const SCRATCH3_PATH: &str = "ignition/script-python/uat2/second.py";
#[test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1"]
fn full_project_resource_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 name = timestamped_name("");
let workdir = tempfile::tempdir().expect("workdir");
let zip_path = workdir.path().join("loop.zip");
expect_ok(
"project new",
&ign(&config, &env, &["project", "new", &name, "--compact"]),
);
let out = ign(&config, &env, &["project", "list", "--compact"]);
expect_ok("project list", &out);
let listing = data_envelope(&out);
let listed = listing["data"]["projects"]
.as_array()
.expect("projects array")
.iter()
.any(|project| project["name"] == Value::String(name.clone()));
assert!(listed, "the new project appears in `project list`");
let out = ign(&config, &env, &["resource", "list", &name, "--compact"]);
expect_ok("resource list (fresh)", &out);
let fresh = data_envelope(&out)["data"].clone();
assert_eq!(
fresh["resources"],
Value::Array(vec![]),
"a brand-new project exports zero resource members: {fresh}"
);
let scratch_body = format!(
r#"{{"scope":"G","code":"print('{}')", "e2e": true}}"#,
name.replace('\'', "")
);
let scratch_file = workdir.path().join("scratch.json");
std::fs::write(&scratch_file, &scratch_body).expect("write scratch body");
expect_ok(
"resource put scratch",
&ign(
&config,
&env,
&[
"resource",
"put",
&name,
SCRATCH_PATH,
"--file",
scratch_file.to_str().unwrap(),
"--yes",
"--compact",
],
),
);
let out = ign(
&config,
&env,
&["resource", "get", &name, SCRATCH_PATH, "--compact"],
);
expect_ok("resource get scratch", &out);
let got = data_envelope(&out)["data"].clone();
assert_eq!(got["content_kind"], Value::String("json".into()));
assert_eq!(
got["content"]["e2e"],
Value::Bool(true),
"the written content reads back: {got}"
);
let out = ign(&config, &env, &["resource", "list", &name, "--compact"]);
expect_ok("resource list (populated)", &out);
let populated = data_envelope(&out)["data"].clone();
assert!(
populated["resources"]
.as_array()
.expect("resources array")
.iter()
.any(|entry| entry["path"] == Value::String(SCRATCH_PATH.into())),
"the put member appears in the surgery list: {populated}"
);
let out = ign(
&config,
&env,
&["resource", "get", &name, SCRATCH_PATH, "--compact"],
);
expect_ok("resource get scratch (pre-second-put)", &out);
let old_content = data_envelope(&out)["data"]["content"].clone();
assert_eq!(
old_content["e2e"],
Value::Bool(true),
"the export BEFORE the second put carries the member with OLD content: {old_content}"
);
let out = ign(
&config,
&env,
&[
"project",
"export",
&name,
"-o",
zip_path.to_str().unwrap(),
"--compact",
],
);
expect_ok("project export", &out);
let export = data_envelope(&out)["data"].clone();
assert!(zip_path.exists(), "the export file landed");
assert!(export["bytes"].as_u64().expect("bytes") > 0);
assert!(
export["scope"]["includes"]
.as_array()
.unwrap()
.iter()
.any(|v| v == "scripts"),
"scope.includes carries scripts: {export}"
);
assert!(
export["scope"]["excludes"]
.as_array()
.unwrap()
.iter()
.any(|v| v == "tags"),
"scope.excludes carries tags: {export}"
);
let scratch_new = workdir.path().join("scratch-new.json");
std::fs::write(
&scratch_new,
r#"{"scope":"G","code":"print('second-edition')", "e2e": true, "edited": true}"#,
)
.expect("write scratch-new body");
expect_ok(
"resource put scratch (new content)",
&ign(
&config,
&env,
&[
"resource",
"put",
&name,
SCRATCH_PATH,
"--file",
scratch_new.to_str().unwrap(),
"--yes",
"--compact",
],
),
);
let out = ign(
&config,
&env,
&["resource", "get", &name, SCRATCH_PATH, "--compact"],
);
expect_ok("resource get scratch (post-second-put)", &out);
let new_content = data_envelope(&out)["data"]["content"].clone();
assert_eq!(
new_content["edited"],
Value::Bool(true),
"the export AFTER the second put carries the member with NEW content: {new_content}"
);
let scratch2_file = workdir.path().join("scratch2.json");
std::fs::write(&scratch2_file, r#"{"scope":"G","code":"print('second')"}"#)
.expect("write scratch2 body");
expect_ok(
"resource put scratch2",
&ign(
&config,
&env,
&[
"resource",
"put",
&name,
SCRATCH2_PATH,
"--file",
scratch2_file.to_str().unwrap(),
"--yes",
"--compact",
],
),
);
let scratch3_file = workdir.path().join("scratch3.txt");
std::fs::write(&scratch3_file, "print('used-project-put-new')\n").expect("write scratch3");
expect_ok(
"resource put scratch3 (new chain, used project)",
&ign(
&config,
&env,
&[
"resource",
"put",
&name,
SCRATCH3_PATH,
"--file",
scratch3_file.to_str().unwrap(),
"--yes",
"--compact",
],
),
);
let out = ign(
&config,
&env,
&["resource", "get", &name, SCRATCH3_PATH, "--compact"],
);
expect_ok("resource get scratch3", &out);
let landed = data_envelope(&out)["data"].clone();
assert_eq!(
landed["content_kind"],
Value::String("text".into()),
"plain text put classifies text: {landed}"
);
assert_eq!(
landed["content"],
Value::String("print('used-project-put-new')\n".into()),
"THE UAT Gap-1 truth: put-new LANDS on a used project — get reads it back: {landed}"
);
let out = ign(&config, &env, &["resource", "list", &name, "--compact"]);
expect_ok("resource list (scratch3 landed)", &out);
let listed3 = data_envelope(&out)["data"].clone();
assert!(
listed3["resources"]
.as_array()
.expect("resources array")
.iter()
.any(|entry| entry["path"] == Value::String(SCRATCH3_PATH.into())),
"the new-chain member appears in the list: {listed3}"
);
assert!(
listed3["resources"]
.as_array()
.expect("resources array")
.iter()
.any(|entry| entry["path"]
== Value::String("ignition/script-python/uat2/resource.json".into())),
"the synthesized parent descriptor rides the archive (the live-proven landing shape): {listed3}"
);
let abort = ign(
&config,
&env,
&[
"project",
"import",
&name,
"--file",
zip_path.to_str().unwrap(),
"--compact",
],
);
expect_exit(&abort, 6, "project_exists", "abort import");
expect_ok(
"overwrite import",
&ign(
&config,
&env,
&[
"project",
"import",
&name,
"--file",
zip_path.to_str().unwrap(),
"--collision-policy",
"overwrite",
"--yes",
"--compact",
],
),
);
let out = ign(
&config,
&env,
&["resource", "get", &name, SCRATCH_PATH, "--compact"],
);
expect_ok("resource get scratch post-import", &out);
let survived = data_envelope(&out)["data"].clone();
assert_eq!(
survived["content"]["e2e"],
Value::Bool(true),
"the pre-export resource survived the overwrite import: {survived}"
);
assert_eq!(
survived["content"]["edited"],
Value::Null,
"…and carries the IN-ZIP (old) content — the import replaced the later edit: {survived}"
);
let wiped = ign(
&config,
&env,
&["resource", "get", &name, SCRATCH2_PATH, "--compact"],
);
expect_exit(&wiped, 6, "not_found", "resource get scratch2 post-import");
let wiped3 = ign(
&config,
&env,
&["resource", "get", &name, SCRATCH3_PATH, "--compact"],
);
expect_exit(&wiped3, 6, "not_found", "resource get scratch3 post-import");
expect_ok(
"resource delete scratch",
&ign(
&config,
&env,
&[
"resource",
"delete",
&name,
SCRATCH_PATH,
"--yes",
"--compact",
],
),
);
let deleted = ign(
&config,
&env,
&["resource", "get", &name, SCRATCH_PATH, "--compact"],
);
expect_exit(&deleted, 6, "not_found", "resource get scratch post-delete");
let renamed = timestamped_name("-renamed");
expect_ok(
"project rename",
&ign(
&config,
&env,
&["project", "rename", &name, &renamed, "--compact"],
),
);
let copied = timestamped_name("-copy");
expect_ok(
"project copy",
&ign(
&config,
&env,
&["project", "copy", &renamed, &copied, "--compact"],
),
);
for candidate in [name.as_str(), renamed.as_str(), copied.as_str()] {
let _ = ign(
&config,
&env,
&["project", "delete", candidate, "--yes", "--compact"],
)
.status
.success();
}
}
#[tokio::test]
#[ignore = "opt-in: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN"]
async fn openapi_capture_writes_phase3_extract() {
let Some(env) = live_env() else {
skip("IGNITION_LIVE_URL / IGNITION_LIVE_TOKEN not both set");
return;
};
let url = format!("{}/openapi.json", env.url.trim_end_matches('/'));
let response = reqwest::Client::new()
.get(&url)
.header("X-Ignition-API-Token", &env.token)
.send()
.await
.unwrap_or_else(|err| panic!("openapi fetch from {url}: {err}"));
assert!(
response.status().is_success(),
"openapi fetch answered {} — check the token's permissions",
response.status()
);
let spec: Value = response.json().await.expect("openapi.json parses as JSON");
let families = [
"/data/api/v1/projects",
"/data/api/v1/scan",
"/data/api/v1/resources",
];
let mut trimmed = serde_json::Map::new();
if let Some(paths) = spec["paths"].as_object() {
for (path, operations) in paths {
if families.iter().any(|prefix| path.starts_with(prefix)) {
trimmed.insert(path.clone(), operations.clone());
}
}
}
let projects_present = trimmed
.keys()
.any(|path| path.starts_with("/data/api/v1/projects"));
assert!(
projects_present,
"the official openapi carries the projects family — a negative means the spec moved"
);
let resources_present = trimmed
.keys()
.any(|path| path.contains("/projects/{project}/resources") || path.contains("/resources"));
println!(
"project-resources family present in openapi.json: {resources_present} \
(FALSE settles 03-RESEARCH Open Question 1 against ignition-mcp — \
re-plan the resource family from the extract)"
);
let extract = serde_json::json!({
"captured_from": url,
"families": families,
"path_count": trimmed.len(),
"project_resources_present": resources_present,
"info": spec["info"],
"paths": Value::Object(trimmed),
});
let out: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(".planning/phases/03-project-operations/openapi-8.3.6-phase3-extract.json");
std::fs::write(
&out,
serde_json::to_vec_pretty(&extract).expect("extract serializes"),
)
.unwrap_or_else(|err| panic!("write {}: {err}", out.display()));
println!(
"wrote {} ({} phase-3 paths)",
out.display(),
extract["path_count"]
);
}
fn live_url_b() -> Option<String> {
std::env::var("IGNITION_LIVE_URL_B")
.ok()
.filter(|value| !value.trim().is_empty())
}
#[tokio::test]
#[ignore = "opt-in: set IGNITION_LIVE_URL + IGNITION_LIVE_URL_B + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1"]
async fn project_sync_two_gateways_witness() {
let Some(env) = live_env_mutations() else {
skip("IGNITION_LIVE_URL / IGNITION_LIVE_TOKEN / IGNITION_LIVE_MUTATIONS not all set");
return;
};
let Some(url_b) = live_url_b() else {
skip("IGNITION_LIVE_URL_B not set (no second gateway)");
return;
};
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\n[profiles.other]\nurl = \"{}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n",
env.url, url_b
),
)
.expect("write config");
let ign_two = |args: &[&str]| -> Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", &config)
.env("IGNITION_TOKEN", &env.token);
command.args(args).output().expect("spawn ign")
};
let name = timestamped_name("-sync");
let path = "ignition/script-python/uat/synced";
expect_ok(
"project new on A",
&ign_two(&["--profile", "dev", "project", "new", &name, "--compact"]),
);
expect_ok(
"project new on B",
&ign_two(&["--profile", "other", "project", "new", &name, "--compact"]),
);
let body_dir = tempfile::tempdir().expect("body dir");
let body_file = body_dir.path().join("synced.json");
let body = br#"{"scope":"G","code":"print('promoted by sync')"}"#;
std::fs::write(&body_file, body).expect("write body");
expect_ok(
"resource put on A",
&ign_two(&[
"--profile",
"dev",
"resource",
"put",
&name,
path,
"--file",
body_file.to_str().unwrap(),
"--yes",
"--compact",
]),
);
let diff_out = ign_two(&[
"project",
"diff",
"dev",
"other",
"--project",
&name,
"--compact",
]);
expect_ok("project diff", &diff_out);
let diff = data_envelope(&diff_out);
let entries = diff["data"]["entries"]
.as_array()
.cloned()
.unwrap_or_default();
let entry = entries
.iter()
.find(|entry| entry["path"].as_str() == Some(path))
.unwrap_or_else(|| panic!("diff carries the resource: {diff}"));
assert_eq!(entry["status"], Value::String("removed".into()));
let sync_out = ign_two(&[
"project",
"sync",
"dev",
"other",
"--project",
&name,
"--resource",
path,
"--yes",
"--compact",
]);
expect_ok("project sync", &sync_out);
let synced = data_envelope(&sync_out);
assert_eq!(
synced["data"]["synced"],
Value::Array(vec![Value::String(path.into())]),
"the synced list names exactly the promoted path: {synced}"
);
let get_out = ign_two(&[
"--profile",
"other",
"resource",
"get",
&name,
path,
"--compact",
]);
expect_ok("resource get on B", &get_out);
let got = data_envelope(&get_out);
let expected: Value = serde_json::from_slice(body).expect("body parses");
assert_eq!(
got["data"]["content"], expected,
"B adopted A's member content: {got}"
);
for profile in ["dev", "other"] {
let _ = ign_two(&[
"--profile",
profile,
"project",
"delete",
&name,
"--yes",
"--compact",
])
.status
.success();
}
}