Files
lightcraft/crates/mcp/tests/it/e2e.rs
T
aiongg f3bb7a9f30 CI: one test binary per crate, line tables only, one WASM check (#2710)
* CI: dev profile keeps line tables for workspace crates and no debug info for dependencies

* Control server tests: persistent_requests_and_slow_replies_outlive_the_idle_read_timeout no longer races the idle timeout

Each request (and the closing half-shutdown) reaches the socket before the
worker reads, so on a slow machine only the reply waits outlast the 100 ms
idle timeout, as the test intends. The idle test sends its input the same
way, and the no-frames test allows 5 s instead of 2 s to give up.

* CI: one integration-test binary per crate

Each crate's tests/*.rs become modules of tests/it/main.rs (51 test files
into 13 binaries, plus 6 that stay their own). Inner #![cfg] attributes
move to the mod lines. Files that change state for the whole process keep
a binary of their own: codecs jxl_tail_heap and raw sr2_heap (dhat global
allocator), gpu shutdown, shutdown_first and memory, ui-egui shutdown.
Docs, comments and the GPU probe name the new paths and targets
(--test it <module>::); the CI report names an it binary's skips after
the crate.

* CI: tests under nextest, doctests apart, one wasm check

xtask ci runs the tests with cargo nextest run --lib --bins --tests when
cargo-nextest is installed, else cargo test with the same targets and a
one-line notice; doctests run after them with cargo test --workspace --doc.
The heif and hardware GPU steps use the same runner. Examples stay out of
the test build (clippy --all-targets still checks them). Concurrent tests
under nextest follow the RUST_TEST_THREADS cap (NEXTEST_TEST_THREADS).
The wasm check is one cargo check --keep-going over the same crates, and
clippy runs with --keep-going.

* CI: tests run under cargo test by default, nextest with LIGHTCRAFT_NEXTEST=1 (per-process scene cache)
2026-10-23 13:34:30 -04:00

346 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! M0.9 acceptance: drive LightCraft over MCP (stdio framing), set exposure and render an image.
//! Runs against the headless backend directly and through the TCP control-channel transport
//! (`Remote`) to a stand-in control server.
use std::io::{BufRead, BufReader, Cursor, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode};
use serde_json::{Value, json};
/// Feed a whole session through `Server::serve` and return the replies by id.
fn session(server: &mut Server, msgs: &[Value]) -> Vec<Value> {
let input: String = msgs.iter().map(|m| format!("{m}\n")).collect();
let mut out = Vec::new();
server.serve(Cursor::new(input), &mut out).unwrap();
String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect()
}
fn call(id: u64, name: &str, args: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}})
}
/// Mean sRGB value of the PNG in an MCP image result.
fn mean_of(result: &Value) -> (f64, u32, u32) {
let img = result["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap_or_else(|| panic!("no image in {result}"));
assert_eq!(img["mimeType"], "image/png");
let png = base64_decode(img["data"].as_str().unwrap()).unwrap();
assert_eq!(&png[1..4], b"PNG");
let d = lightcraft_codecs::decode(&png, Default::default()).unwrap();
let rgba = d.to_srgb8();
let sum: u64 = rgba.data.iter().map(|p| p[0] as u64 + p[1] as u64 + p[2] as u64).sum();
(sum as f64 / (3 * rgba.data.len()) as f64, d.width, d.height)
}
fn exposure_roundtrip(server: &mut Server) {
let replies = session(
server,
&[
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "e2e", "version": "0"}}}),
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
call(3, "query_photos", json!({"limit": 1})),
],
);
assert_eq!(replies.len(), 3, "notifications get no reply");
assert_eq!(replies[0]["result"]["serverInfo"]["name"], "lightcraft");
let tools: Vec<&str> = replies[1]["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(tools.contains(&"set_develop") && tools.contains(&"render_photo") && tools.contains(&"cmd_develop_set"));
let id = replies[2]["result"]["structuredContent"]["photos"][0]["id"].as_u64().expect("a photo");
let replies = session(
server,
&[
call(10, "render_photo", json!({"id": id, "size": 256})),
call(11, "set_develop", json!({"id": id, "values": {"light.exposure": 1.5}})),
call(12, "get_develop", json!({"id": id})),
call(13, "render_photo", json!({"id": id, "size": 256})),
// The generated per-command tool works too: exposure -1.5 via `cmd_develop_set`.
call(14, "cmd_develop_set", json!({"control": "light.exposure", "value": -1.5})),
call(15, "render_photo", json!({"size": 256})),
],
);
for r in &replies {
assert!(r["error"].is_null() && r["result"]["isError"] == false, "{r}");
}
assert_eq!(replies[1]["result"]["structuredContent"]["controls"][0]["value"], 1.5);
assert_eq!(replies[2]["result"]["structuredContent"]["light"]["exposure"], 1.5);
let (before, w, h) = mean_of(&replies[0]["result"]);
let (brighter, w2, h2) = mean_of(&replies[3]["result"]);
let (darker, _, _) = mean_of(&replies[5]["result"]);
assert_eq!((w, h), (w2, h2));
assert!(w.max(h) <= 256 && w.max(h) >= 200, "{w}x{h}");
println!("mean sRGB: exposure 0 → {before:.1}, +1.5 → {brighter:.1}, -1.5 → {darker:.1} ({w}×{h})");
assert!(brighter > before + 15.0, "exposure +1.5 should brighten: {before} → {brighter}");
assert!(darker < before - 15.0, "exposure -1.5 should darken: {before} → {darker}");
}
#[test]
fn file_writers_advertise_destructive_effects() {
for with_commands in [false, true] {
let mut server = Server::new(Box::new(Headless::demo())).with_command_tools(with_commands);
let replies = session(&mut server, &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})]);
let tools = replies[0]["result"]["tools"].as_array().unwrap();
for name in ["export", "render_photo"] {
let tool = tools.iter().find(|t| t["name"] == name).unwrap();
assert_eq!(tool["annotations"]["readOnlyHint"], false, "{name}");
assert_eq!(tool["annotations"]["destructiveHint"], true, "{name} can replace a file");
}
let preview = tools.iter().find(|t| t["name"] == "render_preview").unwrap();
assert_eq!(preview["annotations"]["readOnlyHint"], true);
assert_eq!(preview["annotations"]["destructiveHint"], false);
}
let addr = fake_app();
let mut server = Server::new(Box::new(Remote::connect(&addr).unwrap()));
let replies = session(&mut server, &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})]);
let tools = replies[0]["result"]["tools"].as_array().unwrap();
let screenshot = tools.iter().find(|t| t["name"] == "screenshot").unwrap();
assert_eq!(screenshot["annotations"]["readOnlyHint"], false);
assert_eq!(screenshot["annotations"]["destructiveHint"], true);
let image_only = tools.iter().find(|t| t["name"] == "ui_screenshot").unwrap();
assert_eq!(image_only["annotations"]["readOnlyHint"], true);
assert_eq!(image_only["annotations"]["destructiveHint"], false);
}
#[test]
fn headless_set_exposure_and_render() {
exposure_roundtrip(&mut Server::new(Box::new(Headless::demo())));
}
#[test]
fn import_render_export_real_file() {
let dir = std::env::temp_dir().join(format!("lightcraft-mcp-e2e-{}", std::process::id()));
std::fs::create_dir_all(dir.join("sub")).unwrap();
// A synthetic gradient PNG to import.
let img = lightcraft_raster::Rgba8::from_fn(96, 64, |x, y| [(x * 2) as u8, (y * 3) as u8, 128, 255]);
lightcraft_mcp::write_image(&dir.join("sub/gradient.png"), &img, 90).unwrap();
let out = dir.join("out.jpg");
let mut server = Server::new(Box::new(Headless::default()));
let replies = session(
&mut server,
&[
call(1, "import", json!({"paths": [dir.to_string_lossy()]})),
call(2, "set_develop", json!({"values": {"light.exposure": 0.5}})),
call(3, "render_photo", json!({"format": "jpeg", "size": 64})),
call(4, "export", json!({"path": out.to_string_lossy(), "longEdge": 48})),
],
);
for r in &replies {
assert!(r["result"]["isError"] == false, "{r}");
}
assert_eq!(replies[0]["result"]["structuredContent"]["imported"].as_array().unwrap().len(), 1);
assert_eq!(replies[2]["result"]["content"][0]["mimeType"], "image/jpeg");
let jpg = std::fs::read(&out).unwrap();
assert_eq!(&jpg[..2], &[0xff, 0xd8]);
let d = lightcraft_codecs::decode(&jpg, Default::default()).unwrap();
assert_eq!((d.width, d.height), (48, 32));
let _ = std::fs::remove_dir_all(&dir);
}
/// A stand-in for the desktop app's control server: JSON lines over TCP, answered by a headless
/// backend (the real app answers the same methods from its UI thread).
fn fake_app() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
std::thread::spawn(move || {
let mut backend = Headless::demo();
for stream in listener.incoming().flatten() {
let mut out = stream.try_clone().unwrap();
for line in BufReader::new(stream).lines() {
let Ok(line) = line else { break };
let msg: Value = serde_json::from_str(&line).unwrap();
let reply = match backend.call(msg["method"].as_str().unwrap(), msg["params"].clone()) {
Ok(v) => json!({"id": msg["id"], "ok": true, "result": v}),
Err(e) => json!({"id": msg["id"], "ok": false, "error": e}),
};
if writeln!(out, "{reply}").is_err() {
break;
}
}
}
});
addr
}
#[test]
fn remote_set_exposure_and_render() {
let addr = fake_app();
let remote = Remote::connect(&addr).unwrap();
assert!(remote.has_ui());
let mut server = Server::new(Box::new(remote));
exposure_roundtrip(&mut server);
}
#[test]
fn remote_unreachable_is_a_tool_error() {
// Bind and drop to get a port with (almost certainly) nothing listening.
let addr = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().to_string();
let mut server = Server::new(Box::new(Remote::lazy(&addr)));
let r = session(&mut server, &[call(1, "query_photos", json!({}))]);
assert_eq!(r[0]["result"]["isError"], true);
assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable"));
}
/// [`Headless`] whose batch export, while `hold` is set, waits once its first photo is written:
/// it keeps asking the server's progress hook at that photo boundary (which answers pings and
/// reads cancels meanwhile) until the request is cancelled or the test clears `hold`. A ping or
/// cancel sent after the first progress then lands mid-batch, however fast the machine exports.
struct HoldAfterFirst {
inner: Headless,
hold: Arc<AtomicBool>,
}
impl Backend for HoldAfterFirst {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
self.inner.has_ui()
}
fn describe(&self) -> String {
self.inner.describe()
}
fn set_progress(&mut self, hook: Option<ProgressHook>) {
let hold = self.hold.clone();
self.inner.set_progress(hook.map(|mut hook| -> ProgressHook {
Box::new(move |done, total, name| {
// bounded, so a cancel that is never read fails the test instead of hanging it
let until = Instant::now() + Duration::from_secs(20);
while done == 1 && done < total && hold.load(Ordering::SeqCst) && Instant::now() < until {
if !hook(done, total, name) {
return false;
}
std::thread::sleep(Duration::from_millis(5));
}
hook(done, total, name)
})
}));
}
}
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
/// between complete photos, unrelated output preservation, and a usable session afterwards.
/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or
/// the cancel is read, so neither can finish first on a fast machine.
#[test]
fn export_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (input, mut send) = std::io::pipe().unwrap();
let (output, write) = std::io::pipe().unwrap();
let hold = Arc::new(AtomicBool::new(true));
let backend = HoldAfterFirst { inner: Headless::demo(), hold: hold.clone() };
let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(output).lines() {
let value: Value = serde_json::from_str(&line.unwrap()).unwrap();
if tx.send(value).is_err() {
break;
}
}
});
let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress");
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
let query = next();
let ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
assert_eq!(ids.len(), 8);
let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"}));
request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"});
writeln!(send, "{request}").unwrap();
let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false);
loop {
let value = next();
if value["method"] == "notifications/progress" {
assert_eq!(value["params"]["progressToken"], "photos");
let progress = value["params"]["progress"].as_f64().unwrap();
assert!(progress > last && progress <= 8.0, "{value}");
assert_eq!(value["params"]["total"], 8);
last = progress;
notes += 1;
if !asked {
writeln!(send, "{}", json!({"jsonrpc":"2.0","id":3,"method":"ping"})).unwrap();
asked = true;
}
} else if value["id"] == 3 {
answered = true;
// the ping was answered mid-batch: let the export run to the end
hold.store(false, Ordering::SeqCst);
} else {
assert_eq!(value["id"], 2, "{value}");
assert_eq!(value["result"]["isError"], false, "{value}");
assert_eq!(value["result"]["resultType"], "complete");
break;
}
}
assert!(notes >= 2 && answered, "{notes} progress messages; ping answered {answered}");
assert_eq!(last, 8.0);
let out = dir.join("cancelled");
std::fs::create_dir_all(&out).unwrap();
let decoy = out.join("photo999.png");
std::fs::write(&decoy, b"unrelated existing output").unwrap();
// this export waits after its first photo until the cancel below is read
hold.store(true, Ordering::SeqCst);
let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}}));
request["params"]["_meta"] = json!({"progressToken":44});
writeln!(send, "{request}").unwrap();
loop {
let value = next();
assert_ne!(value["id"], 4, "must be cancellable before completion: {value}");
assert_eq!(value["params"]["progressToken"], 44, "{value}");
if value["params"]["progress"].as_f64().unwrap() > 0.0 {
break;
}
}
writeln!(send, "{}", json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}})).unwrap();
writeln!(send, "{}", call(5, "doc_inspect", json!({}))).unwrap();
loop {
let value = next();
if value["method"] == "notifications/progress" {
continue;
}
assert_eq!(value["id"], 5, "cancelled request must not reply: {value}");
assert_eq!(value["result"]["isError"], false);
break;
}
assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output");
let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect();
assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}");
for path in &photos {
lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
}
assert_eq!(std::fs::read_dir(dir.join("complete")).unwrap().count(), 8, "earlier export retained");
drop(send);
server.join().unwrap();
reader.join().unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn export_without_token_finishes_after_eof() {
let dir = std::env::temp_dir().join(format!("lc-mcp-eof-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut headless = Headless::demo();
let ids: Vec<_> = headless.session.visible_cloned().iter().take(2).map(|id| id.0).collect();
let request = call(1, "cmd_app_export", json!({"ids":ids,"dir":dir,"longEdge":64,"format":"png"}));
// Headless sessions remain movable to a worker after installing progress support.
let replies = std::thread::spawn(move || {
let mut s = Server::new(Box::new(headless));
session(&mut s, &[request, call(2, "doc_inspect", json!({}))])
})
.join()
.unwrap();
assert_eq!(replies.len(), 2, "no token means no notifications");
assert_eq!(replies[0]["result"]["isError"], false, "{replies:?}");
assert_eq!(replies[1]["id"], 2, "queued request survives EOF");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}