1use chrono::Utc;
8
9use crate::event::{AppEvent, InteractionKind, IpcResult};
10use crate::recording::RecordedSession;
11
12#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
14pub enum CodegenStyle {
15 #[default]
17 Direct,
18 Locator,
20}
21
22#[derive(Debug, Clone, Eq, PartialEq)]
24pub struct CodegenOptions {
25 pub test_name: String,
27 pub include_ipc_assertions: bool,
29 pub include_state_checks: bool,
31 pub include_timing_comments: bool,
33 pub emit_ipc_assert_calls: bool,
35 pub style: CodegenStyle,
37}
38
39impl Default for CodegenOptions {
40 fn default() -> Self {
41 Self {
42 test_name: "recorded_flow".to_string(),
43 include_ipc_assertions: true,
44 include_state_checks: true,
45 include_timing_comments: true,
46 emit_ipc_assert_calls: false,
47 style: CodegenStyle::Direct,
48 }
49 }
50}
51
52#[must_use]
57pub fn generate_test_default(session: &RecordedSession) -> String {
58 generate_test(session, &CodegenOptions::default())
59}
60
61#[must_use]
67pub fn generate_test(session: &RecordedSession, options: &CodegenOptions) -> String {
68 let mut out = String::with_capacity(2048);
69
70 let date = Utc::now().format("%Y-%m-%d");
72 out.push_str(&format!("// Generated by victauri record -- {date}\n"));
73 out.push_str(&format!("// Session: {}\n", session.id));
74
75 match options.style {
77 CodegenStyle::Direct => {
78 out.push_str("\nuse victauri_test::VictauriClient;\n");
79 if options.emit_ipc_assert_calls {
80 out.push_str("use victauri_test::assert_ipc_called;\n");
81 }
82 }
83 CodegenStyle::Locator => {
84 out.push_str("\nuse victauri_test::prelude::*;\n");
85 }
86 }
87 out.push('\n');
88
89 out.push_str("#[tokio::test]\n");
91 out.push_str(&format!("async fn {}() {{\n", options.test_name));
92 out.push_str(
93 " let mut client = VictauriClient::discover().await.expect(\"connect to Tauri app\");\n",
94 );
95
96 let session_start = session.started_at;
97 let mut ipc_commands_seen: Vec<String> = Vec::new();
98
99 for (i, recorded) in session.events.iter().enumerate() {
100 if options.include_timing_comments {
102 let elapsed_ms = recorded
103 .timestamp
104 .signed_duration_since(session_start)
105 .num_milliseconds();
106
107 let show_timing = if i == 0 {
108 elapsed_ms > 500
109 } else {
110 let prev_ts = session.events[i - 1].timestamp;
111 let gap_ms = recorded
112 .timestamp
113 .signed_duration_since(prev_ts)
114 .num_milliseconds();
115 gap_ms > 500
116 };
117
118 if show_timing {
119 out.push_str(&format!("\n // +{elapsed_ms}ms\n"));
120 }
121 }
122
123 match &recorded.event {
124 AppEvent::DomInteraction {
125 action,
126 selector,
127 value,
128 ..
129 } => match options.style {
130 CodegenStyle::Direct => {
131 emit_interaction(&mut out, action, selector, value.as_deref());
132 }
133 CodegenStyle::Locator => {
134 emit_locator_interaction(&mut out, action, selector, value.as_deref());
135 }
136 },
137
138 AppEvent::Ipc(call) if options.include_ipc_assertions => {
139 if call.command.starts_with("plugin:victauri|") {
141 continue;
142 }
143 if matches!(call.result, IpcResult::Ok(_)) {
144 let cmd = &call.command;
145 if options.emit_ipc_assert_calls {
146 ipc_commands_seen.push(cmd.clone());
147 }
148 out.push_str(&format!(
152 " // IPC: {} completed successfully\n",
153 sanitize_comment(cmd)
154 ));
155 }
156 }
157
158 AppEvent::StateChange { key, caused_by, .. }
159 if options.include_state_checks && caused_by.is_some() =>
160 {
161 out.push_str(&format!(
162 " // State changed: {}\n",
163 sanitize_comment(key)
164 ));
165 }
166
167 _ => {}
169 }
170 }
171
172 if options.emit_ipc_assert_calls && !ipc_commands_seen.is_empty() {
174 out.push_str("\n // Verify IPC calls\n");
175 out.push_str(" let log = client.get_ipc_log(None).await.unwrap();\n");
176 for cmd in &ipc_commands_seen {
177 let escaped = escape_rust_str(cmd);
178 out.push_str(&format!(" assert_ipc_called(&log, \"{escaped}\");\n"));
179 }
180 }
181
182 out.push_str("}\n");
183 out
184}
185
186fn sanitize_comment(s: &str) -> String {
189 s.replace(['\n', '\r'], " ")
190}
191
192fn escape_rust_str(s: &str) -> String {
194 let mut escaped = String::with_capacity(s.len());
195 for ch in s.chars() {
196 match ch {
197 '\\' => escaped.push_str("\\\\"),
198 '"' => escaped.push_str("\\\""),
199 '\n' => escaped.push_str("\\n"),
200 '\r' => escaped.push_str("\\r"),
201 '\t' => escaped.push_str("\\t"),
202 other => escaped.push(other),
203 }
204 }
205 escaped
206}
207
208fn emit_locator_interaction(
210 out: &mut String,
211 action: &InteractionKind,
212 selector: &str,
213 value: Option<&str>,
214) {
215 let locator = selector_to_locator(selector);
216
217 match action {
218 InteractionKind::Click => {
219 out.push_str(&format!(
220 " {locator}.click(&mut client).await.unwrap();\n"
221 ));
222 }
223 InteractionKind::DoubleClick => {
224 out.push_str(&format!(
225 " {locator}.double_click(&mut client).await.unwrap();\n"
226 ));
227 }
228 InteractionKind::Fill => {
229 let val = value.map_or_else(String::new, escape_rust_str);
230 out.push_str(&format!(
231 " {locator}.fill(&mut client, \"{val}\").await.unwrap();\n"
232 ));
233 }
234 InteractionKind::KeyPress => {
235 let val = value.map_or_else(String::new, escape_rust_str);
236 out.push_str(&format!(
237 " {locator}.press_key(&mut client, \"{val}\").await.unwrap();\n"
238 ));
239 }
240 InteractionKind::Select => {
241 let val = value.map_or_else(String::new, escape_rust_str);
242 out.push_str(&format!(
243 " {locator}.select_option(&mut client, &[\"{val}\"]).await.unwrap();\n"
244 ));
245 }
246 InteractionKind::Navigate => {
247 let val = value.map_or_else(String::new, escape_rust_str);
248 out.push_str(&format!(" client.navigate(\"{val}\").await.unwrap();\n"));
249 }
250 InteractionKind::Scroll => {
251 out.push_str(&format!(
252 " {locator}.scroll_into_view(&mut client).await.unwrap();\n"
253 ));
254 }
255 }
256}
257
258fn selector_to_locator(selector: &str) -> String {
260 if let Some(start) = selector.find(":has-text(\"") {
262 let text_start = start + ":has-text(\"".len();
263 if let Some(end) = selector[text_start..].find("\")") {
264 let text = escape_rust_str(&selector[text_start..text_start + end]);
265 return format!("Locator::text(\"{text}\")");
266 }
267 }
268
269 if selector.starts_with('#') && !selector[1..].contains(' ') {
271 let escaped = escape_rust_str(selector);
272 return format!("Locator::css(\"{escaped}\")");
273 }
274
275 if let Some(start) = selector.find("[data-testid=\"") {
277 let id_start = start + "[data-testid=\"".len();
278 if let Some(end) = selector[id_start..].find("\"]") {
279 let id = escape_rust_str(&selector[id_start..id_start + end]);
280 return format!("Locator::test_id(\"{id}\")");
281 }
282 }
283
284 if let Some(start) = selector.find("[role=\"") {
286 let role_start = start + "[role=\"".len();
287 if let Some(end) = selector[role_start..].find("\"]") {
288 let role = escape_rust_str(&selector[role_start..role_start + end]);
289 return format!("Locator::role(\"{role}\")");
290 }
291 }
292
293 let escaped = escape_rust_str(selector);
295 format!("Locator::css(\"{escaped}\")")
296}
297
298enum ResolvedSelector {
304 ById(String),
306 ByText(String),
308 Raw(String),
310}
311
312fn resolve_selector(selector: &str) -> ResolvedSelector {
314 if let Some(start) = selector.find(":has-text(\"") {
316 let text_start = start + ":has-text(\"".len();
317 if let Some(end) = selector[text_start..].find("\")") {
318 let text = &selector[text_start..text_start + end];
319 return ResolvedSelector::ByText(text.to_string());
320 }
321 }
322
323 if selector.starts_with('#') && !selector[1..].contains(' ') {
325 let id = &selector[1..];
326 return ResolvedSelector::ById(id.to_string());
327 }
328
329 ResolvedSelector::Raw(selector.to_string())
330}
331
332fn emit_interaction(
338 out: &mut String,
339 action: &InteractionKind,
340 selector: &str,
341 value: Option<&str>,
342) {
343 let resolved = resolve_selector(selector);
344
345 match action {
346 InteractionKind::Click => {
347 emit_resolved_call(out, "click", &resolved, None);
348 }
349 InteractionKind::DoubleClick => {
350 emit_resolved_call(out, "double_click", &resolved, None);
351 }
352 InteractionKind::Fill => {
353 let val = value.map_or_else(String::new, escape_rust_str);
354 emit_resolved_call(out, "fill", &resolved, Some(&val));
355 }
356 InteractionKind::KeyPress => {
357 let val = value.map_or_else(String::new, escape_rust_str);
358 out.push_str(&format!(
359 " client.press_key(\"{val}\").await.unwrap();\n"
360 ));
361 }
362 InteractionKind::Select => {
363 let val = value.map_or_else(String::new, escape_rust_str);
364 emit_resolved_select(out, &resolved, &val);
365 }
366 InteractionKind::Navigate => {
367 let val = value.map_or_else(String::new, escape_rust_str);
368 out.push_str(&format!(" client.navigate(\"{val}\").await.unwrap();\n"));
369 }
370 InteractionKind::Scroll => {
371 emit_resolved_scroll(out, &resolved);
372 }
373 }
374}
375
376fn emit_resolved_call(
382 out: &mut String,
383 base_method: &str,
384 resolved: &ResolvedSelector,
385 extra_arg: Option<&str>,
386) {
387 let suffix = extra_arg.map_or_else(String::new, |v| format!(", \"{v}\""));
388 match resolved {
389 ResolvedSelector::ById(id) => {
390 let escaped = escape_rust_str(id);
391 out.push_str(&format!(
392 " client.{base_method}_by_id(\"{escaped}\"{suffix}).await.unwrap();\n"
393 ));
394 }
395 ResolvedSelector::ByText(text) => {
396 let escaped = escape_rust_str(text);
397 out.push_str(&format!(
398 " client.{base_method}_by_text(\"{escaped}\"{suffix}).await.unwrap();\n"
399 ));
400 }
401 ResolvedSelector::Raw(sel) => {
402 let escaped = escape_rust_str(sel);
403 out.push_str(&format!(
404 " client.{base_method}_by_selector(\"{escaped}\"{suffix}).await.unwrap();\n"
405 ));
406 }
407 }
408}
409
410fn emit_resolved_select(out: &mut String, resolved: &ResolvedSelector, val: &str) {
415 match resolved {
416 ResolvedSelector::ById(id) => {
417 let escaped = escape_rust_str(id);
418 out.push_str(&format!(
419 " client.select_option_by_id(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
420 ));
421 }
422 ResolvedSelector::ByText(text) => {
423 let escaped = escape_rust_str(text);
424 out.push_str(&format!(
425 " client.select_option_by_text(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
426 ));
427 }
428 ResolvedSelector::Raw(sel) => {
429 let escaped = escape_rust_str(sel);
430 out.push_str(&format!(
431 " client.select_option_by_selector(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
432 ));
433 }
434 }
435}
436
437fn emit_resolved_scroll(out: &mut String, resolved: &ResolvedSelector) {
443 match resolved {
444 ResolvedSelector::ById(id) => {
445 let escaped = escape_rust_str(id);
446 out.push_str(&format!(
447 " client.scroll_to_by_id(\"{escaped}\").await.unwrap();\n"
448 ));
449 }
450 ResolvedSelector::ByText(_) | ResolvedSelector::Raw(_) => {
451 let sel = match resolved {
452 ResolvedSelector::ByText(t) => escape_rust_str(t),
453 ResolvedSelector::Raw(s) => escape_rust_str(s),
454 _ => unreachable!(),
455 };
456 out.push_str(&format!(
457 " client.scroll_to_by_selector(\"{sel}\").await.unwrap();\n"
458 ));
459 }
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use chrono::{Duration, Utc};
466
467 use super::*;
468 use crate::event::{AppEvent, InteractionKind, IpcCall, IpcResult};
469 use crate::recording::{RecordedEvent, RecordedSession};
470
471 fn make_session(events: Vec<RecordedEvent>) -> RecordedSession {
472 RecordedSession {
473 id: "test-session-001".to_string(),
474 started_at: Utc::now(),
475 events,
476 checkpoints: vec![],
477 }
478 }
479
480 fn interaction_event(
481 index: usize,
482 action: InteractionKind,
483 selector: &str,
484 value: Option<&str>,
485 offset_ms: i64,
486 ) -> RecordedEvent {
487 RecordedEvent {
488 index,
489 timestamp: Utc::now() + Duration::milliseconds(offset_ms),
490 event: AppEvent::DomInteraction {
491 action,
492 selector: selector.to_string(),
493 value: value.map(String::from),
494 timestamp: Utc::now() + Duration::milliseconds(offset_ms),
495 webview_label: "main".to_string(),
496 },
497 }
498 }
499
500 #[test]
501 fn empty_session_produces_valid_skeleton() {
502 let session = make_session(vec![]);
503 let code = generate_test_default(&session);
504
505 assert!(code.contains("use victauri_test::VictauriClient;"));
506 assert!(code.contains("#[tokio::test]"));
507 assert!(code.contains("async fn recorded_flow()"));
508 assert!(code.contains("VictauriClient::discover()"));
509 assert!(code.contains("Session: test-session-001"));
510 }
511
512 #[test]
513 fn click_by_id_generated_for_hash_selector() {
514 let session = make_session(vec![interaction_event(
515 0,
516 InteractionKind::Click,
517 "#submit-btn",
518 None,
519 0,
520 )]);
521 let code = generate_test_default(&session);
522
523 assert!(
524 code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
525 "expected click_by_id for # selector, got:\n{code}"
526 );
527 }
528
529 #[test]
530 fn fill_generates_correct_call() {
531 let session = make_session(vec![interaction_event(
532 0,
533 InteractionKind::Fill,
534 "input[name=\"email\"]",
535 Some("user@example.com"),
536 0,
537 )]);
538 let code = generate_test_default(&session);
539
540 assert!(code.contains(
541 "client.fill_by_selector(\"input[name=\\\"email\\\"]\", \"user@example.com\").await.unwrap();"
542 ));
543 }
544
545 #[test]
546 fn ipc_comment_included_when_enabled() {
547 let session = make_session(vec![RecordedEvent {
548 index: 0,
549 timestamp: Utc::now(),
550 event: AppEvent::Ipc(IpcCall {
551 id: "c1".to_string(),
552 command: "save_settings".to_string(),
553 timestamp: Utc::now(),
554 duration_ms: Some(10),
555 result: IpcResult::Ok(serde_json::json!(true)),
556 arg_size_bytes: 0,
557 webview_label: "main".to_string(),
558 }),
559 }]);
560 let code = generate_test_default(&session);
561
562 assert!(code.contains("// IPC: save_settings completed successfully"));
563 }
564
565 #[test]
566 fn internal_victauri_ipc_skipped() {
567 let session = make_session(vec![RecordedEvent {
568 index: 0,
569 timestamp: Utc::now(),
570 event: AppEvent::Ipc(IpcCall {
571 id: "c2".to_string(),
572 command: "plugin:victauri|get_snapshot".to_string(),
573 timestamp: Utc::now(),
574 duration_ms: Some(2),
575 result: IpcResult::Ok(serde_json::json!({})),
576 arg_size_bytes: 0,
577 webview_label: "main".to_string(),
578 }),
579 }]);
580 let code = generate_test_default(&session);
581
582 assert!(!code.contains("plugin:victauri"));
583 }
584
585 #[test]
586 fn ipc_omitted_when_disabled() {
587 let session = make_session(vec![RecordedEvent {
588 index: 0,
589 timestamp: Utc::now(),
590 event: AppEvent::Ipc(IpcCall {
591 id: "c1".to_string(),
592 command: "save_settings".to_string(),
593 timestamp: Utc::now(),
594 duration_ms: Some(10),
595 result: IpcResult::Ok(serde_json::json!(true)),
596 arg_size_bytes: 0,
597 webview_label: "main".to_string(),
598 }),
599 }]);
600 let opts = CodegenOptions {
601 include_ipc_assertions: false,
602 ..CodegenOptions::default()
603 };
604 let code = generate_test(&session, &opts);
605
606 assert!(!code.contains("IPC:"));
607 }
608
609 #[test]
610 fn state_change_comment_included() {
611 let session = make_session(vec![RecordedEvent {
612 index: 0,
613 timestamp: Utc::now(),
614 event: AppEvent::StateChange {
615 key: "user.theme".to_string(),
616 timestamp: Utc::now(),
617 caused_by: Some("toggle_theme".to_string()),
618 },
619 }]);
620 let code = generate_test_default(&session);
621
622 assert!(code.contains("// State changed: user.theme"));
623 }
624
625 #[test]
626 fn custom_test_name() {
627 let session = make_session(vec![]);
628 let opts = CodegenOptions {
629 test_name: "my_custom_test".to_string(),
630 ..CodegenOptions::default()
631 };
632 let code = generate_test(&session, &opts);
633
634 assert!(code.contains("async fn my_custom_test()"));
635 }
636
637 #[test]
638 fn special_chars_escaped() {
639 let session = make_session(vec![interaction_event(
640 0,
641 InteractionKind::Fill,
642 "input",
643 Some("line1\nline2\ttab\"quote\\back"),
644 0,
645 )]);
646 let code = generate_test_default(&session);
647
648 assert!(code.contains("\\n"));
649 assert!(code.contains("\\t"));
650 assert!(code.contains("\\\""));
651 assert!(code.contains("\\\\"));
652 }
653
654 #[test]
655 fn all_interaction_kinds_generate_code() {
656 let session = make_session(vec![
657 interaction_event(0, InteractionKind::Click, "[data-testid=\"a\"]", None, 0),
658 interaction_event(
659 1,
660 InteractionKind::DoubleClick,
661 "[data-testid=\"b\"]",
662 None,
663 10,
664 ),
665 interaction_event(
666 2,
667 InteractionKind::Fill,
668 "[data-testid=\"c\"]",
669 Some("val"),
670 20,
671 ),
672 interaction_event(3, InteractionKind::KeyPress, "#d", Some("Enter"), 30),
673 interaction_event(
674 4,
675 InteractionKind::Select,
676 "[data-testid=\"e\"]",
677 Some("opt1"),
678 40,
679 ),
680 interaction_event(5, InteractionKind::Navigate, "#f", Some("/page"), 50),
681 interaction_event(6, InteractionKind::Scroll, "#g", None, 60),
682 ]);
683 let code = generate_test(
684 &session,
685 &CodegenOptions {
686 include_timing_comments: false,
687 ..CodegenOptions::default()
688 },
689 );
690
691 assert!(code.contains("client.click_by_selector(\"[data-testid=\\\"a\\\"]\")"));
692 assert!(code.contains("client.double_click_by_selector(\"[data-testid=\\\"b\\\"]\")"));
693 assert!(code.contains("client.fill_by_selector(\"[data-testid=\\\"c\\\"]\", \"val\")"));
694 assert!(code.contains("client.press_key(\"Enter\")"));
695 assert!(code.contains(
696 "client.select_option_by_selector(\"[data-testid=\\\"e\\\"]\", &[\"opt1\"])"
697 ));
698 assert!(code.contains("client.navigate(\"/page\")"));
699 assert!(code.contains("client.scroll_to_by_id(\"g\")"));
700 }
701
702 #[test]
703 fn dom_mutation_and_window_event_skipped() {
704 let ts = Utc::now();
705 let session = make_session(vec![
706 RecordedEvent {
707 index: 0,
708 timestamp: ts,
709 event: AppEvent::DomMutation {
710 webview_label: "main".to_string(),
711 timestamp: ts,
712 mutation_count: 5,
713 },
714 },
715 RecordedEvent {
716 index: 1,
717 timestamp: ts,
718 event: AppEvent::WindowEvent {
719 label: "main".to_string(),
720 event: "focus".to_string(),
721 timestamp: ts,
722 },
723 },
724 ]);
725 let code = generate_test_default(&session);
726
727 assert!(!code.contains("client."));
729 assert!(!code.contains("// IPC:"));
730 assert!(!code.contains("// State"));
731 }
732
733 #[test]
734 fn default_options_are_correct() {
735 let opts = CodegenOptions::default();
736 assert_eq!(opts.test_name, "recorded_flow");
737 assert!(opts.include_ipc_assertions);
738 assert!(opts.include_state_checks);
739 assert!(opts.include_timing_comments);
740 }
741
742 fn make_session_at(base: chrono::DateTime<Utc>, events: Vec<RecordedEvent>) -> RecordedSession {
745 RecordedSession {
746 id: "timing-session".to_string(),
747 started_at: base,
748 events,
749 checkpoints: vec![],
750 }
751 }
752
753 fn interaction_event_at(
754 base: chrono::DateTime<Utc>,
755 index: usize,
756 action: InteractionKind,
757 selector: &str,
758 value: Option<&str>,
759 offset_ms: i64,
760 ) -> RecordedEvent {
761 let ts = base + Duration::milliseconds(offset_ms);
762 RecordedEvent {
763 index,
764 timestamp: ts,
765 event: AppEvent::DomInteraction {
766 action,
767 selector: selector.to_string(),
768 value: value.map(String::from),
769 timestamp: ts,
770 webview_label: "main".to_string(),
771 },
772 }
773 }
774
775 #[test]
776 fn timing_comment_emitted_for_large_gap() {
777 let base = Utc::now();
778 let session = make_session_at(
779 base,
780 vec![
781 interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
782 interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 1000),
783 ],
784 );
785 let opts = CodegenOptions {
786 include_timing_comments: true,
787 ..CodegenOptions::default()
788 };
789 let code = generate_test(&session, &opts);
790
791 assert!(
792 code.contains("// +1000ms"),
793 "expected timing comment for 1000ms gap, got:\n{code}"
794 );
795 }
796
797 #[test]
798 fn timing_comment_omitted_for_small_gap() {
799 let base = Utc::now();
800 let session = make_session_at(
801 base,
802 vec![
803 interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
804 interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 200),
805 ],
806 );
807 let opts = CodegenOptions {
808 include_timing_comments: true,
809 ..CodegenOptions::default()
810 };
811 let code = generate_test(&session, &opts);
812
813 assert!(
814 !code.contains("// +"),
815 "expected no timing comment for 200ms gap, got:\n{code}"
816 );
817 }
818
819 #[test]
822 fn id_selector_emits_click_by_id() {
823 let session = make_session(vec![interaction_event(
824 0,
825 InteractionKind::Click,
826 "#my-id",
827 None,
828 0,
829 )]);
830 let code = generate_test_default(&session);
831
832 assert!(
833 code.contains("client.click_by_id(\"my-id\").await.unwrap();"),
834 "expected click_by_id, got:\n{code}"
835 );
836 }
837
838 #[test]
839 fn has_text_selector_emits_click_by_text() {
840 let session = make_session(vec![interaction_event(
841 0,
842 InteractionKind::Click,
843 "button:has-text(\"Submit\")",
844 None,
845 0,
846 )]);
847 let code = generate_test_default(&session);
848
849 assert!(
850 code.contains("client.click_by_text(\"Submit\").await.unwrap();"),
851 "expected click_by_text, got:\n{code}"
852 );
853 }
854
855 #[test]
856 fn role_has_text_selector_emits_click_by_text() {
857 let session = make_session(vec![interaction_event(
858 0,
859 InteractionKind::Click,
860 "[role=\"button\"]:has-text(\"Save\")",
861 None,
862 0,
863 )]);
864 let code = generate_test_default(&session);
865
866 assert!(
867 code.contains("client.click_by_text(\"Save\").await.unwrap();"),
868 "expected click_by_text for role selector, got:\n{code}"
869 );
870 }
871
872 #[test]
873 fn data_testid_selector_emits_click_by_selector() {
874 let session = make_session(vec![interaction_event(
875 0,
876 InteractionKind::Click,
877 "[data-testid=\"foo\"]",
878 None,
879 0,
880 )]);
881 let code = generate_test_default(&session);
882
883 assert!(
884 code.contains(
885 "client.click_by_selector(\"[data-testid=\\\"foo\\\"]\").await.unwrap();"
886 ),
887 "expected click_by_selector for data-testid selector, got:\n{code}"
888 );
889 }
890
891 #[test]
892 fn fill_with_id_selector_emits_fill_by_id() {
893 let session = make_session(vec![interaction_event(
894 0,
895 InteractionKind::Fill,
896 "#email",
897 Some("user@example.com"),
898 0,
899 )]);
900 let code = generate_test_default(&session);
901
902 assert!(
903 code.contains("client.fill_by_id(\"email\", \"user@example.com\").await.unwrap();"),
904 "expected fill_by_id, got:\n{code}"
905 );
906 }
907
908 #[test]
909 fn double_click_with_has_text_emits_by_text() {
910 let session = make_session(vec![interaction_event(
911 0,
912 InteractionKind::DoubleClick,
913 "span:has-text(\"Edit\")",
914 None,
915 0,
916 )]);
917 let code = generate_test_default(&session);
918
919 assert!(
920 code.contains("client.double_click_by_text(\"Edit\").await.unwrap();"),
921 "expected double_click_by_text, got:\n{code}"
922 );
923 }
924
925 #[test]
926 fn select_with_id_emits_select_option_by_id() {
927 let session = make_session(vec![interaction_event(
928 0,
929 InteractionKind::Select,
930 "#country",
931 Some("AU"),
932 0,
933 )]);
934 let code = generate_test_default(&session);
935
936 assert!(
937 code.contains("client.select_option_by_id(\"country\", &[\"AU\"]).await.unwrap();"),
938 "expected select_option_by_id, got:\n{code}"
939 );
940 }
941
942 #[test]
946 fn round_trip_realistic_session() {
947 let base = Utc::now();
948
949 let events = vec![
950 interaction_event_at(base, 0, InteractionKind::Click, "#submit-btn", None, 0),
952 interaction_event_at(
954 base,
955 1,
956 InteractionKind::Fill,
957 "input[name=email]",
958 Some("test@example.com"),
959 100,
960 ),
961 interaction_event_at(
963 base,
964 2,
965 InteractionKind::KeyPress,
966 "body",
967 Some("Enter"),
968 200,
969 ),
970 RecordedEvent {
972 index: 3,
973 timestamp: base + Duration::milliseconds(300),
974 event: AppEvent::Ipc(IpcCall {
975 id: "ipc-1".to_string(),
976 command: "save_draft".to_string(),
977 timestamp: base + Duration::milliseconds(300),
978 duration_ms: Some(15),
979 result: IpcResult::Ok(serde_json::json!({"saved": true})),
980 arg_size_bytes: 42,
981 webview_label: "main".to_string(),
982 }),
983 },
984 RecordedEvent {
986 index: 4,
987 timestamp: base + Duration::milliseconds(350),
988 event: AppEvent::StateChange {
989 key: "draft.status".to_string(),
990 timestamp: base + Duration::milliseconds(350),
991 caused_by: Some("save_draft".to_string()),
992 },
993 },
994 ];
995
996 let session = make_session_at(base, events);
997 let code = generate_test(&session, &CodegenOptions::default());
998
999 assert!(
1001 code.contains("#[tokio::test]"),
1002 "missing #[tokio::test] attribute:\n{code}"
1003 );
1004 assert!(
1005 code.contains("async fn recorded_flow()"),
1006 "missing async fn declaration:\n{code}"
1007 );
1008 assert!(
1009 code.contains("VictauriClient::discover()"),
1010 "missing VictauriClient::discover() call:\n{code}"
1011 );
1012 assert!(
1013 code.ends_with("}\n"),
1014 "missing closing brace at end of generated code:\n{code}"
1015 );
1016
1017 assert!(
1021 code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
1022 "expected click_by_id for #submit-btn:\n{code}"
1023 );
1024 assert!(
1025 !code.contains("client.click(\"#submit-btn\")"),
1026 "#submit-btn should NOT appear as raw client.click:\n{code}"
1027 );
1028
1029 assert!(
1031 code.contains(
1032 "client.fill_by_selector(\"input[name=email]\", \"test@example.com\").await.unwrap();"
1033 ),
1034 "expected fill_by_selector for input[name=email]:\n{code}"
1035 );
1036 assert!(
1037 !code.contains("client.fill_by_id(\"input[name=email]\""),
1038 "input[name=email] should NOT resolve to fill_by_id:\n{code}"
1039 );
1040
1041 assert!(
1043 code.contains("client.press_key(\"Enter\").await.unwrap();"),
1044 "expected press_key(\"Enter\"):\n{code}"
1045 );
1046
1047 assert!(
1049 code.contains("// IPC: save_draft completed successfully"),
1050 "expected IPC comment for save_draft:\n{code}"
1051 );
1052
1053 assert!(
1055 code.contains("// State changed: draft.status"),
1056 "expected state change comment for draft.status:\n{code}"
1057 );
1058
1059 let open_braces = code.matches('{').count();
1061 let close_braces = code.matches('}').count();
1062 assert_eq!(
1063 open_braces, close_braces,
1064 "unbalanced braces: {open_braces} open vs {close_braces} close in:\n{code}"
1065 );
1066 }
1067
1068 #[test]
1071 fn locator_style_click_by_id() {
1072 let session = make_session(vec![interaction_event(
1073 0,
1074 InteractionKind::Click,
1075 "#submit-btn",
1076 None,
1077 0,
1078 )]);
1079 let opts = CodegenOptions {
1080 style: CodegenStyle::Locator,
1081 include_timing_comments: false,
1082 ..CodegenOptions::default()
1083 };
1084 let code = generate_test(&session, &opts);
1085
1086 assert!(
1087 code.contains("Locator::css(\"#submit-btn\").click(&mut client).await.unwrap();"),
1088 "expected Locator::css for id selector, got:\n{code}"
1089 );
1090 assert!(code.contains("use victauri_test::prelude::*;"));
1091 }
1092
1093 #[test]
1094 fn locator_style_click_by_text() {
1095 let session = make_session(vec![interaction_event(
1096 0,
1097 InteractionKind::Click,
1098 "button:has-text(\"Save\")",
1099 None,
1100 0,
1101 )]);
1102 let opts = CodegenOptions {
1103 style: CodegenStyle::Locator,
1104 include_timing_comments: false,
1105 ..CodegenOptions::default()
1106 };
1107 let code = generate_test(&session, &opts);
1108
1109 assert!(
1110 code.contains("Locator::text(\"Save\").click(&mut client).await.unwrap();"),
1111 "expected Locator::text for has-text, got:\n{code}"
1112 );
1113 }
1114
1115 #[test]
1116 fn locator_style_fill_by_testid() {
1117 let session = make_session(vec![interaction_event(
1118 0,
1119 InteractionKind::Fill,
1120 "[data-testid=\"email-input\"]",
1121 Some("user@test.com"),
1122 0,
1123 )]);
1124 let opts = CodegenOptions {
1125 style: CodegenStyle::Locator,
1126 include_timing_comments: false,
1127 ..CodegenOptions::default()
1128 };
1129 let code = generate_test(&session, &opts);
1130
1131 assert!(
1132 code.contains(
1133 "Locator::test_id(\"email-input\").fill(&mut client, \"user@test.com\").await.unwrap();"
1134 ),
1135 "expected Locator::test_id for data-testid, got:\n{code}"
1136 );
1137 }
1138
1139 #[test]
1140 fn locator_style_role_selector() {
1141 let session = make_session(vec![interaction_event(
1142 0,
1143 InteractionKind::Click,
1144 "[role=\"button\"]",
1145 None,
1146 0,
1147 )]);
1148 let opts = CodegenOptions {
1149 style: CodegenStyle::Locator,
1150 include_timing_comments: false,
1151 ..CodegenOptions::default()
1152 };
1153 let code = generate_test(&session, &opts);
1154
1155 assert!(
1156 code.contains("Locator::role(\"button\").click(&mut client).await.unwrap();"),
1157 "expected Locator::role for role selector, got:\n{code}"
1158 );
1159 }
1160
1161 #[test]
1162 fn ipc_assert_calls_emitted() {
1163 let session = make_session(vec![RecordedEvent {
1164 index: 0,
1165 timestamp: Utc::now(),
1166 event: AppEvent::Ipc(IpcCall {
1167 id: "c1".to_string(),
1168 command: "save_settings".to_string(),
1169 timestamp: Utc::now(),
1170 duration_ms: Some(10),
1171 result: IpcResult::Ok(serde_json::json!(true)),
1172 arg_size_bytes: 0,
1173 webview_label: "main".to_string(),
1174 }),
1175 }]);
1176 let opts = CodegenOptions {
1177 emit_ipc_assert_calls: true,
1178 ..CodegenOptions::default()
1179 };
1180 let code = generate_test(&session, &opts);
1181
1182 assert!(
1183 code.contains("assert_ipc_called(&log, \"save_settings\");"),
1184 "expected assert_ipc_called, got:\n{code}"
1185 );
1186 assert!(code.contains("use victauri_test::assert_ipc_called;"));
1187 assert!(code.contains("let log = client.get_ipc_log(None).await.unwrap();"));
1188 }
1189
1190 #[test]
1191 fn selector_to_locator_mapping() {
1192 assert_eq!(selector_to_locator("#btn"), "Locator::css(\"#btn\")");
1193 assert_eq!(
1194 selector_to_locator("button:has-text(\"OK\")"),
1195 "Locator::text(\"OK\")"
1196 );
1197 assert_eq!(
1198 selector_to_locator("[data-testid=\"foo\"]"),
1199 "Locator::test_id(\"foo\")"
1200 );
1201 assert_eq!(
1202 selector_to_locator("[role=\"navigation\"]"),
1203 "Locator::role(\"navigation\")"
1204 );
1205 assert_eq!(
1206 selector_to_locator(".my-class"),
1207 "Locator::css(\".my-class\")"
1208 );
1209 }
1210}