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 }),
687 }]);
688 let code = generate_test_default(&session);
689
690 assert!(code.contains("// IPC: save_settings completed successfully"));
691 }
692
693 #[test]
694 fn internal_victauri_ipc_skipped() {
695 let session = make_session(vec![RecordedEvent {
696 index: 0,
697 timestamp: Utc::now(),
698 event: AppEvent::Ipc(IpcCall {
699 id: "c2".to_string(),
700 command: "plugin:victauri|get_snapshot".to_string(),
701 timestamp: Utc::now(),
702 duration_ms: Some(2),
703 result: IpcResult::Ok(serde_json::json!({})),
704 arg_size_bytes: 0,
705 webview_label: "main".to_string(),
706 mocked: false,
707 }),
708 }]);
709 let code = generate_test_default(&session);
710
711 assert!(!code.contains("plugin:victauri"));
712 }
713
714 #[test]
715 fn ipc_omitted_when_disabled() {
716 let session = make_session(vec![RecordedEvent {
717 index: 0,
718 timestamp: Utc::now(),
719 event: AppEvent::Ipc(IpcCall {
720 id: "c1".to_string(),
721 command: "save_settings".to_string(),
722 timestamp: Utc::now(),
723 duration_ms: Some(10),
724 result: IpcResult::Ok(serde_json::json!(true)),
725 arg_size_bytes: 0,
726 webview_label: "main".to_string(),
727 mocked: false,
728 }),
729 }]);
730 let opts = CodegenOptions {
731 include_ipc_assertions: false,
732 ..CodegenOptions::default()
733 };
734 let code = generate_test(&session, &opts);
735
736 assert!(!code.contains("IPC:"));
737 }
738
739 #[test]
740 fn state_change_comment_included() {
741 let session = make_session(vec![RecordedEvent {
742 index: 0,
743 timestamp: Utc::now(),
744 event: AppEvent::StateChange {
745 key: "user.theme".to_string(),
746 timestamp: Utc::now(),
747 caused_by: Some("toggle_theme".to_string()),
748 },
749 }]);
750 let code = generate_test_default(&session);
751
752 assert!(code.contains("// State changed: user.theme"));
753 }
754
755 #[test]
756 fn custom_test_name() {
757 let session = make_session(vec![]);
758 let opts = CodegenOptions {
759 test_name: "my_custom_test".to_string(),
760 ..CodegenOptions::default()
761 };
762 let code = generate_test(&session, &opts);
763
764 assert!(code.contains("async fn my_custom_test()"));
765 }
766
767 #[test]
768 fn hostile_test_name_and_session_id_cannot_inject_rust() {
769 let mut session = make_session(Vec::new());
770 session.id = "session\nuse std::process::Command;".to_string();
771 let opts = CodegenOptions {
772 test_name: "x() {}\n#[test]\nfn injected".to_string(),
773 ..CodegenOptions::default()
774 };
775
776 let code = generate_test(&session, &opts);
777
778 assert!(code.contains("// Session: session use std::process::Command;"));
779 assert!(code.contains("async fn x________test__fn_injected()"));
780 assert!(!code.contains("\nuse std::process::Command;"));
781 assert!(!code.contains("\n#[test]\nfn injected"));
782 }
783
784 #[test]
785 fn special_chars_escaped() {
786 let session = make_session(vec![interaction_event(
787 0,
788 InteractionKind::Fill,
789 "input",
790 Some("line1\nline2\ttab\"quote\\back"),
791 0,
792 )]);
793 let code = generate_test_default(&session);
794
795 assert!(code.contains("\\n"));
796 assert!(code.contains("\\t"));
797 assert!(code.contains("\\\""));
798 assert!(code.contains("\\\\"));
799 }
800
801 #[test]
802 fn all_interaction_kinds_generate_code() {
803 let session = make_session(vec![
804 interaction_event(0, InteractionKind::Click, "[data-testid=\"a\"]", None, 0),
805 interaction_event(
806 1,
807 InteractionKind::DoubleClick,
808 "[data-testid=\"b\"]",
809 None,
810 10,
811 ),
812 interaction_event(
813 2,
814 InteractionKind::Fill,
815 "[data-testid=\"c\"]",
816 Some("val"),
817 20,
818 ),
819 interaction_event(3, InteractionKind::KeyPress, "#d", Some("Enter"), 30),
820 interaction_event(
821 4,
822 InteractionKind::Select,
823 "[data-testid=\"e\"]",
824 Some("opt1"),
825 40,
826 ),
827 interaction_event(5, InteractionKind::Navigate, "#f", Some("/page"), 50),
828 interaction_event(6, InteractionKind::Scroll, "#g", None, 60),
829 ]);
830 let code = generate_test(
831 &session,
832 &CodegenOptions {
833 include_timing_comments: false,
834 ..CodegenOptions::default()
835 },
836 );
837
838 assert!(code.contains("client.click_by_selector(\"[data-testid=\\\"a\\\"]\")"));
839 assert!(code.contains("client.double_click_by_selector(\"[data-testid=\\\"b\\\"]\")"));
840 assert!(code.contains("client.fill_by_selector(\"[data-testid=\\\"c\\\"]\", \"val\")"));
841 assert!(code.contains("client.press_key(\"Enter\")"));
842 assert!(code.contains(
843 "client.select_option_by_selector(\"[data-testid=\\\"e\\\"]\", &[\"opt1\"])"
844 ));
845 assert!(code.contains("client.navigate(\"/page\")"));
846 assert!(code.contains("client.scroll_to_by_id(\"g\")"));
847 }
848
849 #[test]
850 fn dom_mutation_and_window_event_skipped() {
851 let ts = Utc::now();
852 let session = make_session(vec![
853 RecordedEvent {
854 index: 0,
855 timestamp: ts,
856 event: AppEvent::DomMutation {
857 webview_label: "main".to_string(),
858 timestamp: ts,
859 mutation_count: 5,
860 },
861 },
862 RecordedEvent {
863 index: 1,
864 timestamp: ts,
865 event: AppEvent::WindowEvent {
866 label: "main".to_string(),
867 event: "focus".to_string(),
868 timestamp: ts,
869 },
870 },
871 ]);
872 let code = generate_test_default(&session);
873
874 assert!(!code.contains("client."));
876 assert!(!code.contains("// IPC:"));
877 assert!(!code.contains("// State"));
878 }
879
880 #[test]
881 fn default_options_are_correct() {
882 let opts = CodegenOptions::default();
883 assert_eq!(opts.test_name, "recorded_flow");
884 assert!(opts.include_ipc_assertions);
885 assert!(opts.include_state_checks);
886 assert!(opts.include_timing_comments);
887 }
888
889 fn make_session_at(base: chrono::DateTime<Utc>, events: Vec<RecordedEvent>) -> RecordedSession {
892 RecordedSession {
893 id: "timing-session".to_string(),
894 started_at: base,
895 events,
896 checkpoints: vec![],
897 }
898 }
899
900 fn interaction_event_at(
901 base: chrono::DateTime<Utc>,
902 index: usize,
903 action: InteractionKind,
904 selector: &str,
905 value: Option<&str>,
906 offset_ms: i64,
907 ) -> RecordedEvent {
908 let ts = base + Duration::milliseconds(offset_ms);
909 RecordedEvent {
910 index,
911 timestamp: ts,
912 event: AppEvent::DomInteraction {
913 action,
914 selector: selector.to_string(),
915 value: value.map(String::from),
916 timestamp: ts,
917 webview_label: "main".to_string(),
918 },
919 }
920 }
921
922 #[test]
923 fn timing_comment_emitted_for_large_gap() {
924 let base = Utc::now();
925 let session = make_session_at(
926 base,
927 vec![
928 interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
929 interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 1000),
930 ],
931 );
932 let opts = CodegenOptions {
933 include_timing_comments: true,
934 ..CodegenOptions::default()
935 };
936 let code = generate_test(&session, &opts);
937
938 assert!(
939 code.contains("// +1000ms"),
940 "expected timing comment for 1000ms gap, got:\n{code}"
941 );
942 }
943
944 #[test]
945 fn timing_comment_omitted_for_small_gap() {
946 let base = Utc::now();
947 let session = make_session_at(
948 base,
949 vec![
950 interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
951 interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 200),
952 ],
953 );
954 let opts = CodegenOptions {
955 include_timing_comments: true,
956 ..CodegenOptions::default()
957 };
958 let code = generate_test(&session, &opts);
959
960 assert!(
961 !code.contains("// +"),
962 "expected no timing comment for 200ms gap, got:\n{code}"
963 );
964 }
965
966 #[test]
969 fn id_selector_emits_click_by_id() {
970 let session = make_session(vec![interaction_event(
971 0,
972 InteractionKind::Click,
973 "#my-id",
974 None,
975 0,
976 )]);
977 let code = generate_test_default(&session);
978
979 assert!(
980 code.contains("client.click_by_id(\"my-id\").await.unwrap();"),
981 "expected click_by_id, got:\n{code}"
982 );
983 }
984
985 #[test]
986 fn has_text_selector_emits_click_by_text() {
987 let session = make_session(vec![interaction_event(
988 0,
989 InteractionKind::Click,
990 "button:has-text(\"Submit\")",
991 None,
992 0,
993 )]);
994 let code = generate_test_default(&session);
995
996 assert!(
997 code.contains("client.click_by_text(\"Submit\").await.unwrap();"),
998 "expected click_by_text, got:\n{code}"
999 );
1000 }
1001
1002 #[test]
1003 fn role_has_text_selector_emits_click_by_text() {
1004 let session = make_session(vec![interaction_event(
1005 0,
1006 InteractionKind::Click,
1007 "[role=\"button\"]:has-text(\"Save\")",
1008 None,
1009 0,
1010 )]);
1011 let code = generate_test_default(&session);
1012
1013 assert!(
1014 code.contains("client.click_by_text(\"Save\").await.unwrap();"),
1015 "expected click_by_text for role selector, got:\n{code}"
1016 );
1017 }
1018
1019 #[test]
1020 fn data_testid_selector_emits_click_by_selector() {
1021 let session = make_session(vec![interaction_event(
1022 0,
1023 InteractionKind::Click,
1024 "[data-testid=\"foo\"]",
1025 None,
1026 0,
1027 )]);
1028 let code = generate_test_default(&session);
1029
1030 assert!(
1031 code.contains(
1032 "client.click_by_selector(\"[data-testid=\\\"foo\\\"]\").await.unwrap();"
1033 ),
1034 "expected click_by_selector for data-testid selector, got:\n{code}"
1035 );
1036 }
1037
1038 #[test]
1039 fn fill_with_id_selector_emits_fill_by_id() {
1040 let session = make_session(vec![interaction_event(
1041 0,
1042 InteractionKind::Fill,
1043 "#email",
1044 Some("user@example.com"),
1045 0,
1046 )]);
1047 let code = generate_test_default(&session);
1048
1049 assert!(
1050 code.contains("client.fill_by_id(\"email\", \"user@example.com\").await.unwrap();"),
1051 "expected fill_by_id, got:\n{code}"
1052 );
1053 }
1054
1055 #[test]
1056 fn double_click_with_has_text_emits_by_text() {
1057 let session = make_session(vec![interaction_event(
1058 0,
1059 InteractionKind::DoubleClick,
1060 "span:has-text(\"Edit\")",
1061 None,
1062 0,
1063 )]);
1064 let code = generate_test_default(&session);
1065
1066 assert!(
1067 code.contains("client.double_click_by_text(\"Edit\").await.unwrap();"),
1068 "expected double_click_by_text, got:\n{code}"
1069 );
1070 }
1071
1072 #[test]
1073 fn select_with_id_emits_select_option_by_id() {
1074 let session = make_session(vec![interaction_event(
1075 0,
1076 InteractionKind::Select,
1077 "#country",
1078 Some("AU"),
1079 0,
1080 )]);
1081 let code = generate_test_default(&session);
1082
1083 assert!(
1084 code.contains("client.select_option_by_id(\"country\", &[\"AU\"]).await.unwrap();"),
1085 "expected select_option_by_id, got:\n{code}"
1086 );
1087 }
1088
1089 #[test]
1093 fn round_trip_realistic_session() {
1094 let base = Utc::now();
1095
1096 let events = vec![
1097 interaction_event_at(base, 0, InteractionKind::Click, "#submit-btn", None, 0),
1099 interaction_event_at(
1101 base,
1102 1,
1103 InteractionKind::Fill,
1104 "input[name=email]",
1105 Some("test@example.com"),
1106 100,
1107 ),
1108 interaction_event_at(
1110 base,
1111 2,
1112 InteractionKind::KeyPress,
1113 "body",
1114 Some("Enter"),
1115 200,
1116 ),
1117 RecordedEvent {
1119 index: 3,
1120 timestamp: base + Duration::milliseconds(300),
1121 event: AppEvent::Ipc(IpcCall {
1122 id: "ipc-1".to_string(),
1123 command: "save_draft".to_string(),
1124 timestamp: base + Duration::milliseconds(300),
1125 duration_ms: Some(15),
1126 result: IpcResult::Ok(serde_json::json!({"saved": true})),
1127 arg_size_bytes: 42,
1128 webview_label: "main".to_string(),
1129 mocked: false,
1130 }),
1131 },
1132 RecordedEvent {
1134 index: 4,
1135 timestamp: base + Duration::milliseconds(350),
1136 event: AppEvent::StateChange {
1137 key: "draft.status".to_string(),
1138 timestamp: base + Duration::milliseconds(350),
1139 caused_by: Some("save_draft".to_string()),
1140 },
1141 },
1142 ];
1143
1144 let session = make_session_at(base, events);
1145 let code = generate_test(&session, &CodegenOptions::default());
1146
1147 assert!(
1149 code.contains("#[tokio::test]"),
1150 "missing #[tokio::test] attribute:\n{code}"
1151 );
1152 assert!(
1153 code.contains("async fn recorded_flow()"),
1154 "missing async fn declaration:\n{code}"
1155 );
1156 assert!(
1157 code.contains("VictauriClient::discover()"),
1158 "missing VictauriClient::discover() call:\n{code}"
1159 );
1160 assert!(
1161 code.ends_with("}\n"),
1162 "missing closing brace at end of generated code:\n{code}"
1163 );
1164
1165 assert!(
1169 code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
1170 "expected click_by_id for #submit-btn:\n{code}"
1171 );
1172 assert!(
1173 !code.contains("client.click(\"#submit-btn\")"),
1174 "#submit-btn should NOT appear as raw client.click:\n{code}"
1175 );
1176
1177 assert!(
1179 code.contains(
1180 "client.fill_by_selector(\"input[name=email]\", \"test@example.com\").await.unwrap();"
1181 ),
1182 "expected fill_by_selector for input[name=email]:\n{code}"
1183 );
1184 assert!(
1185 !code.contains("client.fill_by_id(\"input[name=email]\""),
1186 "input[name=email] should NOT resolve to fill_by_id:\n{code}"
1187 );
1188
1189 assert!(
1191 code.contains("client.press_key(\"Enter\").await.unwrap();"),
1192 "expected press_key(\"Enter\"):\n{code}"
1193 );
1194
1195 assert!(
1197 code.contains("// IPC: save_draft completed successfully"),
1198 "expected IPC comment for save_draft:\n{code}"
1199 );
1200
1201 assert!(
1203 code.contains("// State changed: draft.status"),
1204 "expected state change comment for draft.status:\n{code}"
1205 );
1206
1207 let open_braces = code.matches('{').count();
1209 let close_braces = code.matches('}').count();
1210 assert_eq!(
1211 open_braces, close_braces,
1212 "unbalanced braces: {open_braces} open vs {close_braces} close in:\n{code}"
1213 );
1214 }
1215
1216 #[test]
1219 fn locator_style_click_by_id() {
1220 let session = make_session(vec![interaction_event(
1221 0,
1222 InteractionKind::Click,
1223 "#submit-btn",
1224 None,
1225 0,
1226 )]);
1227 let opts = CodegenOptions {
1228 style: CodegenStyle::Locator,
1229 include_timing_comments: false,
1230 ..CodegenOptions::default()
1231 };
1232 let code = generate_test(&session, &opts);
1233
1234 assert!(
1235 code.contains("Locator::css(\"#submit-btn\").click(&mut client).await.unwrap();"),
1236 "expected Locator::css for id selector, got:\n{code}"
1237 );
1238 assert!(code.contains("use victauri_test::prelude::*;"));
1239 }
1240
1241 #[test]
1242 fn locator_style_click_by_text() {
1243 let session = make_session(vec![interaction_event(
1244 0,
1245 InteractionKind::Click,
1246 "button:has-text(\"Save\")",
1247 None,
1248 0,
1249 )]);
1250 let opts = CodegenOptions {
1251 style: CodegenStyle::Locator,
1252 include_timing_comments: false,
1253 ..CodegenOptions::default()
1254 };
1255 let code = generate_test(&session, &opts);
1256
1257 assert!(
1258 code.contains("Locator::text(\"Save\").click(&mut client).await.unwrap();"),
1259 "expected Locator::text for has-text, got:\n{code}"
1260 );
1261 }
1262
1263 #[test]
1264 fn locator_style_fill_by_testid() {
1265 let session = make_session(vec![interaction_event(
1266 0,
1267 InteractionKind::Fill,
1268 "[data-testid=\"email-input\"]",
1269 Some("user@test.com"),
1270 0,
1271 )]);
1272 let opts = CodegenOptions {
1273 style: CodegenStyle::Locator,
1274 include_timing_comments: false,
1275 ..CodegenOptions::default()
1276 };
1277 let code = generate_test(&session, &opts);
1278
1279 assert!(
1280 code.contains(
1281 "Locator::test_id(\"email-input\").fill(&mut client, \"user@test.com\").await.unwrap();"
1282 ),
1283 "expected Locator::test_id for data-testid, got:\n{code}"
1284 );
1285 }
1286
1287 #[test]
1288 fn locator_style_role_selector() {
1289 let session = make_session(vec![interaction_event(
1290 0,
1291 InteractionKind::Click,
1292 "[role=\"button\"]",
1293 None,
1294 0,
1295 )]);
1296 let opts = CodegenOptions {
1297 style: CodegenStyle::Locator,
1298 include_timing_comments: false,
1299 ..CodegenOptions::default()
1300 };
1301 let code = generate_test(&session, &opts);
1302
1303 assert!(
1304 code.contains("Locator::role(\"button\").click(&mut client).await.unwrap();"),
1305 "expected Locator::role for role selector, got:\n{code}"
1306 );
1307 }
1308
1309 #[test]
1310 fn ipc_assert_calls_emitted() {
1311 let session = make_session(vec![RecordedEvent {
1312 index: 0,
1313 timestamp: Utc::now(),
1314 event: AppEvent::Ipc(IpcCall {
1315 id: "c1".to_string(),
1316 command: "save_settings".to_string(),
1317 timestamp: Utc::now(),
1318 duration_ms: Some(10),
1319 result: IpcResult::Ok(serde_json::json!(true)),
1320 arg_size_bytes: 0,
1321 webview_label: "main".to_string(),
1322 mocked: false,
1323 }),
1324 }]);
1325 let opts = CodegenOptions {
1326 emit_ipc_assert_calls: true,
1327 ..CodegenOptions::default()
1328 };
1329 let code = generate_test(&session, &opts);
1330
1331 assert!(
1332 code.contains("assert_ipc_called(&log, \"save_settings\");"),
1333 "expected assert_ipc_called, got:\n{code}"
1334 );
1335 assert!(code.contains("use victauri_test::assert_ipc_called;"));
1336 assert!(code.contains("let log = client.get_ipc_log(None).await.unwrap();"));
1337 }
1338
1339 #[test]
1340 fn selector_to_locator_mapping() {
1341 assert_eq!(selector_to_locator("#btn"), "Locator::css(\"#btn\")");
1342 assert_eq!(
1343 selector_to_locator("button:has-text(\"OK\")"),
1344 "Locator::text(\"OK\")"
1345 );
1346 assert_eq!(
1347 selector_to_locator("[data-testid=\"foo\"]"),
1348 "Locator::test_id(\"foo\")"
1349 );
1350 assert_eq!(
1351 selector_to_locator("[role=\"navigation\"]"),
1352 "Locator::role(\"navigation\")"
1353 );
1354 assert_eq!(
1355 selector_to_locator(".my-class"),
1356 "Locator::css(\".my-class\")"
1357 );
1358 }
1359}