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