1#![cfg_attr(not(test), deny(unsafe_code))]
8
9use serde::{Deserialize, Serialize};
10
11pub mod config;
12pub mod escape;
13pub use config::{
14 parse_str, Action, Assertion, AssertionKind, CliAction, Config, LoadError, LoadedConfig,
15 ResolvedAction, Scenario,
16};
17pub use escape::{decode_bytes_escape, EscapeError};
18
19#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
37#[serde(tag = "type")]
38pub enum WsMessage {
39 SerialIn { data: String },
41 SerialOut { data: String },
45 State { state: GuestState },
48 Launch,
51 Reset,
55 Hello { version: String },
58 KernelChanged {
63 ok: bool,
64 mtime: i64,
65 size: u64,
66 sha256_prefix: String,
67 reason: Option<String>,
68 },
69 ConfigUpdate { config: serde_json::Value },
73 ConfigInvalid {
77 error: String,
78 line: Option<u32>,
79 col: Option<u32>,
80 },
81 ScenarioStart { scenario: String },
88 ScenarioAbort { reason: String },
93 ScenarioResult {
113 verdict: String,
114 scenario: String,
115 started_at: String,
116 ended_at: String,
117 actions: serde_json::Value,
118 transcript: serde_json::Value,
119 error: Option<String>,
120 },
121}
122
123#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
126pub enum GuestState {
127 Idle,
129 Loading,
131 Running,
134 Halted,
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn serial_in_roundtrip() {
144 let m = WsMessage::SerialIn {
145 data: "aGVsbG8=".into(),
146 };
147 let s = serde_json::to_string(&m).unwrap();
148 assert_eq!(s, r#"{"type":"SerialIn","data":"aGVsbG8="}"#);
149 let back: WsMessage = serde_json::from_str(&s).unwrap();
150 assert_eq!(back, m);
151 }
152
153 #[test]
154 fn unit_variant_serializes_as_object_with_only_type() {
155 let launch = serde_json::to_string(&WsMessage::Launch).unwrap();
156 assert_eq!(launch, r#"{"type":"Launch"}"#);
157 let reset = serde_json::to_string(&WsMessage::Reset).unwrap();
158 assert_eq!(reset, r#"{"type":"Reset"}"#);
159
160 let back_launch: WsMessage = serde_json::from_str(&launch).unwrap();
161 assert_eq!(back_launch, WsMessage::Launch);
162 let back_reset: WsMessage = serde_json::from_str(&reset).unwrap();
163 assert_eq!(back_reset, WsMessage::Reset);
164 }
165
166 #[test]
167 fn state_message_contains_nested_state() {
168 let m = WsMessage::State {
169 state: GuestState::Running,
170 };
171 let s = serde_json::to_string(&m).unwrap();
172 assert_eq!(s, r#"{"type":"State","state":"Running"}"#);
173 let back: WsMessage = serde_json::from_str(&s).unwrap();
174 assert_eq!(back, m);
175 }
176
177 #[test]
178 fn hello_message_carries_version_string() {
179 let m = WsMessage::Hello {
180 version: "0.1.0".into(),
181 };
182 let s = serde_json::to_string(&m).unwrap();
183 assert!(s.contains(r#""version":"0.1.0""#), "got: {s}");
184 assert!(s.contains(r#""type":"Hello""#), "got: {s}");
185 let back: WsMessage = serde_json::from_str(&s).unwrap();
186 assert_eq!(back, m);
187 }
188
189 #[test]
190 fn guest_state_serializes_as_bare_string() {
191 let s = serde_json::to_string(&GuestState::Halted).unwrap();
192 assert_eq!(s, r#""Halted""#);
193 }
194
195 #[test]
196 fn wsmessage_implements_required_derives() {
197 let m = WsMessage::SerialIn {
198 data: "Zm9v".into(),
199 };
200 let cloned = m.clone();
201 assert_eq!(m, cloned);
202 }
203
204 #[test]
205 fn kernel_changed_ok_true_roundtrip() {
206 let m = WsMessage::KernelChanged {
207 ok: true,
208 mtime: 1_715_000_000,
209 size: 12_345_678,
210 sha256_prefix: "abc123def456".into(),
211 reason: None,
212 };
213 let s = serde_json::to_string(&m).unwrap();
214 let back: WsMessage = serde_json::from_str(&s).unwrap();
215 assert_eq!(back, m);
216 assert!(s.contains(r#""type":"KernelChanged""#), "got: {s}");
218 assert!(s.contains(r#""ok":true"#), "got: {s}");
219 assert!(s.contains(r#""reason":null"#), "got: {s}");
220 }
221
222 #[test]
223 fn kernel_changed_ok_false_with_reason_roundtrip() {
224 let m = WsMessage::KernelChanged {
225 ok: false,
226 mtime: 0,
227 size: 0,
228 sha256_prefix: String::new(),
229 reason: Some("not ELF".into()),
230 };
231 let s = serde_json::to_string(&m).unwrap();
232 let back: WsMessage = serde_json::from_str(&s).unwrap();
233 assert_eq!(back, m);
234 assert!(s.contains(r#""reason":"not ELF""#), "got: {s}");
235 }
236
237 #[test]
238 fn config_update_carries_opaque_value() {
239 let m = WsMessage::ConfigUpdate {
240 config: serde_json::json!({ "schema_version": 1, "actions": [] }),
241 };
242 let s = serde_json::to_string(&m).unwrap();
243 let back: WsMessage = serde_json::from_str(&s).unwrap();
244 assert_eq!(back, m);
245 assert!(s.contains(r#""type":"ConfigUpdate""#), "got: {s}");
246 }
247
248 #[test]
249 fn config_invalid_with_and_without_span() {
250 let with_span = WsMessage::ConfigInvalid {
251 error: "unknown field 'lable'".into(),
252 line: Some(12),
253 col: Some(1),
254 };
255 let s = serde_json::to_string(&with_span).unwrap();
256 let back: WsMessage = serde_json::from_str(&s).unwrap();
257 assert_eq!(back, with_span);
258 assert!(s.contains(r#""line":12"#), "got: {s}");
259 assert!(s.contains(r#""col":1"#), "got: {s}");
260
261 let without_span = WsMessage::ConfigInvalid {
262 error: "permission denied".into(),
263 line: None,
264 col: None,
265 };
266 let s = serde_json::to_string(&without_span).unwrap();
267 let back: WsMessage = serde_json::from_str(&s).unwrap();
268 assert_eq!(back, without_span);
269 assert!(s.contains(r#""line":null"#), "got: {s}");
270 assert!(s.contains(r#""col":null"#), "got: {s}");
271 }
272
273 #[test]
274 fn large_mtime_survives_i64() {
275 let m = WsMessage::KernelChanged {
277 ok: true,
278 mtime: 9_999_999_999_999_i64,
279 size: u64::MAX,
280 sha256_prefix: "deadbeefcafe".into(),
281 reason: None,
282 };
283 let s = serde_json::to_string(&m).unwrap();
284 let back: WsMessage = serde_json::from_str(&s).unwrap();
285 assert_eq!(back, m);
286 }
287
288 #[test]
289 fn scenario_start_roundtrip() {
290 let m = WsMessage::ScenarioStart {
291 scenario: "boot_smoke".into(),
292 };
293 let s = serde_json::to_string(&m).unwrap();
294 let back: WsMessage = serde_json::from_str(&s).unwrap();
295 assert_eq!(back, m);
296 assert!(s.contains(r#""type":"ScenarioStart""#), "got: {s}");
297 assert!(s.contains(r#""scenario":"boot_smoke""#), "got: {s}");
298 }
299
300 #[test]
301 fn scenario_abort_roundtrip() {
302 let m = WsMessage::ScenarioAbort {
303 reason: "outer timeout".into(),
304 };
305 let s = serde_json::to_string(&m).unwrap();
306 let back: WsMessage = serde_json::from_str(&s).unwrap();
307 assert_eq!(back, m);
308 assert!(s.contains(r#""type":"ScenarioAbort""#), "got: {s}");
309 }
310
311 #[test]
312 fn scenario_result_pass_roundtrip() {
313 let m = WsMessage::ScenarioResult {
314 verdict: "pass".into(),
315 scenario: "boot_smoke".into(),
316 started_at: "2026-05-19T14:32:01.123Z".into(),
317 ended_at: "2026-05-19T14:32:03.311Z".into(),
318 actions: serde_json::json!([{"label":"reboot","verdict":"pass"}]),
319 transcript: serde_json::json!([
320 {"ts":"2026-05-19T14:32:01.123Z","type":"scenario_start"}
321 ]),
322 error: None,
323 };
324 let s = serde_json::to_string(&m).unwrap();
325 let back: WsMessage = serde_json::from_str(&s).unwrap();
326 assert_eq!(back, m);
327 assert!(s.contains(r#""type":"ScenarioResult""#), "got: {s}");
328 assert!(s.contains(r#""verdict":"pass""#), "got: {s}");
329 assert!(s.contains(r#""error":null"#), "got: {s}");
330 }
331
332 #[test]
333 fn scenario_result_timeout_roundtrip() {
334 let m = WsMessage::ScenarioResult {
335 verdict: "timeout".into(),
336 scenario: "boot_smoke".into(),
337 started_at: "2026-05-19T14:32:01.123Z".into(),
338 ended_at: "2026-05-19T14:32:31.999Z".into(),
339 actions: serde_json::json!([]),
340 transcript: serde_json::json!([]),
341 error: Some("no serial output observed".into()),
342 };
343 let s = serde_json::to_string(&m).unwrap();
344 let back: WsMessage = serde_json::from_str(&s).unwrap();
345 assert_eq!(back, m);
346 assert!(
347 s.contains(r#""error":"no serial output observed""#),
348 "got: {s}"
349 );
350 }
351
352 #[test]
353 fn scenario_result_opaque_payload_roundtrip() {
354 let actions = serde_json::json!([
359 {
360 "label": "reboot",
361 "verdict": "pass",
362 "assertions": [
363 {"kind": "regex", "pattern": "hello", "verdict": "pass"},
364 {"kind": "regex", "pattern": "world", "verdict": "pass"}
365 ]
366 },
367 {
368 "label": "halt",
369 "verdict": "fail",
370 "assertions": [
371 {"kind": "regex", "pattern": "halted", "verdict": "fail"}
372 ]
373 }
374 ]);
375 let transcript = serde_json::json!([
376 {"ts": "2026-05-19T14:32:01.123Z", "type": "scenario_start", "scenario": "boot_smoke"},
377 {"ts": "2026-05-19T14:32:01.456Z", "type": "action_start", "label": "reboot"},
378 {"ts": "2026-05-19T14:32:02.789Z", "type": "serial_out", "data_b64": "aGVsbG8gd29ybGQK"},
379 {"ts": "2026-05-19T14:32:03.000Z", "type": "action_end", "label": "reboot", "verdict": "pass"},
380 {"ts": "2026-05-19T14:32:03.311Z", "type": "scenario_end", "verdict": "fail"}
381 ]);
382 let m = WsMessage::ScenarioResult {
383 verdict: "fail".into(),
384 scenario: "boot_smoke".into(),
385 started_at: "2026-05-19T14:32:01.123Z".into(),
386 ended_at: "2026-05-19T14:32:03.311Z".into(),
387 actions,
388 transcript,
389 error: None,
390 };
391 let s = serde_json::to_string(&m).unwrap();
392 let back: WsMessage = serde_json::from_str(&s).unwrap();
393 assert_eq!(back, m);
394 }
395}