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