1use serde::{Deserialize, Serialize};
25
26use crate::convert::ResolvedPoint;
27use crate::flow::{Settings, Step};
28
29pub const PROTOCOL_VERSION: u32 = 1;
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct Request {
36 pub id: Option<u64>,
38 pub body: RequestBody,
39}
40
41#[derive(Debug, Clone, PartialEq)]
50pub enum RequestBody {
51 Hello {
56 version: u32,
57 settings: Option<Settings>,
58 },
59 Step { step: Step },
61 Relocate,
63 Bye,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct Response {
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub id: Option<u64>,
72 #[serde(flatten)]
73 pub body: ResponseBody,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "result", rename_all = "snake_case")]
78pub enum ResponseBody {
79 Welcome {
81 version: u32,
82 verbs: Vec<String>,
85 session: String,
86 },
87 Done {
95 outcome: String,
96 #[serde(skip_serializing_if = "Vec::is_empty", default)]
97 points: Vec<ResolvedPoint>,
98 #[serde(skip_serializing_if = "Option::is_none", default)]
101 detail: Option<String>,
102 elapsed_ms: u64,
103 },
104 Located {
106 moved: Vec<String>,
108 missing: Vec<String>,
111 },
112 Closed,
114 Error { detail: String },
117}
118
119impl Response {
120 pub fn error(id: Option<u64>, detail: impl Into<String>) -> Self {
121 Self {
122 id,
123 body: ResponseBody::Error {
124 detail: detail.into(),
125 },
126 }
127 }
128
129 pub fn to_line(&self) -> String {
135 match serde_json::to_string(self) {
136 Ok(json) => format!("{json}\n"),
137 Err(error) => format!(
138 "{{\"result\":\"error\",\"detail\":\"could not serialize response: {error}\"}}\n"
139 ),
140 }
141 }
142}
143
144pub fn id_in(line: &str) -> Option<u64> {
152 serde_json::from_str::<serde_json::Value>(line)
153 .ok()?
154 .get("id")?
155 .as_u64()
156}
157
158pub fn parse_request(line: &str) -> Result<Request, String> {
165 let mut value: serde_json::Value = serde_json::from_str(line).map_err(|error| {
166 format!(
167 "not a valid request: {error}. \
168 Expected one JSON object per line, e.g. \
169 {{\"id\":1,\"do\":\"click\",\"target\":\"submit\"}}"
170 )
171 })?;
172 let object = value
173 .as_object_mut()
174 .ok_or_else(|| "a request must be a JSON object".to_string())?;
175
176 let id = object.get("id").and_then(serde_json::Value::as_u64);
177 let verb = object
178 .remove("do")
179 .ok_or_else(|| "a request needs a \"do\" naming the action, e.g. \"click\"".to_string())?;
180 let verb = verb
181 .as_str()
182 .ok_or_else(|| "\"do\" must be a string".to_string())?
183 .to_string();
184
185 let body = match verb.as_str() {
186 "hello" => {
187 let version = object
188 .get("version")
189 .and_then(serde_json::Value::as_u64)
190 .and_then(|version| u32::try_from(version).ok())
191 .ok_or_else(|| {
192 format!(
193 "hello needs a \"version\" number; \
194 this build speaks version {PROTOCOL_VERSION}"
195 )
196 })?;
197 let settings = match object.remove("settings") {
198 None => None,
199 Some(value) => {
200 Some(serde_json::from_value::<Settings>(value).map_err(|error| {
201 format!("hello carried settings this build cannot read: {error}")
202 })?)
203 }
204 };
205 RequestBody::Hello { version, settings }
206 }
207 "relocate" => RequestBody::Relocate,
208 "bye" => RequestBody::Bye,
209 step_name => {
210 object.remove("id");
211 object.insert(
212 "action".to_string(),
213 serde_json::Value::String(step_name.to_string()),
214 );
215 let step: Step = serde_json::from_value(value)
219 .map_err(|error| format!("cannot read {step_name:?} as an action: {error}"))?;
220 RequestBody::Step { step }
221 }
222 };
223 Ok(Request { id, body })
224}
225
226pub fn supported_verbs() -> Vec<String> {
229 [
230 "click",
231 "double_click",
232 "drag",
233 "type",
234 "key",
235 "verify",
236 "wait_for",
237 "wait_gone",
238 "pause",
239 ]
240 .iter()
241 .map(|s| (*s).to_string())
242 .collect()
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn a_step_request_reads_the_same_vocabulary_as_flows() {
251 let request = parse_request(r#"{"id":7,"do":"click","target":"submit"}"#).expect("valid");
252 assert_eq!(request.id, Some(7));
253 assert_eq!(
254 request.body,
255 RequestBody::Step {
256 step: Step::Click {
257 target: "submit".into()
258 }
259 }
260 );
261 }
262
263 #[test]
264 fn every_step_kind_crosses_the_wire() {
265 for (line, expected) in [
266 (
267 r#"{"do":"type","text":"hello"}"#,
268 Step::Type {
269 text: "hello".into(),
270 },
271 ),
272 (
273 r#"{"do":"key","chord":"cmd+s"}"#,
274 Step::Key {
275 chord: "cmd+s".into(),
276 },
277 ),
278 (r#"{"do":"pause","ms":250}"#, Step::Pause { ms: 250 }),
279 (
280 r#"{"do":"drag","from":"a","to":"b"}"#,
281 Step::Drag {
282 from: "a".into(),
283 to: "b".into(),
284 },
285 ),
286 (
287 r#"{"do":"wait_for","target":"dialog"}"#,
288 Step::WaitFor {
289 target: "dialog".into(),
290 },
291 ),
292 ] {
293 let request = parse_request(line).expect("valid");
294 assert_eq!(
295 request.body,
296 RequestBody::Step { step: expected },
297 "line: {line}"
298 );
299 }
300 }
301
302 #[test]
303 fn an_id_is_optional_and_echoed_when_present() {
304 assert_eq!(
305 parse_request(r#"{"do":"relocate"}"#).expect("valid").id,
306 None
307 );
308 let response = Response::error(Some(3), "nope");
309 assert!(response.to_line().contains("\"id\":3"));
310 }
311
312 #[test]
313 fn a_response_is_exactly_one_line() {
314 let response = Response {
315 id: Some(1),
316 body: ResponseBody::Done {
317 outcome: "verified".into(),
318 points: Vec::new(),
319 detail: None,
320 elapsed_ms: 12,
321 },
322 };
323 let line = response.to_line();
324 assert_eq!(line.matches('\n').count(), 1, "exactly one newline");
325 assert!(line.ends_with('\n'));
326 assert!(!line.trim_end().contains('\n'), "no embedded newlines");
327 }
328
329 #[test]
330 fn garbage_becomes_a_readable_error_not_a_crash() {
331 let error = parse_request("not json at all").expect_err("should fail");
332 assert!(
333 error.contains("one JSON object per line"),
334 "teaches the shape: {error}"
335 );
336 }
337
338 #[test]
339 fn an_unknown_verb_is_refused_rather_than_guessed() {
340 assert!(parse_request(r#"{"do":"teleport","target":"home"}"#).is_err());
341 }
342
343 #[test]
344 fn a_request_without_a_verb_says_so() {
345 let error = parse_request(r#"{"id":1}"#).expect_err("no verb");
346 assert!(error.contains("\"do\""), "names the missing key: {error}");
347 }
348
349 #[test]
350 fn a_malformed_step_names_the_field_it_wanted() {
351 let error = parse_request(r#"{"do":"click"}"#).expect_err("missing target");
353 assert!(error.contains("target"), "names the missing field: {error}");
354 }
355
356 #[test]
357 fn an_unknown_verb_lists_the_ones_that_exist() {
358 let error = parse_request(r#"{"do":"teleport"}"#).expect_err("unknown");
359 assert!(error.contains("wait_for"), "lists what exists: {error}");
360 }
361
362 #[test]
363 fn the_handshake_advertises_what_this_build_can_do() {
364 let request = parse_request(r#"{"id":0,"do":"hello","version":1}"#).expect("valid");
365 assert_eq!(
366 request.body,
367 RequestBody::Hello {
368 version: PROTOCOL_VERSION,
369 settings: None
370 }
371 );
372 let verbs = supported_verbs();
373 assert!(verbs.contains(&"click".to_string()));
374 assert!(verbs.contains(&"wait_for".to_string()));
375 }
376
377 #[test]
378 fn a_handshake_can_carry_run_settings() {
379 let request =
380 parse_request(r#"{"do":"hello","version":1,"settings":{"timeout_ms":30000}}"#)
381 .expect("valid");
382 let RequestBody::Hello { settings, .. } = request.body else {
383 panic!("expected a hello");
384 };
385 let settings = settings.expect("settings were sent");
386 assert_eq!(settings.timeout_ms, 30_000);
387 assert!(settings.relocate);
389 }
390
391 #[test]
392 fn a_handshake_without_a_version_says_which_one_we_speak() {
393 let error = parse_request(r#"{"do":"hello"}"#).expect_err("no version");
394 assert!(
395 error.contains(&PROTOCOL_VERSION.to_string()),
396 "names the version to send: {error}"
397 );
398 }
399
400 #[test]
401 fn a_typo_in_settings_is_an_error_not_a_silent_default() {
402 let error = parse_request(r#"{"do":"hello","version":1,"settings":{"timeout":1}}"#)
404 .expect_err("unknown field");
405 assert!(error.contains("settings"), "says where: {error}");
406 }
407
408 #[test]
409 fn a_failed_step_carries_its_reason() {
410 let response = Response {
411 id: None,
412 body: ResponseBody::Done {
413 outcome: "failed".into(),
414 points: Vec::new(),
415 detail: Some("timed out after 10000ms".into()),
416 elapsed_ms: 10_004,
417 },
418 };
419 assert!(response.to_line().contains("timed out"));
420 }
421
422 #[test]
423 fn a_rejected_request_can_still_be_matched_to_its_id() {
424 assert_eq!(id_in(r#"{"id":42,"do":"click"}"#), Some(42));
427 assert_eq!(id_in(r#"{"do":"click"}"#), None);
428 assert_eq!(id_in("not json"), None);
429 }
430
431 #[test]
432 fn responses_round_trip() {
433 let response = Response {
434 id: Some(9),
435 body: ResponseBody::Located {
436 moved: vec!["submit".into()],
437 missing: Vec::new(),
438 },
439 };
440 let line = response.to_line();
441 let back: Response = serde_json::from_str(line.trim_end()).expect("round trip");
442 assert_eq!(back, response);
443 }
444}