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