1use crate::challenges::{DetectChallengeIntent, SolveChallengeIntent};
11use crate::commands::{
12 CommandEnvelope, CompleteFormIntent, ControlAction, DismissObstructionIntent, ExtractField,
13 ExtractIntent, FillIntent, FollowIntent, IntentCommand, IntentHints, LocateIntent,
14 RuntimeCommand, SubmitAndVerifyIntent, WaitCondition, WaitForCommand, WaitForStateIntent,
15 MAX_INTENT_PURPOSE_BYTES,
16};
17use crate::ids::{AttemptId, CommandId, PageId, SessionId, WorkflowId};
18use chrono::{DateTime, Utc};
19
20#[derive(Debug, Clone)]
23pub struct IntentEnvelopeMeta {
24 pub command_id: CommandId,
25 pub workflow_id: WorkflowId,
26 pub attempt_id: AttemptId,
27 pub session_id: SessionId,
28 pub page_id: Option<PageId>,
29 pub deadline: DateTime<Utc>,
31}
32
33fn assert_intent_purpose(purpose: &str) {
37 if purpose.len() > MAX_INTENT_PURPOSE_BYTES {
38 panic!(
39 "intent purpose exceeds {MAX_INTENT_PURPOSE_BYTES} bytes (got {})",
40 purpose.len()
41 );
42 }
43 if purpose.trim().is_empty() {
44 panic!("intent purpose must be a non-empty string");
45 }
46}
47
48fn normalize_fill_value(value: ControlAction) -> ControlAction {
53 value
54}
55
56pub fn intent_envelope(meta: IntentEnvelopeMeta, command: RuntimeCommand) -> CommandEnvelope {
58 debug_assert!(
59 matches!(command, RuntimeCommand::Intent(_)),
60 "intentEnvelope requires a RuntimeCommand with kind \"intent\""
61 );
62 CommandEnvelope {
63 schema_version: CommandEnvelope::SCHEMA_VERSION,
64 command_id: meta.command_id,
65 workflow_id: meta.workflow_id,
66 attempt_id: meta.attempt_id,
67 session_id: meta.session_id,
68 page_id: meta.page_id,
69 deadline: meta.deadline,
70 command,
71 }
72}
73
74pub fn locate_runtime_command(input: LocateIntent) -> RuntimeCommand {
76 assert_intent_purpose(&input.purpose);
77 RuntimeCommand::Intent(IntentCommand::Locate(LocateIntent {
78 purpose: input.purpose,
79 hints: input.hints,
80 }))
81}
82
83pub fn fill_runtime_command(input: FillIntent) -> RuntimeCommand {
85 assert_intent_purpose(&input.purpose);
86 RuntimeCommand::Intent(IntentCommand::Fill(FillIntent {
87 purpose: input.purpose,
88 hints: input.hints,
89 value: normalize_fill_value(input.value),
90 }))
91}
92
93pub fn complete_form_runtime_command(input: CompleteFormIntent) -> RuntimeCommand {
95 assert_intent_purpose(&input.purpose);
96 assert!(
97 !input.fields.is_empty(),
98 "completeForm fields must not be empty"
99 );
100 assert!(
101 input.fields.len() <= 128,
102 "completeForm fields must not exceed 128 items"
103 );
104 let mut names = std::collections::HashSet::new();
105 let fields = input
106 .fields
107 .into_iter()
108 .map(|mut field| {
109 assert!(
110 !field.name.trim().is_empty(),
111 "completeForm field name must not be empty"
112 );
113 assert!(
114 names.insert(field.name.clone()),
115 "duplicate completeForm field name: {}",
116 field.name
117 );
118 assert_intent_purpose(&field.purpose);
119 field.value = normalize_fill_value(field.value);
120 field
121 })
122 .collect();
123 RuntimeCommand::Intent(IntentCommand::CompleteForm(CompleteFormIntent {
124 purpose: input.purpose,
125 fields,
126 }))
127}
128
129pub fn submit_and_verify_runtime_command(input: SubmitAndVerifyIntent) -> RuntimeCommand {
131 assert_intent_purpose(&input.purpose);
132 RuntimeCommand::Intent(IntentCommand::SubmitAndVerify(SubmitAndVerifyIntent {
133 purpose: input.purpose,
134 hints: input.hints,
135 expected_state: input.expected_state,
136 }))
137}
138
139pub fn wait_for_state_runtime_command(input: WaitForStateIntent) -> RuntimeCommand {
141 RuntimeCommand::Intent(IntentCommand::WaitForState(WaitForStateIntent {
142 condition: input.condition,
143 timeout_ms: input.timeout_ms,
144 }))
145}
146
147pub fn follow_runtime_command(input: FollowIntent) -> RuntimeCommand {
149 assert_intent_purpose(&input.purpose);
150 RuntimeCommand::Intent(IntentCommand::Follow(FollowIntent {
151 purpose: input.purpose,
152 hints: input.hints,
153 expected_destination: input.expected_destination,
154 boundary: input.boundary,
155 }))
156}
157
158pub fn dismiss_obstruction_runtime_command(input: DismissObstructionIntent) -> RuntimeCommand {
160 assert_intent_purpose(&input.purpose);
161 RuntimeCommand::Intent(IntentCommand::DismissObstruction(
162 DismissObstructionIntent {
163 purpose: input.purpose,
164 hints: input.hints,
165 timeout_ms: input.timeout_ms,
166 },
167 ))
168}
169
170pub fn extract_runtime_command(input: ExtractIntent) -> RuntimeCommand {
172 assert_intent_purpose(&input.purpose);
173 assert!(
174 !input.fields.is_empty(),
175 "extract intent must include at least one field"
176 );
177 let mut seen = std::collections::HashSet::new();
178 for field in &input.fields {
179 assert_intent_purpose(&field.purpose);
180 assert!(
181 seen.insert(field.name.clone()),
182 "duplicate extract field name: {}",
183 field.name
184 );
185 }
186 RuntimeCommand::Intent(IntentCommand::Extract(ExtractIntent {
187 purpose: input.purpose,
188 fields: input.fields,
189 }))
190}
191
192pub fn detect_challenge_runtime_command(input: DetectChallengeIntent) -> RuntimeCommand {
194 assert_intent_purpose(&input.purpose);
195 RuntimeCommand::Intent(IntentCommand::DetectChallenge(DetectChallengeIntent {
196 purpose: input.purpose,
197 hints: input.hints,
198 }))
199}
200
201pub fn solve_challenge_runtime_command(input: SolveChallengeIntent) -> RuntimeCommand {
203 assert_intent_purpose(&input.purpose);
204 RuntimeCommand::Intent(IntentCommand::SolveChallenge(SolveChallengeIntent {
205 purpose: input.purpose,
206 hints: input.hints,
207 }))
208}
209
210pub fn locate_envelope(
212 meta: IntentEnvelopeMeta,
213 purpose: &str,
214 hints: Option<IntentHints>,
215) -> CommandEnvelope {
216 intent_envelope(
217 meta,
218 locate_runtime_command(LocateIntent {
219 purpose: purpose.into(),
220 hints: hints.unwrap_or_default(),
221 }),
222 )
223}
224
225pub fn fill_envelope(
227 meta: IntentEnvelopeMeta,
228 purpose: &str,
229 value: ControlAction,
230 hints: Option<IntentHints>,
231) -> CommandEnvelope {
232 intent_envelope(
233 meta,
234 fill_runtime_command(FillIntent {
235 purpose: purpose.into(),
236 value,
237 hints: hints.unwrap_or_default(),
238 }),
239 )
240}
241
242pub fn submit_and_verify_envelope(
244 meta: IntentEnvelopeMeta,
245 purpose: &str,
246 expected_state: WaitForCommand,
247 hints: Option<IntentHints>,
248) -> CommandEnvelope {
249 intent_envelope(
250 meta,
251 submit_and_verify_runtime_command(SubmitAndVerifyIntent {
252 purpose: purpose.into(),
253 expected_state,
254 hints: hints.unwrap_or_default(),
255 }),
256 )
257}
258
259pub fn wait_for_state_envelope(
261 meta: IntentEnvelopeMeta,
262 condition: WaitCondition,
263 timeout_ms: u64,
264) -> CommandEnvelope {
265 intent_envelope(
266 meta,
267 wait_for_state_runtime_command(WaitForStateIntent {
268 condition,
269 timeout_ms,
270 }),
271 )
272}
273
274pub fn follow_envelope(
276 meta: IntentEnvelopeMeta,
277 purpose: &str,
278 expected_destination: WaitForCommand,
279 boundary: bool,
280 hints: Option<IntentHints>,
281) -> CommandEnvelope {
282 intent_envelope(
283 meta,
284 follow_runtime_command(FollowIntent {
285 purpose: purpose.into(),
286 expected_destination,
287 hints: hints.unwrap_or_default(),
288 boundary,
289 }),
290 )
291}
292
293pub fn dismiss_obstruction_envelope(
295 meta: IntentEnvelopeMeta,
296 purpose: &str,
297 timeout_ms: Option<u64>,
298) -> CommandEnvelope {
299 intent_envelope(
300 meta,
301 dismiss_obstruction_runtime_command(DismissObstructionIntent {
302 purpose: purpose.into(),
303 hints: IntentHints::default(),
304 timeout_ms: timeout_ms
305 .unwrap_or(crate::commands::DEFAULT_DISMISS_OBSTRUCTION_TIMEOUT_MS),
306 }),
307 )
308}
309
310pub fn extract_envelope(
312 meta: IntentEnvelopeMeta,
313 purpose: &str,
314 fields: Vec<ExtractField>,
315) -> CommandEnvelope {
316 intent_envelope(
317 meta,
318 extract_runtime_command(ExtractIntent {
319 purpose: purpose.into(),
320 fields,
321 }),
322 )
323}
324
325pub fn detect_challenge_envelope(
327 meta: IntentEnvelopeMeta,
328 purpose: &str,
329 hints: Option<crate::challenges::DetectChallengeHints>,
330) -> CommandEnvelope {
331 intent_envelope(
332 meta,
333 detect_challenge_runtime_command(DetectChallengeIntent {
334 purpose: purpose.into(),
335 hints: hints.unwrap_or_default(),
336 }),
337 )
338}
339
340pub fn solve_challenge_envelope(
342 meta: IntentEnvelopeMeta,
343 purpose: &str,
344 hints: Option<crate::challenges::SolveChallengeHints>,
345) -> CommandEnvelope {
346 intent_envelope(
347 meta,
348 solve_challenge_runtime_command(SolveChallengeIntent {
349 purpose: purpose.into(),
350 hints: hints.unwrap_or_default(),
351 }),
352 )
353}
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::commands::{CompleteFormField, WaitUntil};
358 use crate::ids::PageId;
359
360 fn meta() -> IntentEnvelopeMeta {
361 IntentEnvelopeMeta {
362 command_id: CommandId::new(),
363 workflow_id: WorkflowId::new(),
364 attempt_id: AttemptId::new(),
365 session_id: SessionId::new(),
366 page_id: Some(PageId::new()),
367 deadline: Utc::now(),
368 }
369 }
370
371 #[test]
374 fn submit_envelope_wraps_the_nested_wire_shape() {
375 let envelope = submit_and_verify_envelope(
376 meta(),
377 "Submit the customer priority",
378 WaitForCommand {
379 condition: WaitCondition::Document {
380 ready: WaitUntil::Interactive,
381 },
382 timeout_ms: 1_000,
383 },
384 None,
385 );
386 assert_eq!(envelope.schema_version, CommandEnvelope::SCHEMA_VERSION);
387 match &envelope.command {
388 RuntimeCommand::Intent(IntentCommand::SubmitAndVerify(submit)) => {
389 assert_eq!(submit.purpose, "Submit the customer priority");
390 assert_eq!(
391 submit.expected_state.condition,
392 WaitCondition::Document {
393 ready: WaitUntil::Interactive
394 }
395 );
396 }
397 other => panic!("expected intent submitAndVerify, got {other:?}"),
398 }
399 }
400
401 #[test]
402 fn complete_form_builds_unique_named_fields() {
403 let command = complete_form_runtime_command(CompleteFormIntent {
404 purpose: "fill the onboarding form".into(),
405 fields: vec![CompleteFormField {
406 name: "email".into(),
407 purpose: "work address".into(),
408 hints: IntentHints::default(),
409 value: ControlAction::SetText {
410 value: "ada@example.test".into(),
411 clear_first: true,
412 },
413 revealed_by: None,
414 }],
415 });
416 match command {
417 RuntimeCommand::Intent(IntentCommand::CompleteForm(form)) => {
418 assert_eq!(form.fields.len(), 1);
419 }
420 other => panic!("expected completeForm, got {other:?}"),
421 }
422 }
423
424 #[test]
425 fn duplicate_extract_field_names_are_refused() {
426 let field = ExtractField {
427 name: "email".into(),
428 purpose: "address".into(),
429 hints: IntentHints::default(),
430 value: crate::commands::ExtractValueKind::Text,
431 };
432 let result = std::panic::catch_unwind(|| {
433 extract_runtime_command(ExtractIntent {
434 purpose: "read the header".into(),
435 fields: vec![field.clone(), field],
436 })
437 });
438 assert!(result.is_err(), "duplicate field names must panic");
439 }
440
441 #[test]
442 fn challenge_envelopes_carry_their_hints() {
443 let envelope = solve_challenge_envelope(
444 meta(),
445 "clear the recaptcha",
446 Some(crate::challenges::SolveChallengeHints {
447 region: Some(crate::challenges::ChallengeRegion {
448 x: 1.0,
449 y: 2.0,
450 width: 3.0,
451 height: 4.0,
452 }),
453 timeout_ms: 45_000,
454 }),
455 );
456 match &envelope.command {
457 RuntimeCommand::Intent(IntentCommand::SolveChallenge(solve)) => {
458 assert_eq!(solve.hints.timeout_ms, 45_000);
459 assert!(solve.hints.region.is_some());
460 }
461 other => panic!("expected solveChallenge, got {other:?}"),
462 }
463 }
464}