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