Skip to main content

victauri_core/
codegen.rs

1//! Test code generation from recorded sessions.
2//!
3//! Converts a [`RecordedSession`] into a self-contained Rust test file that
4//! replays DOM interactions through the `VictauriClient` API and optionally
5//! asserts on IPC outcomes and state transitions.
6
7use chrono::Utc;
8
9use crate::event::{AppEvent, InteractionKind, IpcResult};
10use crate::recording::RecordedSession;
11
12/// Controls whether generated code uses direct client methods or the Locator API.
13#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
14pub enum CodegenStyle {
15    /// Generate `client.click_by_id("btn")` style calls.
16    #[default]
17    Direct,
18    /// Generate `Locator::css("#btn").click(&mut client)` style calls.
19    Locator,
20}
21
22/// Options for controlling test code generation.
23#[derive(Debug, Clone, Eq, PartialEq)]
24pub struct CodegenOptions {
25    /// Name of the generated test function.
26    pub test_name: String,
27    /// Whether to include IPC assertions after interactions.
28    pub include_ipc_assertions: bool,
29    /// Whether to include state verification assertions.
30    pub include_state_checks: bool,
31    /// Whether to add timing comments showing relative timestamps.
32    pub include_timing_comments: bool,
33    /// Whether to emit `assert_ipc_called` lines instead of just comments.
34    pub emit_ipc_assert_calls: bool,
35    /// API style: direct client calls or Locator-based.
36    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/// Generates a Rust test file from a recorded session using default options.
53///
54/// This is a convenience wrapper around [`generate_test`] with
55/// [`CodegenOptions::default()`].
56#[must_use]
57pub fn generate_test_default(session: &RecordedSession) -> String {
58    generate_test(session, &CodegenOptions::default())
59}
60
61/// Generates a Rust test file from a recorded session.
62///
63/// Converts DOM interactions into `VictauriClient` API calls and IPC events
64/// into verification assertions. The generated test is self-contained and
65/// can be run with `cargo test`.
66#[must_use]
67pub fn generate_test(session: &RecordedSession, options: &CodegenOptions) -> String {
68    let mut out = String::with_capacity(2048);
69
70    // File header
71    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    // Imports depend on style
76    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    // Test function
90    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        // Timing comment: elapsed since session start, only when gap > 500ms
102        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                // Skip internal victauri plugin commands
141                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                    // Strip newlines defensively (audit #16): these values come from
150                    // the recorded app and are written into a `//` comment, so a
151                    // newline could otherwise break out of the comment line.
152                    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            // DomMutation, WindowEvent, and disabled variants are skipped
169            _ => {}
170        }
171    }
172
173    // Emit IPC assertions at the end if enabled
174    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
187/// Flattens newlines/carriage returns so a value interpolated into a `//` line
188/// comment cannot break out of the comment (defense-in-depth, audit #16).
189fn sanitize_comment(s: &str) -> String {
190    s.replace(['\n', '\r'], " ")
191}
192
193/// Converts arbitrary input into a safe Rust identifier for generated test names.
194fn 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
228/// Escapes a string for embedding in a Rust string literal.
229fn 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
244/// Emits a DOM interaction as Locator API calls.
245fn 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
294/// Converts a raw CSS selector into a Locator factory expression.
295fn selector_to_locator(selector: &str) -> String {
296    // :has-text("...") → Locator::text("...")
297    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    // #id → Locator::css("#id")
306    if selector.starts_with('#') && !selector[1..].contains(' ') {
307        let escaped = escape_rust_str(selector);
308        return format!("Locator::css(\"{escaped}\")");
309    }
310
311    // [data-testid="..."] → Locator::test_id("...")
312    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    // [role="..."] → Locator::role("...")
321    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    // Fallback: Locator::css("...")
330    let escaped = escape_rust_str(selector);
331    format!("Locator::css(\"{escaped}\")")
332}
333
334/// Resolved selector form for emitting idiomatic `VictauriClient` calls.
335///
336/// The JS observer produces raw CSS selectors; this classification maps them
337/// to the high-level convenience methods on `VictauriClient` (`click_by_id`,
338/// `click_by_text`, etc.) so generated tests read naturally.
339enum ResolvedSelector {
340    /// Selector started with `#` — strip the hash and use `*_by_id`.
341    ById(String),
342    /// Selector contained `:has-text("...")` — extract the text and use `*_by_text`.
343    ByText(String),
344    /// Everything else — pass the raw selector through.
345    Raw(String),
346}
347
348/// Classifies a raw CSS selector into the most idiomatic `VictauriClient` form.
349fn resolve_selector(selector: &str) -> ResolvedSelector {
350    // Pattern 2: contains `:has-text("...")` — extract the quoted text.
351    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    // Pattern 1: starts with `#` (simple ID selector, no combinators).
360    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
368/// Emits a single DOM interaction as a `VictauriClient` method call.
369///
370/// Selector-aware: emits `*_by_id` for `#id` selectors, `*_by_text` for
371/// `:has-text("...")` selectors, and `*_by_selector` for raw CSS selectors
372/// so the client resolves them to ref handles before interacting.
373fn 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
412/// Emits a resolved call for click-like and fill-like methods.
413///
414/// For `ById` selectors, emits `*_by_id`. For `ByText`, emits `*_by_text`.
415/// For `Raw` CSS selectors, emits `*_by_selector` which resolves the selector
416/// to a ref handle before interacting.
417fn 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
446/// Emits a resolved `select_option` call.
447///
448/// For `Raw` selectors, emits `select_option_by_selector` which resolves the
449/// CSS selector to a ref handle before selecting.
450fn 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
473/// Emits a resolved `scroll_to` call.
474///
475/// For `Raw` selectors, emits `scroll_to_by_selector`. For `ById`, emits
476/// `scroll_to_by_id`. For `ByText`, falls back to `scroll_to_by_selector`
477/// with the original selector text (scroll has no text variant).
478fn 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        // Should only have the skeleton, no interaction or assertion lines
781        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    // --- Timing comment tests ---
796
797    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    // --- Selector resolution tests ---
873
874    #[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    /// Round-trip test: builds a realistic multi-event session and verifies
996    /// the generated Rust source contains valid structural elements for every
997    /// event kind — click, fill, key-press, IPC, and state-change.
998    #[test]
999    fn round_trip_realistic_session() {
1000        let base = Utc::now();
1001
1002        let events = vec![
1003            // 0: Click on #submit-btn  (should resolve to click_by_id)
1004            interaction_event_at(base, 0, InteractionKind::Click, "#submit-btn", None, 0),
1005            // 1: Fill on input[name=email]  (raw selector — should use client.fill_by_selector)
1006            interaction_event_at(
1007                base,
1008                1,
1009                InteractionKind::Fill,
1010                "input[name=email]",
1011                Some("test@example.com"),
1012                100,
1013            ),
1014            // 2: KeyPress "Enter"
1015            interaction_event_at(
1016                base,
1017                2,
1018                InteractionKind::KeyPress,
1019                "body",
1020                Some("Enter"),
1021                200,
1022            ),
1023            // 3: IPC call — save_draft completed successfully
1024            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            // 4: StateChange caused by save_draft
1038            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        // --- Structural validity: the generated code is a well-formed async test ---
1053        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        // --- Event-specific assertions ---
1071
1072        // Click on #submit-btn should resolve to click_by_id (not raw click with "#submit-btn")
1073        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        // Fill on input[name=email] — raw selector, should use fill_by_selector
1083        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        // KeyPress "Enter"
1095        assert!(
1096            code.contains("client.press_key(\"Enter\").await.unwrap();"),
1097            "expected press_key(\"Enter\"):\n{code}"
1098        );
1099
1100        // IPC comment for save_draft
1101        assert!(
1102            code.contains("// IPC: save_draft completed successfully"),
1103            "expected IPC comment for save_draft:\n{code}"
1104        );
1105
1106        // State change comment for draft.status
1107        assert!(
1108            code.contains("// State changed: draft.status"),
1109            "expected state change comment for draft.status:\n{code}"
1110        );
1111
1112        // --- Brace matching: count opening and closing braces ---
1113        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    // --- Locator style tests ---
1122
1123    #[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}