diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b9e2641c..d7964f23 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -71,6 +71,8 @@ USAGE: per line: {\"method\": \"ui.set\", \"params\": {\"view\": \"detail\"}}. Replies go to stdout. `ui.screenshot` without a path writes -o (then OUT-2.png, OUT-3.png…); `ui.settle {timeoutMs?}` waits for renders. + A failed request (\"ok\": false) does not stop the script, but the exit + status is non-zero when any request failed. -o, --output OUT PNG path (a final screenshot is written here if the script took none) --size WxH window size in points (default 1600x1000) --scale S pixels per point (default 1) @@ -755,6 +757,9 @@ fn snapshot(args: &[String]) -> Result<(), String> { Some(p.with_file_name(format!("{stem}-{shots}.{ext}")).to_string_lossy().to_string()) }; let mut wrote_output = false; + // Issue #166: a scripted request that fails still gets its reply printed and the run carries + // on (like `run --keep-going`), but the exit status reports it — CI judges by exit status. + let mut failed = 0usize; if let Some(script) = &script { let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; let mut out = std::io::stdout().lock(); @@ -787,6 +792,9 @@ fn snapshot(args: &[String]) -> Result<(), String> { } _ => h.request(&method, params, timeout), }; + if reply["ok"] != true { + failed += 1; + } if let Some(o) = reply.as_object_mut() { o.insert("id".into(), id); // wall time of the request (incl. the frames it ran), and since the start @@ -823,7 +831,7 @@ fn snapshot(args: &[String]) -> Result<(), String> { } } eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames()); - Ok(()) + if failed > 0 { Err(format!("{failed} scripted request(s) failed")) } else { Ok(()) } } fn commands(args: &[String]) -> Result<(), String> { diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 7cd0710b..5f4e4b1c 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -183,6 +183,39 @@ fn snapshot_subcommand_renders_the_ui_headlessly() { assert_eq!((d.width, d.height), (640, 480)); } +/// Issue #166: a scripted request that fails makes `snapshot` exit non-zero. The failed reply is +/// still printed, the later lines still run, and the final `-o` screenshot is still written. +#[test] +fn snapshot_script_failure_exits_non_zero() { + let script = tmp("snap-fail.jsonl"); + let out = tmp("snap-fail.png"); + std::fs::write( + &script, + format!( + "{}\n{}\n", + json!({"method": "ui.set", "params": {"view": "grid"}}), // not a view: `photoGrid`, `detail`, … + json!({"method": "ui.set", "params": {"view": "photoGrid"}}), + ), + ) + .unwrap(); + let _ = std::fs::remove_file(&out); + let o = Command::new(BIN) + .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(!o.status.success(), "a failed scripted request must fail the run\n{stderr}"); + assert!(stderr.contains("1 scripted request(s) failed"), "{stderr}"); + let replies: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 2, "{replies:?}"); + assert_eq!(replies[0]["ok"], false, "{replies:?}"); + assert!(replies[0]["error"].as_str().unwrap_or("").contains("grid"), "{replies:?}"); + assert_eq!(replies[1]["ok"], true, "{replies:?}"); + // continue-and-report: the final screenshot is still written + let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap(); + assert_eq!((d.width, d.height), (320, 200)); +} + /// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU, /// without creating a GPU device (no driver is loaded), and says why. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 24fd2bdb..14d3d852 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -86,7 +86,10 @@ lightcraft-cli snapshot --library DIR --script tour.jsonl -o shot.png `tour.jsonl` holds one request per line (`#` comments allowed), e.g. `{"method": "ui.set", "params": {"view": "detail", "right": "edit", "openSections": ["optics"]}}` - then `{"method": "ui.screenshot"}`. Replies are printed to stdout. A `ui.screenshot` without + then `{"method": "ui.screenshot"}`. Replies are printed to stdout. A failed request + (`"ok": false`) does not stop the script — the remaining lines and the final screenshot still + run — but the exit status is non-zero when any request failed, as with `run --keep-going`, so + CI and nightly runs can judge a snapshot by its exit status. A `ui.screenshot` without `path` writes `-o` (then `OUT-2.png`, `OUT-3.png`, …); `ui.settle {timeoutMs?}` waits until no renders are in flight. Each request runs frames until it is answered and its injected input (clicks, keys, drags) has played out. Widget ids for `ui.clickWidget` come from `ui.widgets`