use std::path::{Path, PathBuf};
use assert_cmd::Command;
use serde_json::Value;
fn isolated_config() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
(dir, path)
}
fn write_profile_config(config: &Path, url: &str) {
std::fs::write(
config,
format!(
"active = \"dev\"\n\n[profiles.dev]\nurl = \"{url}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n"
),
)
.expect("write config");
}
fn ign(config: &Path, url: &str, args: &[&str]) -> std::process::Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_TOKEN", "mock:name-key")
.env("IGNITION_URL", url);
command.args(args).output().expect("spawn ign")
}
fn stdout_for_golden(out: &std::process::Output) -> &str {
let stdout = std::str::from_utf8(&out.stdout).expect("utf-8 stdout");
stdout.strip_suffix('\n').unwrap_or(stdout)
}
fn stderr_envelope(out: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&out.stderr);
let start = stderr.find('{').unwrap_or(0);
stderr[start..].to_string()
}
async fn mount_projects_list(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/list"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [
{
"name": "PlantFloor",
"title": "Plant Floor",
"description": "Line control screens",
"enabled": true,
"parent": "Base",
"inheritable": true,
"defaultDb": "MyPostgres",
"runtimeUsageFlags": 0
},
{
"name": "Base",
"enabled": true
}
],
"metadata": {"total": 2, "matching": 2, "limit": -1, "offset": 0}
})),
)
.expect(1..)
.mount(server)
.await;
}
#[tokio::test]
async fn project_list_render_modes_golden() {
let server = wiremock::MockServer::start().await;
mount_projects_list(&server).await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["project", "list", "--json"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
{
"ok": true,
"profile": "dev",
"data": {
"projects": [
{
"name": "PlantFloor",
"title": "Plant Floor",
"description": "Line control screens",
"enabled": true,
"parent": "Base",
"inheritable": true
},
{
"name": "Base",
"title": null,
"description": null,
"enabled": true,
"parent": null,
"inheritable": null
}
]
}
}
"#]],
);
let out = ign(&config, &server.uri(), &["project", "list"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
PlantFloor Plant Floor true Base true
Base - true - -
"#]],
);
let out = ign(&config, &server.uri(), &["project", "list", "--compact"]);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"projects":[{"name":"PlantFloor","title":"Plant Floor","description":"Line control screens","enabled":true,"parent":"Base","inheritable":true},{"name":"Base","title":null,"description":null,"enabled":true,"parent":null,"inheritable":null}]}}"#]],
);
}
#[tokio::test]
async fn project_new_success_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/data/api/v1/projects"))
.and(wiremock::matchers::body_json(serde_json::json!({
"name": "child", "enabled": true, "title": "Child", "parent": "Base"
})))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/find/child"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": "child", "title": "Child", "enabled": true,
"parent": "Base", "inheritable": false
})),
)
.expect(1..)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
&server.uri(),
&[
"project", "new", "child", "--title", "Child", "--parent", "Base",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
created child (parent Base)
"#]],
);
let out = ign(
&config,
&server.uri(),
&[
"project",
"new",
"child",
"--title",
"Child",
"--parent",
"Base",
"--compact",
],
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"name":"child","title":"Child","description":null,"enabled":true,"parent":"Base","inheritable":false}}"#]],
);
}
#[tokio::test]
async fn project_set_title_success_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/data/api/v1/projects/x"))
.and(wiremock::matchers::body_json(
serde_json::json!({"title": "T"}),
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/find/x"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": "x", "title": "T", "enabled": true
})),
)
.expect(1..)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
&server.uri(),
&["project", "set", "x", "--title", "T"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
set title on x
"#]],
);
let out = ign(&config, &server.uri(), &["project", "set", "x"]);
assert_eq!(out.status.code(), Some(2));
}
#[tokio::test]
async fn project_copy_and_rename_human_lines() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/data/api/v1/projects/copy"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1..)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/data/api/v1/projects/rename/Old"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1..)
.mount(&server)
.await;
for name in ["dst", "New"] {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/data/api/v1/projects/find/{name}"
)))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": name, "enabled": true, "parent": "Base"
})),
)
.expect(1)
.mount(&server)
.await;
}
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["project", "copy", "src", "dst"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
copied src → dst
"#]],
);
let out = ign(&config, &server.uri(), &["project", "rename", "Old", "New"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
renamed Old → New
"#]],
);
}
#[tokio::test]
async fn project_delete_without_yes_exits_2_golden() {
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
"http://127.0.0.1:1",
&["project", "delete", "x", "--compact"],
);
assert_eq!(out.status.code(), Some(2), "usage class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stderr_envelope(&out),
snapbox::str![[r#"
{"ok":false,"profile":null,"error":{"code":"confirmation_required","message":"project delete is destructive; rerun with --yes to confirm","endpoint":null,"hint":"this operation is destructive; re-run with --yes or set IGNITION_YES=1"}}
"#]],
);
let out = ign(&config, "http://127.0.0.1:1", &["project", "delete", "x"]);
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("project delete"),
"human message names the operation: {stderr}"
);
assert!(stderr.contains("--yes"), "human hint names --yes: {stderr}");
}
#[tokio::test]
async fn project_delete_with_yes_proves_confirm_true_on_wire() {
let server = wiremock::MockServer::start().await;
let guard = wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/data/api/v1/projects/x"))
.and(wiremock::matchers::query_param("confirm", "true"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1..)
.mount_as_scoped(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(&config, &server.uri(), &["project", "delete", "x", "--yes"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
deleted x
"#]],
);
let out = ign(
&config,
&server.uri(),
&["project", "delete", "x", "--yes", "--compact"],
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"deleted":"x"}}"#]],
);
let requests = guard.received_requests().await;
assert_eq!(requests.len(), 2, "one DELETE per invocation");
for request in &requests {
let query = request.url.query().expect("query present");
assert!(
query.contains("confirm=true"),
"the server's own guard rode the wire: {query}"
);
assert!(request.body.is_empty(), "the DELETE carries no body");
}
}
fn ign_in(dir: &Path, config: &Path, url: &str, args: &[&str]) -> std::process::Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_TOKEN", "mock:name-key")
.env("IGNITION_URL", url)
.current_dir(dir);
command.args(args).output().expect("spawn ign")
}
fn ign_stdin(config: &Path, url: &str, args: &[&str], stdin: &[u8]) -> std::process::Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_TOKEN", "mock:name-key")
.env("IGNITION_URL", url);
command
.args(args)
.write_stdin(stdin)
.output()
.expect("spawn ign")
}
fn zip_fixture() -> Vec<u8> {
use std::io::Write as _;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
writer
.start_file("project.json", options)
.expect("fixture member starts");
writer
.write_all(br#"{"title":"fixture"}"#)
.expect("fixture member writes");
writer.finish().expect("fixture finalizes").into_inner()
}
#[tokio::test]
async fn project_export_success_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/projects/export/My%20Proj",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_raw(zip_fixture(), "application/zip")
.insert_header(
"Content-Disposition",
"attachment; filename=\"MyProj-export.zip\"",
),
)
.expect(1..)
.mount(&server)
.await;
let workdir = tempfile::tempdir().expect("workdir");
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign_in(
workdir.path(),
&config,
&server.uri(),
&["project", "export", "My Proj"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
exported My Proj → MyProj-export.zip ([..] bytes)
scope: includes views/scripts/named-queries/vision-windows/perspective-themes-styles/reporting/alarm-notification-profiles/webdev-routes/translations/sfc-charts · excludes tag-providers/tags/udts/gateway-config/database-connections/users-roles/alarm-journal/certificates
"#]],
);
assert_eq!(
std::fs::read(workdir.path().join("MyProj-export.zip")).expect("file written"),
zip_fixture()
);
assert!(
!workdir.path().join("My Proj.zip.part").exists()
&& !workdir.path().join("MyProj-export.zip.part").exists(),
"the .part temp is gone"
);
let out = ign_in(
workdir.path(),
&config,
&server.uri(),
&["project", "export", "My Proj", "--compact"],
);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"project":"My Proj","file":"MyProj-export.zip","bytes":[..],"scope":{"includes":["views","scripts","named-queries","vision-windows","perspective-themes-styles","reporting","alarm-notification-profiles","webdev-routes","translations","sfc-charts"],"excludes":["tag-providers","tags","udts","gateway-config","database-connections","users-roles","alarm-journal","certificates"]}}}"#]],
);
}
#[tokio::test]
async fn project_export_explicit_output_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/export/x"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_raw(zip_fixture(), "application/zip")
.insert_header(
"Content-Disposition",
"attachment; filename=\"ignored-by-o.zip\"",
),
)
.expect(1..)
.mount(&server)
.await;
let workdir = tempfile::tempdir().expect("workdir");
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign_in(
workdir.path(),
&config,
&server.uri(),
&["project", "export", "x", "-o", "out.zip", "--compact"],
);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"project":"x","file":"out.zip","bytes":[..],"scope":{"includes":["views","scripts","named-queries","vision-windows","perspective-themes-styles","reporting","alarm-notification-profiles","webdev-routes","translations","sfc-charts"],"excludes":["tag-providers","tags","udts","gateway-config","database-connections","users-roles","alarm-journal","certificates"]}}}"#]],
);
assert_eq!(
std::fs::read(workdir.path().join("out.zip")).expect("file written"),
zip_fixture()
);
assert!(
!workdir.path().join("ignored-by-o.zip").exists(),
"-o wins over the disposition name"
);
}
#[tokio::test]
async fn project_import_abort_collision_exits_6_golden() {
let server = wiremock::MockServer::start().await;
let import_guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/data/api/v1/projects/import/x"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount_as_scoped(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/find/x"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"name": "x", "enabled": true})),
)
.expect(1..)
.mount(&server)
.await;
let zip_dir = tempfile::tempdir().expect("zipdir");
let zip_path = zip_dir.path().join("proj.zip");
std::fs::write(&zip_path, zip_fixture()).expect("write fixture");
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
&server.uri(),
&[
"project",
"import",
"x",
"--file",
zip_path.to_str().unwrap(),
"--compact",
],
);
assert_eq!(out.status.code(), Some(6), "target-state class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stderr_envelope(&out),
snapbox::str![[r#"
{"ok":false,"profile":"dev","error":{"code":"project_exists","message":"project x already exists on the gateway","endpoint":null,"hint":"the default collision policy refuses to overwrite; re-run with --collision-policy overwrite to replace it — overwrite REPLACES the ENTIRE project (resources absent from the ZIP are deleted; merge is Designer-only)"}}
"#]],
);
assert!(
import_guard.received_requests().await.is_empty(),
"the refusal happened BEFORE any upload"
);
}
#[tokio::test]
async fn project_import_overwrite_without_yes_exits_2_golden() {
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
"http://127.0.0.1:1",
&[
"project",
"import",
"x",
"--file",
"whatever.zip",
"--collision-policy",
"overwrite",
"--compact",
],
);
assert_eq!(out.status.code(), Some(2), "usage class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stderr_envelope(&out),
snapbox::str![[r#"
{"ok":false,"profile":null,"error":{"code":"confirmation_required","message":"project import --collision-policy overwrite is destructive; rerun with --yes to confirm","endpoint":null,"hint":"this operation is destructive; re-run with --yes or set IGNITION_YES=1"}}
"#]],
);
}
#[tokio::test]
async fn project_import_overwrite_with_yes_uploads() {
let server = wiremock::MockServer::start().await;
let import_guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/data/api/v1/projects/import/x"))
.and(wiremock::matchers::query_param("overwrite", "true"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"status": "imported"})),
)
.expect(1..)
.mount_as_scoped(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/find/x"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let zip_dir = tempfile::tempdir().expect("zipdir");
let zip_path = zip_dir.path().join("proj.zip");
std::fs::write(&zip_path, zip_fixture()).expect("write fixture");
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
&server.uri(),
&[
"project",
"import",
"x",
"--file",
zip_path.to_str().unwrap(),
"--collision-policy",
"overwrite",
"--yes",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
imported x ([..] bytes, policy overwrite)
"#]],
);
let requests = import_guard.received_requests().await;
assert!(!requests.is_empty(), "the upload fired");
for request in &requests {
let query = request.url.query().expect("query present");
assert_eq!(query, "overwrite=true", "the policy rode the wire: {query}");
assert_eq!(request.body, zip_fixture(), "the exact ZIP bytes");
assert_eq!(
request
.headers
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("application/zip")
);
}
}
#[tokio::test]
async fn project_import_denial_exits_6_golden() {
let server = wiremock::MockServer::start().await;
let _import_guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/data/api/v1/projects/import/x"))
.and(wiremock::matchers::query_param("overwrite", "true"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"success": false,
"problem": "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}"
}),
))
.expect(1)
.mount_as_scoped(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/find/x"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let zip_dir = tempfile::tempdir().expect("zipdir");
let zip_path = zip_dir.path().join("proj.zip");
std::fs::write(&zip_path, zip_fixture()).expect("write fixture");
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
&server.uri(),
&[
"project",
"import",
"x",
"--file",
zip_path.to_str().unwrap(),
"--collision-policy",
"overwrite",
"--yes",
"--compact",
],
);
assert_eq!(out.status.code(), Some(6), "target-state class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
let envelope: Value = serde_json::from_str(&stderr_envelope(&out))
.unwrap_or_else(|err| panic!("stderr envelope parses: {err}"));
assert_eq!(
envelope["error"]["code"],
Value::String("import_denied".into()),
"the denial slug: {envelope}"
);
assert!(
envelope["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("resource already exists"),
"the gateway's problem text rides the message: {envelope}"
);
let endpoint = envelope["error"]["endpoint"].as_str().unwrap_or_default();
assert!(
endpoint.starts_with(&server.uri()) && endpoint.contains("/projects/import/x"),
"the endpoint names the import request: {endpoint}"
);
}
#[tokio::test]
async fn project_import_stdin_golden() {
let server = wiremock::MockServer::start().await;
let import_guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/data/api/v1/projects/import/piped",
))
.and(wiremock::matchers::query_param("overwrite", "false"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1)
.mount_as_scoped(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/find/piped"))
.respond_with(
wiremock::ResponseTemplate::new(404)
.set_body_json(serde_json::json!({"message": "Project not found"})),
)
.expect(1)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign_stdin(
&config,
&server.uri(),
&["project", "import", "piped", "--file", "-", "--compact"],
&zip_fixture(),
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"name":"piped","collision_policy":"abort","bytes":[..],"scope":{"includes":["views","scripts","named-queries","vision-windows","perspective-themes-styles","reporting","alarm-notification-profiles","webdev-routes","translations","sfc-charts"],"excludes":["tag-providers","tags","udts","gateway-config","database-connections","users-roles","alarm-journal","certificates"]},"outcome":{"status":"success"}}}"#]],
);
let requests = import_guard.received_requests().await;
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].body, zip_fixture(), "the piped bytes uploaded");
}
#[tokio::test]
async fn project_import_non_zip_exits_2_golden() {
let bad_dir = tempfile::tempdir().expect("bad dir");
let bad_path = bad_dir.path().join("not-a-zip.txt");
std::fs::write(&bad_path, b"definitely not a zip").expect("write junk");
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
"http://127.0.0.1:1",
&[
"project",
"import",
"x",
"--file",
bad_path.to_str().unwrap(),
"--compact",
],
);
assert_eq!(out.status.code(), Some(2), "usage class, zero network");
assert!(out.stdout.is_empty(), "errors never touch stdout");
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stderr_envelope(&out),
snapbox::str![[r#"
{"ok":false,"profile":"dev","error":{"code":"invalid_import_file","message":"invalid import file: missing ZIP magic (PK//x03//x04) — not a project export archive","endpoint":null,"hint":"import expects a project-export ZIP (PK//x03//x04 magic) of at most 512 MB — pass a file produced by `ign project export` via --file (or `-` to pipe one on stdin)"}}
"#]],
);
}
#[tokio::test]
async fn project_import_truncated_zip_exits_2_golden() {
let truncated = {
let full = zip_fixture();
full[..full.len() - 10].to_vec()
};
let bad_dir = tempfile::tempdir().expect("bad dir");
let bad_path = bad_dir.path().join("truncated.zip");
std::fs::write(&bad_path, truncated).expect("write truncated zip");
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
"http://127.0.0.1:1",
&[
"project",
"import",
"x",
"--file",
bad_path.to_str().unwrap(),
"--collision-policy",
"overwrite",
"--yes",
"--compact",
],
);
assert_eq!(out.status.code(), Some(2), "usage class, zero network");
assert!(out.stdout.is_empty(), "errors never touch stdout");
let envelope: Value = serde_json::from_str(&stderr_envelope(&out))
.unwrap_or_else(|err| panic!("stderr envelope parses: {err}"));
assert_eq!(
envelope["error"]["code"],
Value::String("invalid_import_file".into()),
"the structure guard names the caller's file: {envelope}"
);
}
#[tokio::test]
async fn project_delete_nonexistent_exits_6() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/data/api/v1/projects/nope"))
.and(wiremock::matchers::query_param("confirm", "true"))
.respond_with(
wiremock::ResponseTemplate::new(404).set_body_json(serde_json::json!({
"message": "Project not found"
})),
)
.expect(1)
.mount(&server)
.await;
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
&server.uri(),
&["project", "delete", "nope", "--yes", "--compact"],
);
assert_eq!(out.status.code(), Some(6), "target-state class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
let body: Value = serde_json::from_str(&stderr_envelope(&out))
.unwrap_or_else(|err| panic!("stderr envelope parses: {err}"));
assert_eq!(body["ok"], Value::Bool(false));
assert_eq!(body["profile"], Value::String("dev".into()));
assert_eq!(
body["error"]["code"],
Value::String("not_found".into()),
"full envelope: {}",
stderr_envelope(&out)
);
assert_eq!(
body["error"]["endpoint"],
Value::String(format!("{}/data/api/v1/projects/nope", server.uri()))
);
}
fn write_two_profile_config(config: &Path, url_b: &str) {
std::fs::write(
config,
format!(
"active = \"dev\"\n\n[profiles.dev]\nurl = \"http://ignored-a.example.com\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n\n[profiles.other]\nurl = \"{url_b}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n"
),
)
.expect("write config");
}
fn diff_export_zip(project_json: &[u8], members: &[(&str, &[u8])]) -> Vec<u8> {
use std::io::Write as _;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
writer
.start_file("project.json", options)
.expect("project.json starts");
writer.write_all(project_json).expect("project.json writes");
for (name, bytes) in members {
writer.start_file(*name, options).expect("member starts");
writer.write_all(bytes).expect("member writes");
}
writer.finish().expect("zip finalizes").into_inner()
}
fn volatile_descriptor(timestamp: &str) -> Vec<u8> {
format!(
r#"{{"scope":"G","version":1,"files":["script.py"],"attributes":{{"lastModification":{{"actor":"a","timestamp":"{timestamp}"}},"lastModificationSignature":"sig-{timestamp}"}}}}"#
)
.into_bytes()
}
#[tokio::test]
async fn project_diff_render_modes_golden() {
let server_a = wiremock::MockServer::start().await;
let server_b = wiremock::MockServer::start().await;
let zip_a = diff_export_zip(
br#"{"title":"Old","enabled":true}"#,
&[
(
"ignition/resources/script-python/uat/resource.json",
volatile_descriptor("2026-08-28T10:00:00Z").as_slice(),
),
(
"ignition/resources/script-python/uat/script.py",
b"print('old')",
),
],
);
let zip_b = diff_export_zip(
br#"{"title":"New","enabled":true}"#,
&[
(
"ignition/resources/script-python/uat/resource.json",
volatile_descriptor("2026-08-28T11:30:00Z").as_slice(),
),
(
"ignition/resources/script-python/uat/script.py",
b"print('new')",
),
(
"com.example/resources/views/Fresh/view.json",
br#"{"scope":"G"}"#.as_slice(),
),
],
);
for (server, zip) in [(&server_a, zip_a), (&server_b, zip_b)] {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/export/p"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_raw(zip, "application/zip"))
.expect(1..)
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/data/api/v1/projects/import/p"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(server)
.await;
}
let (_dir, config) = isolated_config();
write_two_profile_config(&config, &server_b.uri());
let out = ign(
&config,
&server_a.uri(),
&["project", "diff", "dev", "other", "--project", "p"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
project p · dev → other · statuses are B-relative-to-A (scope project)
project.json title: Old → New
ADDED (1)
com.example/views/Fresh/view.json
REMOVED (0)
CHANGED (1)
ignition/script-python/uat/script.py
1 same, 1 added, 0 removed, 1 changed
"#]],
);
let out = ign(
&config,
&server_a.uri(),
&[
"project",
"diff",
"dev",
"other",
"--project",
"p",
"--compact",
],
);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"scope":"project","profile_a":"dev","profile_b":"other","project":"p","project_meta":[{"field":"title","a":"Old","b":"New"}],"summary":{"same":1,"added":1,"removed":0,"changed":1},"entries":[{"path":"com.example/views/Fresh/view.json","status":"added"},{"path":"ignition/script-python/uat/resource.json","status":"same"},{"path":"ignition/script-python/uat/script.py","status":"changed"}]}}"#]],
);
}
#[tokio::test]
async fn project_diff_same_profile_exits_2_golden() {
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
"http://127.0.0.1:1",
&[
"project",
"diff",
"dev",
"dev",
"--project",
"p",
"--compact",
],
);
assert_eq!(out.status.code(), Some(2), "usage class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stderr_envelope(&out),
snapbox::str![[r#"
{"ok":false,"profile":"dev","error":{"code":"invalid_input","message":"invalid input: diffing a profile against itself is a no-op — name two different profiles","endpoint":null,"hint":"fix the input source — a readable file path via --file, or `-` to pipe the content on stdin"}}
"#]],
);
}
#[tokio::test]
async fn project_diff_unknown_side_profile_exits_3() {
let server_a = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/export/p"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server_a)
.await;
let (_dir, config) = isolated_config();
write_two_profile_config(&config, &server_a.uri());
let out = ign(
&config,
&server_a.uri(),
&[
"project",
"diff",
"dev",
"ghost",
"--project",
"p",
"--compact",
],
);
assert_eq!(out.status.code(), Some(3), "config class");
assert!(out.stdout.is_empty());
let envelope: Value = serde_json::from_str(&stderr_envelope(&out))
.unwrap_or_else(|err| panic!("stderr envelope parses: {err}"));
assert_eq!(
envelope["error"]["code"],
Value::String("profile_not_found".into()),
"full envelope: {}",
stderr_envelope(&out)
);
}
#[tokio::test]
async fn project_sync_without_yes_exits_2_zero_requests_golden() {
let server_a = wiremock::MockServer::start().await;
let server_b = wiremock::MockServer::start().await;
for server in [&server_a, &server_b] {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.named("no export may ride the wire on refusal")
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.named("no import may ride the wire on refusal")
.mount(server)
.await;
}
let (_dir, config) = isolated_config();
write_two_profile_config(&config, &server_b.uri());
let out = ign(
&config,
&server_a.uri(),
&[
"project",
"sync",
"dev",
"other",
"--project",
"p",
"--all-changed",
"--compact",
],
);
assert_eq!(out.status.code(), Some(2), "usage class");
assert!(out.stdout.is_empty(), "errors never touch stdout");
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stderr_envelope(&out),
snapbox::str![[r#"
{"ok":false,"profile":null,"error":{"code":"confirmation_required","message":"project sync (overwrite-import the whole project on other — replaces concurrent Designer edits) is destructive; rerun with --yes to confirm","endpoint":null,"hint":"this operation is destructive; re-run with --yes or set IGNITION_YES=1"}}
"#]],
);
assert!(
server_a
.received_requests()
.await
.unwrap_or_default()
.is_empty()
&& server_b
.received_requests()
.await
.unwrap_or_default()
.is_empty(),
"the refusal performed ZERO requests on either side"
);
}
#[tokio::test]
async fn project_sync_selection_less_exits_2_golden() {
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
"http://127.0.0.1:1",
&[
"project",
"sync",
"dev",
"other",
"--project",
"p",
"--yes",
"--compact",
],
);
assert_eq!(out.status.code(), Some(2), "usage class");
assert!(out.stdout.is_empty());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stderr_envelope(&out),
snapbox::str![[r#"
{"ok":false,"profile":null,"error":{"code":"invalid_input","message":"invalid input: sync needs a selection — pass --resource PATH (repeatable) and/or --all-changed","endpoint":null,"hint":"fix the input source — a readable file path via --file, or `-` to pipe the content on stdin"}}
"#]],
);
}
#[tokio::test]
async fn project_sync_success_golden() {
let server_a = wiremock::MockServer::start().await;
let server_b = wiremock::MockServer::start().await;
let zip_a = diff_export_zip(
br#"{"title":"T"}"#,
&[(
"ignition/resources/script-python/uat/script.py",
b"print('new')",
)],
);
let zip_b = diff_export_zip(
br#"{"title":"T"}"#,
&[(
"ignition/resources/script-python/uat/script.py",
b"print('old')",
)],
);
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/export/p"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_raw(zip_a, "application/zip"))
.expect(1..)
.mount(&server_a)
.await;
let import_guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/data/api/v1/projects/import/p"))
.and(wiremock::matchers::query_param("overwrite", "true"))
.and(wiremock::matchers::header(
"content-type",
"application/zip",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"success": true})),
)
.expect(1..)
.mount_as_scoped(&server_b)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/data/api/v1/projects/export/p"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_raw(zip_b, "application/zip"))
.expect(1..)
.mount(&server_b)
.await;
let (_dir, config) = isolated_config();
write_two_profile_config(&config, &server_b.uri());
let out = ign(
&config,
&server_a.uri(),
&[
"project",
"sync",
"dev",
"other",
"--project",
"p",
"--resource",
"ignition/script-python/uat/script.py",
"--yes",
"--compact",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"scope":"project","profile_a":"dev","profile_b":"other","project":"p","synced":["ignition/script-python/uat/script.py"],"removed":[]}}"#]],
);
let out = ign(
&config,
&server_a.uri(),
&[
"project",
"sync",
"dev",
"other",
"--project",
"p",
"--resource",
"ignition/script-python/uat/script.py",
"--yes",
],
);
assert!(out.status.success());
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
synced 1 resource(s) dev → other · project p (scope project)
+ ignition/script-python/uat/script.py
"#]],
);
let requests = import_guard.received_requests().await;
assert_eq!(requests.len(), 2, "one import per invocation");
for request in &requests {
assert_eq!(
ignition_core::client::resources::read_member(
&request.body,
"ignition/script-python/uat/script.py"
)
.expect("surgical body re-reads"),
b"print('new')".to_vec(),
"A's member bytes ride every import"
);
}
}
fn script_zip_fixture() -> Vec<u8> {
use std::io::Write as _;
let view: &[u8] = br#"{
"scope" : "G",
"children" : [ {
"type" : "ia.display.label",
"eventScripts" : {
"actionPerformed" : {
"config" : {
"script" : "\tprint \u0027clicked\u0027\n\tprint \u0027done\u0027"
}
}
}
}, {
"type" : "ia.chart",
"transform" : {
"script" : "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint \u0027end\u0027"
},
"props" : {
"expression" : "toStr({view.args.x} * 2)"
}
} ]
}"#;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
let members: &[(&str, &[u8])] = &[
("project.json", br#"{"title":"Fixture"}"#),
("c/resources/views/Dash/view.json", view),
("c/resources/views/Dash/resource.json", br#"{"scope":"G"}"#),
("ignition/resources/scratch", b"print('plain')"),
];
for (name, bytes) in members {
writer.start_file(*name, options).expect("starts");
writer.write_all(bytes).expect("writes");
}
writer.finish().expect("finalize").into_inner()
}
#[tokio::test]
async fn project_export_decode_scripts_human_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/projects/export/MyProj",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_raw(script_zip_fixture(), "application/zip")
.insert_header(
"Content-Disposition",
"attachment; filename=\"MyProj-export.zip\"",
),
)
.expect(1)
.mount(&server)
.await;
let workdir = tempfile::tempdir().expect("workdir");
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign_in(
workdir.path(),
&config,
&server.uri(),
&["project", "export", "MyProj", "--decode-scripts"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"
[profile: dev]
Exported 4 members, 2 scripts decoded to MyProj-export
scope: includes views/scripts/named-queries/vision-windows/perspective-themes-styles/reporting/alarm-notification-profiles/webdev-routes/translations/sfc-charts · excludes tag-providers/tags/udts/gateway-config/database-connections/users-roles/alarm-journal/certificates
edit the <member>.<n>.py sidecars, then re-import with `--encode-scripts --file MyProj-export`
"#]],
);
let root = workdir.path().join("MyProj-export");
assert!(root.join("c/resources/views/Dash/view.json.1.py").is_file());
assert!(root.join("c/resources/views/Dash/view.json.2.py").is_file());
assert!(
root.join(ignition_core::client::scripts_codec::MANIFEST_NAME)
.is_file()
);
}
#[tokio::test]
async fn project_export_decode_scripts_json_golden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/projects/export/MyProj",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_raw(script_zip_fixture(), "application/zip"),
)
.expect(1)
.mount(&server)
.await;
let workdir = tempfile::tempdir().expect("workdir");
let out_dir = workdir.path().join("decoded");
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign_in(
workdir.path(),
&config,
&server.uri(),
&[
"project",
"export",
"MyProj",
"--decode-scripts",
"-o",
out_dir.to_str().unwrap(),
"--compact",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"project":"MyProj","dir":"[..]decoded","members":4,"scripts_decoded":2,"bytes":[..],"scope":{"includes":["views","scripts","named-queries","vision-windows","perspective-themes-styles","reporting","alarm-notification-profiles","webdev-routes","translations","sfc-charts"],"excludes":["tag-providers","tags","udts","gateway-config","database-connections","users-roles","alarm-journal","certificates"]}}}"#]],
);
}
#[tokio::test]
async fn project_import_encode_scripts_success_golden() {
let server = wiremock::MockServer::start().await;
let import_guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/data/api/v1/projects/import/roundtrip",
))
.and(wiremock::matchers::query_param("overwrite", "false"))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(1)
.mount_as_scoped(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/data/api/v1/projects/find/roundtrip",
))
.respond_with(
wiremock::ResponseTemplate::new(404)
.set_body_json(serde_json::json!({"message": "Project not found"})),
)
.expect(1)
.mount(&server)
.await;
let tree = tempfile::tempdir().expect("tree dir");
ignition_core::client::scripts_codec::decode_export_tree(&script_zip_fixture(), tree.path())
.expect("decode fixture");
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
&server.uri(),
&[
"project",
"import",
"roundtrip",
"--file",
tree.path().to_str().unwrap(),
"--encode-scripts",
"--compact",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stdout_for_golden(&out),
snapbox::str![[r#"{"ok":true,"profile":"dev","data":{"name":"roundtrip","collision_policy":"abort","bytes":[..],"scope":{"includes":["views","scripts","named-queries","vision-windows","perspective-themes-styles","reporting","alarm-notification-profiles","webdev-routes","translations","sfc-charts"],"excludes":["tag-providers","tags","udts","gateway-config","database-connections","users-roles","alarm-journal","certificates"]},"outcome":{"status":"success"}}}"#]],
);
let requests = import_guard.received_requests().await;
assert_eq!(requests.len(), 1);
let body = &requests[0].body;
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(body.as_slice())).expect("walkable zip");
use std::io::Read as _;
for name in [
"project.json",
"c/resources/views/Dash/view.json",
"c/resources/views/Dash/resource.json",
"ignition/resources/scratch",
] {
let mut member = archive.by_name(name).expect("member present");
let mut bytes = Vec::new();
member.read_to_end(&mut bytes).expect("member reads");
let original = script_zip_fixture();
let mut original_archive =
zip::ZipArchive::new(std::io::Cursor::new(original.as_slice())).expect("fixture zip");
let mut original_member = original_archive.by_name(name).expect("fixture member");
let mut original_bytes = Vec::new();
original_member
.read_to_end(&mut original_bytes)
.expect("fixture reads");
assert_eq!(bytes, original_bytes, "{name} byte-identical in the upload");
}
assert!(
archive
.by_name(ignition_core::client::scripts_codec::MANIFEST_NAME)
.is_err(),
"the manifest never rides an upload"
);
}
#[tokio::test]
async fn project_import_encode_scripts_stdin_refuses() {
let (_dir, config) = isolated_config();
write_profile_config(&config, "http://ignored.example.com");
let out = ign(
&config,
"http://127.0.0.1:1",
&[
"project",
"import",
"x",
"--file",
"-",
"--encode-scripts",
"--compact",
],
);
assert_eq!(out.status.code(), Some(2), "usage class, zero network");
assert!(out.stdout.is_empty(), "errors never touch stdout");
snapbox::Assert::new().action_env("SNAPSHOTS").eq(
stderr_envelope(&out),
snapbox::str![[r#"
{"ok":false,"profile":null,"error":{"code":"invalid_input","message":"invalid input: --encode-scripts needs the decoded export DIRECTORY via --file (stdin cannot carry one)","endpoint":null,"hint":"fix the input source — a readable file path via --file, or `-` to pipe the content on stdin"}}
"#]],
);
}