1use chrono::Utc;
8
9use crate::event::{AppEvent, InteractionKind, IpcResult};
10use crate::recording::RecordedSession;
11
12#[derive(Debug, Clone, Eq, PartialEq)]
14pub struct CodegenOptions {
15 pub test_name: String,
17 pub include_ipc_assertions: bool,
19 pub include_state_checks: bool,
21 pub include_timing_comments: bool,
23}
24
25impl Default for CodegenOptions {
26 fn default() -> Self {
27 Self {
28 test_name: "recorded_flow".to_string(),
29 include_ipc_assertions: true,
30 include_state_checks: true,
31 include_timing_comments: true,
32 }
33 }
34}
35
36#[must_use]
41pub fn generate_test_default(session: &RecordedSession) -> String {
42 generate_test(session, &CodegenOptions::default())
43}
44
45#[must_use]
51pub fn generate_test(session: &RecordedSession, options: &CodegenOptions) -> String {
52 let mut out = String::with_capacity(2048);
53
54 let date = Utc::now().format("%Y-%m-%d");
56 out.push_str(&format!("// Generated by victauri record -- {date}\n"));
57 out.push_str(&format!("// Session: {}\n", session.id));
58 out.push_str("\nuse victauri_test::VictauriClient;\n\n");
59
60 out.push_str("#[tokio::test]\n");
62 out.push_str(&format!("async fn {}() {{\n", options.test_name));
63 out.push_str(
64 " let mut client = VictauriClient::discover().await.expect(\"connect to Tauri app\");\n",
65 );
66
67 let session_start = session.started_at;
68
69 for (i, recorded) in session.events.iter().enumerate() {
70 if options.include_timing_comments {
72 let elapsed_ms = recorded
73 .timestamp
74 .signed_duration_since(session_start)
75 .num_milliseconds();
76
77 let show_timing = if i == 0 {
78 elapsed_ms > 500
79 } else {
80 let prev_ts = session.events[i - 1].timestamp;
81 let gap_ms = recorded
82 .timestamp
83 .signed_duration_since(prev_ts)
84 .num_milliseconds();
85 gap_ms > 500
86 };
87
88 if show_timing {
89 out.push_str(&format!("\n // +{elapsed_ms}ms\n"));
90 }
91 }
92
93 match &recorded.event {
94 AppEvent::DomInteraction {
95 action,
96 selector,
97 value,
98 ..
99 } => {
100 emit_interaction(&mut out, action, selector, value.as_deref());
101 }
102
103 AppEvent::Ipc(call) if options.include_ipc_assertions => {
104 if call.command.starts_with("plugin:victauri|") {
106 continue;
107 }
108 if matches!(call.result, IpcResult::Ok(_)) {
109 let cmd = &call.command;
110 out.push_str(&format!(" // IPC: {cmd} completed successfully\n"));
111 }
112 }
113
114 AppEvent::StateChange { key, caused_by, .. }
115 if options.include_state_checks && caused_by.is_some() =>
116 {
117 out.push_str(&format!(" // State changed: {key}\n"));
118 }
119
120 _ => {}
122 }
123 }
124
125 out.push_str("}\n");
126 out
127}
128
129fn escape_rust_str(s: &str) -> String {
131 let mut escaped = String::with_capacity(s.len());
132 for ch in s.chars() {
133 match ch {
134 '\\' => escaped.push_str("\\\\"),
135 '"' => escaped.push_str("\\\""),
136 '\n' => escaped.push_str("\\n"),
137 '\r' => escaped.push_str("\\r"),
138 '\t' => escaped.push_str("\\t"),
139 other => escaped.push(other),
140 }
141 }
142 escaped
143}
144
145enum ResolvedSelector {
151 ById(String),
153 ByText(String),
155 Raw(String),
157}
158
159fn resolve_selector(selector: &str) -> ResolvedSelector {
161 if let Some(start) = selector.find(":has-text(\"") {
163 let text_start = start + ":has-text(\"".len();
164 if let Some(end) = selector[text_start..].find("\")") {
165 let text = &selector[text_start..text_start + end];
166 return ResolvedSelector::ByText(text.to_string());
167 }
168 }
169
170 if selector.starts_with('#') && !selector[1..].contains(' ') {
172 let id = &selector[1..];
173 return ResolvedSelector::ById(id.to_string());
174 }
175
176 ResolvedSelector::Raw(selector.to_string())
177}
178
179fn emit_interaction(
185 out: &mut String,
186 action: &InteractionKind,
187 selector: &str,
188 value: Option<&str>,
189) {
190 let resolved = resolve_selector(selector);
191
192 match action {
193 InteractionKind::Click => {
194 emit_resolved_call(out, "click", &resolved, None);
195 }
196 InteractionKind::DoubleClick => {
197 emit_resolved_call(out, "double_click", &resolved, None);
198 }
199 InteractionKind::Fill => {
200 let val = value.map_or_else(String::new, escape_rust_str);
201 emit_resolved_call(out, "fill", &resolved, Some(&val));
202 }
203 InteractionKind::KeyPress => {
204 let val = value.map_or_else(String::new, escape_rust_str);
205 out.push_str(&format!(
206 " client.press_key(\"{val}\").await.unwrap();\n"
207 ));
208 }
209 InteractionKind::Select => {
210 let val = value.map_or_else(String::new, escape_rust_str);
211 emit_resolved_select(out, &resolved, &val);
212 }
213 InteractionKind::Navigate => {
214 let val = value.map_or_else(String::new, escape_rust_str);
215 out.push_str(&format!(" client.navigate(\"{val}\").await.unwrap();\n"));
216 }
217 InteractionKind::Scroll => {
218 emit_resolved_scroll(out, &resolved);
219 }
220 }
221}
222
223fn emit_resolved_call(
229 out: &mut String,
230 base_method: &str,
231 resolved: &ResolvedSelector,
232 extra_arg: Option<&str>,
233) {
234 let suffix = extra_arg.map_or_else(String::new, |v| format!(", \"{v}\""));
235 match resolved {
236 ResolvedSelector::ById(id) => {
237 let escaped = escape_rust_str(id);
238 out.push_str(&format!(
239 " client.{base_method}_by_id(\"{escaped}\"{suffix}).await.unwrap();\n"
240 ));
241 }
242 ResolvedSelector::ByText(text) => {
243 let escaped = escape_rust_str(text);
244 out.push_str(&format!(
245 " client.{base_method}_by_text(\"{escaped}\"{suffix}).await.unwrap();\n"
246 ));
247 }
248 ResolvedSelector::Raw(sel) => {
249 let escaped = escape_rust_str(sel);
250 out.push_str(&format!(
251 " client.{base_method}_by_selector(\"{escaped}\"{suffix}).await.unwrap();\n"
252 ));
253 }
254 }
255}
256
257fn emit_resolved_select(out: &mut String, resolved: &ResolvedSelector, val: &str) {
262 match resolved {
263 ResolvedSelector::ById(id) => {
264 let escaped = escape_rust_str(id);
265 out.push_str(&format!(
266 " client.select_option_by_id(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
267 ));
268 }
269 ResolvedSelector::ByText(text) => {
270 let escaped = escape_rust_str(text);
271 out.push_str(&format!(
272 " client.select_option_by_text(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
273 ));
274 }
275 ResolvedSelector::Raw(sel) => {
276 let escaped = escape_rust_str(sel);
277 out.push_str(&format!(
278 " client.select_option_by_selector(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
279 ));
280 }
281 }
282}
283
284fn emit_resolved_scroll(out: &mut String, resolved: &ResolvedSelector) {
290 match resolved {
291 ResolvedSelector::ById(id) => {
292 let escaped = escape_rust_str(id);
293 out.push_str(&format!(
294 " client.scroll_to_by_id(\"{escaped}\").await.unwrap();\n"
295 ));
296 }
297 ResolvedSelector::ByText(_) | ResolvedSelector::Raw(_) => {
298 let sel = match resolved {
299 ResolvedSelector::ByText(t) => escape_rust_str(t),
300 ResolvedSelector::Raw(s) => escape_rust_str(s),
301 _ => unreachable!(),
302 };
303 out.push_str(&format!(
304 " client.scroll_to_by_selector(\"{sel}\").await.unwrap();\n"
305 ));
306 }
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use chrono::{Duration, Utc};
313
314 use super::*;
315 use crate::event::{AppEvent, InteractionKind, IpcCall, IpcResult};
316 use crate::recording::{RecordedEvent, RecordedSession};
317
318 fn make_session(events: Vec<RecordedEvent>) -> RecordedSession {
319 RecordedSession {
320 id: "test-session-001".to_string(),
321 started_at: Utc::now(),
322 events,
323 checkpoints: vec![],
324 }
325 }
326
327 fn interaction_event(
328 index: usize,
329 action: InteractionKind,
330 selector: &str,
331 value: Option<&str>,
332 offset_ms: i64,
333 ) -> RecordedEvent {
334 RecordedEvent {
335 index,
336 timestamp: Utc::now() + Duration::milliseconds(offset_ms),
337 event: AppEvent::DomInteraction {
338 action,
339 selector: selector.to_string(),
340 value: value.map(String::from),
341 timestamp: Utc::now() + Duration::milliseconds(offset_ms),
342 webview_label: "main".to_string(),
343 },
344 }
345 }
346
347 #[test]
348 fn empty_session_produces_valid_skeleton() {
349 let session = make_session(vec![]);
350 let code = generate_test_default(&session);
351
352 assert!(code.contains("use victauri_test::VictauriClient;"));
353 assert!(code.contains("#[tokio::test]"));
354 assert!(code.contains("async fn recorded_flow()"));
355 assert!(code.contains("VictauriClient::discover()"));
356 assert!(code.contains("Session: test-session-001"));
357 }
358
359 #[test]
360 fn click_by_id_generated_for_hash_selector() {
361 let session = make_session(vec![interaction_event(
362 0,
363 InteractionKind::Click,
364 "#submit-btn",
365 None,
366 0,
367 )]);
368 let code = generate_test_default(&session);
369
370 assert!(
371 code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
372 "expected click_by_id for # selector, got:\n{code}"
373 );
374 }
375
376 #[test]
377 fn fill_generates_correct_call() {
378 let session = make_session(vec![interaction_event(
379 0,
380 InteractionKind::Fill,
381 "input[name=\"email\"]",
382 Some("user@example.com"),
383 0,
384 )]);
385 let code = generate_test_default(&session);
386
387 assert!(code.contains(
388 "client.fill_by_selector(\"input[name=\\\"email\\\"]\", \"user@example.com\").await.unwrap();"
389 ));
390 }
391
392 #[test]
393 fn ipc_comment_included_when_enabled() {
394 let session = make_session(vec![RecordedEvent {
395 index: 0,
396 timestamp: Utc::now(),
397 event: AppEvent::Ipc(IpcCall {
398 id: "c1".to_string(),
399 command: "save_settings".to_string(),
400 timestamp: Utc::now(),
401 duration_ms: Some(10),
402 result: IpcResult::Ok(serde_json::json!(true)),
403 arg_size_bytes: 0,
404 webview_label: "main".to_string(),
405 }),
406 }]);
407 let code = generate_test_default(&session);
408
409 assert!(code.contains("// IPC: save_settings completed successfully"));
410 }
411
412 #[test]
413 fn internal_victauri_ipc_skipped() {
414 let session = make_session(vec![RecordedEvent {
415 index: 0,
416 timestamp: Utc::now(),
417 event: AppEvent::Ipc(IpcCall {
418 id: "c2".to_string(),
419 command: "plugin:victauri|get_snapshot".to_string(),
420 timestamp: Utc::now(),
421 duration_ms: Some(2),
422 result: IpcResult::Ok(serde_json::json!({})),
423 arg_size_bytes: 0,
424 webview_label: "main".to_string(),
425 }),
426 }]);
427 let code = generate_test_default(&session);
428
429 assert!(!code.contains("plugin:victauri"));
430 }
431
432 #[test]
433 fn ipc_omitted_when_disabled() {
434 let session = make_session(vec![RecordedEvent {
435 index: 0,
436 timestamp: Utc::now(),
437 event: AppEvent::Ipc(IpcCall {
438 id: "c1".to_string(),
439 command: "save_settings".to_string(),
440 timestamp: Utc::now(),
441 duration_ms: Some(10),
442 result: IpcResult::Ok(serde_json::json!(true)),
443 arg_size_bytes: 0,
444 webview_label: "main".to_string(),
445 }),
446 }]);
447 let opts = CodegenOptions {
448 include_ipc_assertions: false,
449 ..CodegenOptions::default()
450 };
451 let code = generate_test(&session, &opts);
452
453 assert!(!code.contains("IPC:"));
454 }
455
456 #[test]
457 fn state_change_comment_included() {
458 let session = make_session(vec![RecordedEvent {
459 index: 0,
460 timestamp: Utc::now(),
461 event: AppEvent::StateChange {
462 key: "user.theme".to_string(),
463 timestamp: Utc::now(),
464 caused_by: Some("toggle_theme".to_string()),
465 },
466 }]);
467 let code = generate_test_default(&session);
468
469 assert!(code.contains("// State changed: user.theme"));
470 }
471
472 #[test]
473 fn custom_test_name() {
474 let session = make_session(vec![]);
475 let opts = CodegenOptions {
476 test_name: "my_custom_test".to_string(),
477 ..CodegenOptions::default()
478 };
479 let code = generate_test(&session, &opts);
480
481 assert!(code.contains("async fn my_custom_test()"));
482 }
483
484 #[test]
485 fn special_chars_escaped() {
486 let session = make_session(vec![interaction_event(
487 0,
488 InteractionKind::Fill,
489 "input",
490 Some("line1\nline2\ttab\"quote\\back"),
491 0,
492 )]);
493 let code = generate_test_default(&session);
494
495 assert!(code.contains("\\n"));
496 assert!(code.contains("\\t"));
497 assert!(code.contains("\\\""));
498 assert!(code.contains("\\\\"));
499 }
500
501 #[test]
502 fn all_interaction_kinds_generate_code() {
503 let session = make_session(vec![
504 interaction_event(0, InteractionKind::Click, "[data-testid=\"a\"]", None, 0),
505 interaction_event(
506 1,
507 InteractionKind::DoubleClick,
508 "[data-testid=\"b\"]",
509 None,
510 10,
511 ),
512 interaction_event(
513 2,
514 InteractionKind::Fill,
515 "[data-testid=\"c\"]",
516 Some("val"),
517 20,
518 ),
519 interaction_event(3, InteractionKind::KeyPress, "#d", Some("Enter"), 30),
520 interaction_event(
521 4,
522 InteractionKind::Select,
523 "[data-testid=\"e\"]",
524 Some("opt1"),
525 40,
526 ),
527 interaction_event(5, InteractionKind::Navigate, "#f", Some("/page"), 50),
528 interaction_event(6, InteractionKind::Scroll, "#g", None, 60),
529 ]);
530 let code = generate_test(
531 &session,
532 &CodegenOptions {
533 include_timing_comments: false,
534 ..CodegenOptions::default()
535 },
536 );
537
538 assert!(code.contains("client.click_by_selector(\"[data-testid=\\\"a\\\"]\")"));
539 assert!(code.contains("client.double_click_by_selector(\"[data-testid=\\\"b\\\"]\")"));
540 assert!(code.contains("client.fill_by_selector(\"[data-testid=\\\"c\\\"]\", \"val\")"));
541 assert!(code.contains("client.press_key(\"Enter\")"));
542 assert!(code.contains(
543 "client.select_option_by_selector(\"[data-testid=\\\"e\\\"]\", &[\"opt1\"])"
544 ));
545 assert!(code.contains("client.navigate(\"/page\")"));
546 assert!(code.contains("client.scroll_to_by_id(\"g\")"));
547 }
548
549 #[test]
550 fn dom_mutation_and_window_event_skipped() {
551 let ts = Utc::now();
552 let session = make_session(vec![
553 RecordedEvent {
554 index: 0,
555 timestamp: ts,
556 event: AppEvent::DomMutation {
557 webview_label: "main".to_string(),
558 timestamp: ts,
559 mutation_count: 5,
560 },
561 },
562 RecordedEvent {
563 index: 1,
564 timestamp: ts,
565 event: AppEvent::WindowEvent {
566 label: "main".to_string(),
567 event: "focus".to_string(),
568 timestamp: ts,
569 },
570 },
571 ]);
572 let code = generate_test_default(&session);
573
574 assert!(!code.contains("client."));
576 assert!(!code.contains("// IPC:"));
577 assert!(!code.contains("// State"));
578 }
579
580 #[test]
581 fn default_options_are_correct() {
582 let opts = CodegenOptions::default();
583 assert_eq!(opts.test_name, "recorded_flow");
584 assert!(opts.include_ipc_assertions);
585 assert!(opts.include_state_checks);
586 assert!(opts.include_timing_comments);
587 }
588
589 fn make_session_at(base: chrono::DateTime<Utc>, events: Vec<RecordedEvent>) -> RecordedSession {
592 RecordedSession {
593 id: "timing-session".to_string(),
594 started_at: base,
595 events,
596 checkpoints: vec![],
597 }
598 }
599
600 fn interaction_event_at(
601 base: chrono::DateTime<Utc>,
602 index: usize,
603 action: InteractionKind,
604 selector: &str,
605 value: Option<&str>,
606 offset_ms: i64,
607 ) -> RecordedEvent {
608 let ts = base + Duration::milliseconds(offset_ms);
609 RecordedEvent {
610 index,
611 timestamp: ts,
612 event: AppEvent::DomInteraction {
613 action,
614 selector: selector.to_string(),
615 value: value.map(String::from),
616 timestamp: ts,
617 webview_label: "main".to_string(),
618 },
619 }
620 }
621
622 #[test]
623 fn timing_comment_emitted_for_large_gap() {
624 let base = Utc::now();
625 let session = make_session_at(
626 base,
627 vec![
628 interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
629 interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 1000),
630 ],
631 );
632 let opts = CodegenOptions {
633 include_timing_comments: true,
634 ..CodegenOptions::default()
635 };
636 let code = generate_test(&session, &opts);
637
638 assert!(
639 code.contains("// +1000ms"),
640 "expected timing comment for 1000ms gap, got:\n{code}"
641 );
642 }
643
644 #[test]
645 fn timing_comment_omitted_for_small_gap() {
646 let base = Utc::now();
647 let session = make_session_at(
648 base,
649 vec![
650 interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
651 interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 200),
652 ],
653 );
654 let opts = CodegenOptions {
655 include_timing_comments: true,
656 ..CodegenOptions::default()
657 };
658 let code = generate_test(&session, &opts);
659
660 assert!(
661 !code.contains("// +"),
662 "expected no timing comment for 200ms gap, got:\n{code}"
663 );
664 }
665
666 #[test]
669 fn id_selector_emits_click_by_id() {
670 let session = make_session(vec![interaction_event(
671 0,
672 InteractionKind::Click,
673 "#my-id",
674 None,
675 0,
676 )]);
677 let code = generate_test_default(&session);
678
679 assert!(
680 code.contains("client.click_by_id(\"my-id\").await.unwrap();"),
681 "expected click_by_id, got:\n{code}"
682 );
683 }
684
685 #[test]
686 fn has_text_selector_emits_click_by_text() {
687 let session = make_session(vec![interaction_event(
688 0,
689 InteractionKind::Click,
690 "button:has-text(\"Submit\")",
691 None,
692 0,
693 )]);
694 let code = generate_test_default(&session);
695
696 assert!(
697 code.contains("client.click_by_text(\"Submit\").await.unwrap();"),
698 "expected click_by_text, got:\n{code}"
699 );
700 }
701
702 #[test]
703 fn role_has_text_selector_emits_click_by_text() {
704 let session = make_session(vec![interaction_event(
705 0,
706 InteractionKind::Click,
707 "[role=\"button\"]:has-text(\"Save\")",
708 None,
709 0,
710 )]);
711 let code = generate_test_default(&session);
712
713 assert!(
714 code.contains("client.click_by_text(\"Save\").await.unwrap();"),
715 "expected click_by_text for role selector, got:\n{code}"
716 );
717 }
718
719 #[test]
720 fn data_testid_selector_emits_click_by_selector() {
721 let session = make_session(vec![interaction_event(
722 0,
723 InteractionKind::Click,
724 "[data-testid=\"foo\"]",
725 None,
726 0,
727 )]);
728 let code = generate_test_default(&session);
729
730 assert!(
731 code.contains(
732 "client.click_by_selector(\"[data-testid=\\\"foo\\\"]\").await.unwrap();"
733 ),
734 "expected click_by_selector for data-testid selector, got:\n{code}"
735 );
736 }
737
738 #[test]
739 fn fill_with_id_selector_emits_fill_by_id() {
740 let session = make_session(vec![interaction_event(
741 0,
742 InteractionKind::Fill,
743 "#email",
744 Some("user@example.com"),
745 0,
746 )]);
747 let code = generate_test_default(&session);
748
749 assert!(
750 code.contains("client.fill_by_id(\"email\", \"user@example.com\").await.unwrap();"),
751 "expected fill_by_id, got:\n{code}"
752 );
753 }
754
755 #[test]
756 fn double_click_with_has_text_emits_by_text() {
757 let session = make_session(vec![interaction_event(
758 0,
759 InteractionKind::DoubleClick,
760 "span:has-text(\"Edit\")",
761 None,
762 0,
763 )]);
764 let code = generate_test_default(&session);
765
766 assert!(
767 code.contains("client.double_click_by_text(\"Edit\").await.unwrap();"),
768 "expected double_click_by_text, got:\n{code}"
769 );
770 }
771
772 #[test]
773 fn select_with_id_emits_select_option_by_id() {
774 let session = make_session(vec![interaction_event(
775 0,
776 InteractionKind::Select,
777 "#country",
778 Some("AU"),
779 0,
780 )]);
781 let code = generate_test_default(&session);
782
783 assert!(
784 code.contains("client.select_option_by_id(\"country\", &[\"AU\"]).await.unwrap();"),
785 "expected select_option_by_id, got:\n{code}"
786 );
787 }
788
789 #[test]
793 fn round_trip_realistic_session() {
794 let base = Utc::now();
795
796 let events = vec![
797 interaction_event_at(base, 0, InteractionKind::Click, "#submit-btn", None, 0),
799 interaction_event_at(
801 base,
802 1,
803 InteractionKind::Fill,
804 "input[name=email]",
805 Some("test@example.com"),
806 100,
807 ),
808 interaction_event_at(
810 base,
811 2,
812 InteractionKind::KeyPress,
813 "body",
814 Some("Enter"),
815 200,
816 ),
817 RecordedEvent {
819 index: 3,
820 timestamp: base + Duration::milliseconds(300),
821 event: AppEvent::Ipc(IpcCall {
822 id: "ipc-1".to_string(),
823 command: "save_draft".to_string(),
824 timestamp: base + Duration::milliseconds(300),
825 duration_ms: Some(15),
826 result: IpcResult::Ok(serde_json::json!({"saved": true})),
827 arg_size_bytes: 42,
828 webview_label: "main".to_string(),
829 }),
830 },
831 RecordedEvent {
833 index: 4,
834 timestamp: base + Duration::milliseconds(350),
835 event: AppEvent::StateChange {
836 key: "draft.status".to_string(),
837 timestamp: base + Duration::milliseconds(350),
838 caused_by: Some("save_draft".to_string()),
839 },
840 },
841 ];
842
843 let session = make_session_at(base, events);
844 let code = generate_test(&session, &CodegenOptions::default());
845
846 assert!(
848 code.contains("#[tokio::test]"),
849 "missing #[tokio::test] attribute:\n{code}"
850 );
851 assert!(
852 code.contains("async fn recorded_flow()"),
853 "missing async fn declaration:\n{code}"
854 );
855 assert!(
856 code.contains("VictauriClient::discover()"),
857 "missing VictauriClient::discover() call:\n{code}"
858 );
859 assert!(
860 code.ends_with("}\n"),
861 "missing closing brace at end of generated code:\n{code}"
862 );
863
864 assert!(
868 code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
869 "expected click_by_id for #submit-btn:\n{code}"
870 );
871 assert!(
872 !code.contains("client.click(\"#submit-btn\")"),
873 "#submit-btn should NOT appear as raw client.click:\n{code}"
874 );
875
876 assert!(
878 code.contains(
879 "client.fill_by_selector(\"input[name=email]\", \"test@example.com\").await.unwrap();"
880 ),
881 "expected fill_by_selector for input[name=email]:\n{code}"
882 );
883 assert!(
884 !code.contains("client.fill_by_id(\"input[name=email]\""),
885 "input[name=email] should NOT resolve to fill_by_id:\n{code}"
886 );
887
888 assert!(
890 code.contains("client.press_key(\"Enter\").await.unwrap();"),
891 "expected press_key(\"Enter\"):\n{code}"
892 );
893
894 assert!(
896 code.contains("// IPC: save_draft completed successfully"),
897 "expected IPC comment for save_draft:\n{code}"
898 );
899
900 assert!(
902 code.contains("// State changed: draft.status"),
903 "expected state change comment for draft.status:\n{code}"
904 );
905
906 let open_braces = code.matches('{').count();
908 let close_braces = code.matches('}').count();
909 assert_eq!(
910 open_braces, close_braces,
911 "unbalanced braces: {open_braces} open vs {close_braces} close in:\n{code}"
912 );
913 }
914}