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