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