diff --git a/Cargo.lock b/Cargo.lock index aa84d29..c9f4e8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,7 @@ dependencies = [ "futures-util", "hidapi", "ico", + "proptest", "serde", "serde_json", "tokio", @@ -47,6 +48,7 @@ dependencies = [ name = "agent-notify-core" version = "0.1.0" dependencies = [ + "proptest", "serde", "thiserror 2.0.18", "uuid", @@ -166,6 +168,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -227,6 +235,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -597,6 +620,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fdeflate" version = "0.3.7" @@ -622,6 +651,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1327,6 +1362,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -1764,6 +1808,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.11.1", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.9" @@ -1869,6 +1938,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -1998,7 +2076,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2055,6 +2133,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2289,6 +2379,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2641,6 +2744,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2733,6 +2842,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 45fb35a..d6dbed6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,4 @@ tower-http = { version = "0.6.11", features = ["limit", "trace"] } tracing = "0.1.43" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } uuid = { version = "1.19.0", features = ["serde", "v4"] } +proptest = "1.9.0" diff --git a/crates/agent-notify-bridge/Cargo.toml b/crates/agent-notify-bridge/Cargo.toml index 1ecdd83..f8a4ec4 100644 --- a/crates/agent-notify-bridge/Cargo.toml +++ b/crates/agent-notify-bridge/Cargo.toml @@ -39,3 +39,6 @@ winit = { version = "0.30", default-features = false, features = ["rwh_06"] } [build-dependencies] embed-resource = "3.0.9" + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/agent-notify-bridge/src/ipc.rs b/crates/agent-notify-bridge/src/ipc.rs index af90cfd..20b9f4d 100644 --- a/crates/agent-notify-bridge/src/ipc.rs +++ b/crates/agent-notify-bridge/src/ipc.rs @@ -65,6 +65,8 @@ where mod tests { use super::*; use agent_notify_core::{AgentEventInput, AgentState}; + use proptest::prelude::*; + use serde::{Serialize, de::DeserializeOwned}; use std::io::{BufReader, Cursor}; fn sample_event() -> AgentEvent { @@ -82,6 +84,90 @@ mod tests { .unwrap() } + fn arb_string(max_chars: usize) -> impl Strategy { + prop::collection::vec(any::(), 0..=max_chars) + .prop_map(|chars| chars.into_iter().collect()) + } + + fn arb_nonblank_string(max_chars: usize) -> impl Strategy { + arb_string(max_chars.saturating_sub(1)).prop_map(|tail| format!("x{tail}")) + } + + fn arb_agent_state() -> impl Strategy { + prop_oneof![ + Just(AgentState::Running), + Just(AgentState::WaitingInput), + Just(AgentState::Done), + Just(AgentState::Failed), + ] + } + + fn arb_event() -> impl Strategy { + ( + arb_nonblank_string(40), + arb_nonblank_string(40), + prop::option::of(arb_string(40)), + arb_agent_state(), + prop::option::of(arb_string(80)), + prop::option::of(any::()), + prop::option::of(1_u64..=3_600), + prop::option::of(arb_string(40)), + ) + .prop_map( + |(agent, host, repo, state, summary, priority, ttl_seconds, run_id)| { + AgentEventInput { + agent, + host, + repo, + state, + summary, + priority, + ttl_seconds, + run_id, + } + .into_event() + .unwrap() + }, + ) + } + + fn arb_request() -> impl Strategy { + prop_oneof![ + Just(HidBrokerRequest::ProbeKeyboard), + arb_event().prop_map(|event| HidBrokerRequest::SetDisplay { event }), + arb_string(80).prop_map(|reason| HidBrokerRequest::Clear { reason }), + Just(HidBrokerRequest::Shutdown), + ] + } + + fn arb_response() -> impl Strategy { + prop_oneof![ + prop::option::of(arb_string(80)).prop_map(|display| HidBrokerResponse::Ok { display }), + any::().prop_map(|present| HidBrokerResponse::KeyboardPresent { present }), + (arb_string(40), arb_string(120)) + .prop_map(|(code, message)| HidBrokerResponse::Error { code, message }), + ] + } + + fn assert_json_line_round_trip(message: T) -> proptest::test_runner::TestCaseResult + where + T: DeserializeOwned + Serialize, + { + let expected = serde_json::to_value(&message).unwrap(); + let mut encoded = Vec::new(); + write_message(&mut encoded, &message).unwrap(); + + prop_assert_eq!(encoded.last(), Some(&b'\n')); + prop_assert_eq!(encoded.iter().filter(|byte| **byte == b'\n').count(), 1); + prop_assert!(encoded.len() <= MAX_IPC_MESSAGE_BYTES + 1); + + let mut reader = BufReader::new(Cursor::new(encoded)); + let decoded: T = read_message(&mut reader).unwrap().unwrap(); + let actual = serde_json::to_value(decoded).unwrap(); + prop_assert_eq!(actual, expected); + Ok(()) + } + #[test] fn request_round_trips_as_json_line() { let request = HidBrokerRequest::SetDisplay { @@ -128,4 +214,32 @@ mod tests { let decoded: HidBrokerResponse = read_message(&mut reader).unwrap().unwrap(); assert!(matches!(decoded, HidBrokerResponse::Error { .. })); } + + proptest! { + #[test] + fn generated_requests_round_trip_as_json_lines(request in arb_request()) { + assert_json_line_round_trip(request)?; + } + + #[test] + fn generated_responses_round_trip_as_json_lines(response in arb_response()) { + assert_json_line_round_trip(response)?; + } + + #[test] + fn arbitrary_raw_frames_decode_or_fail_cleanly(mut raw in prop::collection::vec(any::(), 0..=MAX_IPC_MESSAGE_BYTES + 32)) { + raw.push(b'\n'); + let mut reader = BufReader::new(Cursor::new(raw)); + let result = read_message::<_, HidBrokerRequest>(&mut reader); + + if let Err(err) = result { + let message = err.to_string(); + prop_assert!( + message.contains("failed to decode IPC message") + || message.contains("exceeds"), + "unexpected raw-frame error: {message}" + ); + } + } + } } diff --git a/crates/agent-notify-core/Cargo.toml b/crates/agent-notify-core/Cargo.toml index 325c1da..68cb058 100644 --- a/crates/agent-notify-core/Cargo.toml +++ b/crates/agent-notify-core/Cargo.toml @@ -9,3 +9,6 @@ repository.workspace = true serde.workspace = true thiserror.workspace = true uuid.workspace = true + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/agent-notify-core/src/lib.rs b/crates/agent-notify-core/src/lib.rs index d157a17..2465114 100644 --- a/crates/agent-notify-core/src/lib.rs +++ b/crates/agent-notify-core/src/lib.rs @@ -370,6 +370,7 @@ fn unix_ms() -> u64 { #[cfg(test)] mod tests { use super::*; + use proptest::prelude::*; fn event(state: AgentState, priority: Option) -> AgentEvent { AgentEventInput { @@ -386,6 +387,47 @@ mod tests { .unwrap() } + fn arb_string(max_chars: usize) -> impl Strategy { + prop::collection::vec(any::(), 0..=max_chars) + .prop_map(|chars| chars.into_iter().collect()) + } + + fn arb_agent_state() -> impl Strategy { + prop_oneof![ + Just(AgentState::Running), + Just(AgentState::WaitingInput), + Just(AgentState::Done), + Just(AgentState::Failed), + ] + } + + fn arb_event_input() -> impl Strategy { + ( + arb_string(120), + arb_string(120), + prop::option::of(arb_string(120)), + arb_agent_state(), + prop::option::of(arb_string(240)), + prop::option::of(any::()), + prop::option::of(any::()), + prop::option::of(arb_string(120)), + ) + .prop_map( + |(agent, host, repo, state, summary, priority, ttl_seconds, run_id)| { + AgentEventInput { + agent, + host, + repo, + state, + summary, + priority, + ttl_seconds, + run_id, + } + }, + ) + } + #[test] fn defaults_priority_by_state() { assert_eq!(event(AgentState::WaitingInput, None).priority, 90); @@ -461,4 +503,33 @@ mod tests { let done = event(AgentState::Done, None); assert_eq!(choose_latest(Some(waiting), done.clone()).id, done.id); } + + proptest! { + #[test] + fn accepted_events_always_fit_uhk_macro_report(input in arb_event_input()) { + if let Ok(event) = input.into_event() { + let command = macro_command_for_event(&event); + prop_assert!( + command.is_ok(), + "accepted event produced an invalid macro command: {:?}", + command.err() + ); + let command = command.unwrap(); + prop_assert!(command.len() <= UHK_MAX_MACRO_COMMAND_BYTES); + + let report = uhk_exec_macro_report(4, &command); + prop_assert!( + report.is_ok(), + "accepted macro command produced an invalid UHK report: {:?}", + report.err() + ); + let report = report.unwrap(); + prop_assert_eq!(report.len(), command.len() + 3); + prop_assert_eq!(report[0], 4); + prop_assert_eq!(report[1], UHK_EXEC_MACRO_COMMAND); + prop_assert_eq!(&report[2..2 + command.len()], command.as_bytes()); + prop_assert_eq!(report.last(), Some(&0)); + } + } + } }