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