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/// Options for controlling test code generation.
13#[derive(Debug, Clone, Eq, PartialEq)]
14pub struct CodegenOptions {
15    /// Name of the generated test function.
16    pub test_name: String,
17    /// Whether to include IPC assertions after interactions.
18    pub include_ipc_assertions: bool,
19    /// Whether to include state verification assertions.
20    pub include_state_checks: bool,
21    /// Whether to add timing comments showing relative timestamps.
22    pub include_timing_comments: bool,
23}
24
25impl Default for CodegenOptions {
26    fn default() -> Self {
27        Self {
28            test_name: "recorded_flow".to_string(),
29            include_ipc_assertions: true,
30            include_state_checks: true,
31            include_timing_comments: true,
32        }
33    }
34}
35
36/// Generates a Rust test file from a recorded session using default options.
37///
38/// This is a convenience wrapper around [`generate_test`] with
39/// [`CodegenOptions::default()`].
40#[must_use]
41pub fn generate_test_default(session: &RecordedSession) -> String {
42    generate_test(session, &CodegenOptions::default())
43}
44
45/// Generates a Rust test file from a recorded session.
46///
47/// Converts DOM interactions into `VictauriClient` API calls and IPC events
48/// into verification assertions. The generated test is self-contained and
49/// can be run with `cargo test`.
50#[must_use]
51pub fn generate_test(session: &RecordedSession, options: &CodegenOptions) -> String {
52    let mut out = String::with_capacity(2048);
53
54    // File header
55    let date = Utc::now().format("%Y-%m-%d");
56    out.push_str(&format!("// Generated by victauri record -- {date}\n"));
57    out.push_str(&format!("// Session: {}\n", session.id));
58    out.push_str("\nuse victauri_test::VictauriClient;\n\n");
59
60    // Test function
61    out.push_str("#[tokio::test]\n");
62    out.push_str(&format!("async fn {}() {{\n", options.test_name));
63    out.push_str(
64        "    let mut client = VictauriClient::discover().await.expect(\"connect to Tauri app\");\n",
65    );
66
67    let session_start = session.started_at;
68
69    for (i, recorded) in session.events.iter().enumerate() {
70        // Timing comment: elapsed since session start, only when gap > 500ms
71        if options.include_timing_comments {
72            let elapsed_ms = recorded
73                .timestamp
74                .signed_duration_since(session_start)
75                .num_milliseconds();
76
77            let show_timing = if i == 0 {
78                elapsed_ms > 500
79            } else {
80                let prev_ts = session.events[i - 1].timestamp;
81                let gap_ms = recorded
82                    .timestamp
83                    .signed_duration_since(prev_ts)
84                    .num_milliseconds();
85                gap_ms > 500
86            };
87
88            if show_timing {
89                out.push_str(&format!("\n    // +{elapsed_ms}ms\n"));
90            }
91        }
92
93        match &recorded.event {
94            AppEvent::DomInteraction {
95                action,
96                selector,
97                value,
98                ..
99            } => {
100                emit_interaction(&mut out, action, selector, value.as_deref());
101            }
102
103            AppEvent::Ipc(call) if options.include_ipc_assertions => {
104                // Skip internal victauri plugin commands
105                if call.command.starts_with("plugin:victauri|") {
106                    continue;
107                }
108                if matches!(call.result, IpcResult::Ok(_)) {
109                    let cmd = &call.command;
110                    out.push_str(&format!("    // IPC: {cmd} completed successfully\n"));
111                }
112            }
113
114            AppEvent::StateChange { key, caused_by, .. } if options.include_state_checks => {
115                if caused_by.is_some() {
116                    out.push_str(&format!("    // State changed: {key}\n"));
117                }
118            }
119
120            // DomMutation, WindowEvent, and disabled variants are skipped
121            _ => {}
122        }
123    }
124
125    out.push_str("}\n");
126    out
127}
128
129/// Escapes a string for embedding in a Rust string literal.
130fn escape_rust_str(s: &str) -> String {
131    let mut escaped = String::with_capacity(s.len());
132    for ch in s.chars() {
133        match ch {
134            '\\' => escaped.push_str("\\\\"),
135            '"' => escaped.push_str("\\\""),
136            '\n' => escaped.push_str("\\n"),
137            '\r' => escaped.push_str("\\r"),
138            '\t' => escaped.push_str("\\t"),
139            other => escaped.push(other),
140        }
141    }
142    escaped
143}
144
145/// Resolved selector form for emitting idiomatic `VictauriClient` calls.
146///
147/// The JS observer produces raw CSS selectors; this classification maps them
148/// to the high-level convenience methods on `VictauriClient` (`click_by_id`,
149/// `click_by_text`, etc.) so generated tests read naturally.
150enum ResolvedSelector {
151    /// Selector started with `#` — strip the hash and use `*_by_id`.
152    ById(String),
153    /// Selector contained `:has-text("...")` — extract the text and use `*_by_text`.
154    ByText(String),
155    /// Everything else — pass the raw selector through.
156    Raw(String),
157}
158
159/// Classifies a raw CSS selector into the most idiomatic `VictauriClient` form.
160fn resolve_selector(selector: &str) -> ResolvedSelector {
161    // Pattern 2: contains `:has-text("...")` — extract the quoted text.
162    if let Some(start) = selector.find(":has-text(\"") {
163        let text_start = start + ":has-text(\"".len();
164        if let Some(end) = selector[text_start..].find("\")") {
165            let text = &selector[text_start..text_start + end];
166            return ResolvedSelector::ByText(text.to_string());
167        }
168    }
169
170    // Pattern 1: starts with `#` (simple ID selector, no combinators).
171    if selector.starts_with('#') && !selector[1..].contains(' ') {
172        let id = &selector[1..];
173        return ResolvedSelector::ById(id.to_string());
174    }
175
176    ResolvedSelector::Raw(selector.to_string())
177}
178
179/// Emits a single DOM interaction as a `VictauriClient` method call.
180///
181/// Selector-aware: emits `*_by_id` for `#id` selectors, `*_by_text` for
182/// `:has-text("...")` selectors, and the raw `client.click(selector)` form
183/// for everything else.
184fn emit_interaction(
185    out: &mut String,
186    action: &InteractionKind,
187    selector: &str,
188    value: Option<&str>,
189) {
190    let resolved = resolve_selector(selector);
191
192    match action {
193        InteractionKind::Click => {
194            emit_resolved_call(out, "click", &resolved, None);
195        }
196        InteractionKind::DoubleClick => {
197            emit_resolved_call(out, "double_click", &resolved, None);
198        }
199        InteractionKind::Fill => {
200            let val = value.map_or_else(String::new, escape_rust_str);
201            emit_resolved_call(out, "fill", &resolved, Some(&val));
202        }
203        InteractionKind::KeyPress => {
204            let val = value.map_or_else(String::new, escape_rust_str);
205            out.push_str(&format!(
206                "    client.press_key(\"{val}\").await.unwrap();\n"
207            ));
208        }
209        InteractionKind::Select => {
210            let val = value.map_or_else(String::new, escape_rust_str);
211            emit_resolved_select(out, &resolved, &val);
212        }
213        InteractionKind::Navigate => {
214            let val = value.map_or_else(String::new, escape_rust_str);
215            out.push_str(&format!("    client.navigate(\"{val}\").await.unwrap();\n"));
216        }
217        InteractionKind::Scroll => {
218            let sel = escape_rust_str(selector);
219            out.push_str(&format!(
220                "    client.scroll_to(\"{sel}\", None, None).await.unwrap();\n"
221            ));
222        }
223    }
224}
225
226/// Emits a resolved call for click-like and fill-like methods.
227fn emit_resolved_call(
228    out: &mut String,
229    base_method: &str,
230    resolved: &ResolvedSelector,
231    extra_arg: Option<&str>,
232) {
233    let suffix = extra_arg.map_or_else(String::new, |v| format!(", \"{v}\""));
234    match resolved {
235        ResolvedSelector::ById(id) => {
236            let escaped = escape_rust_str(id);
237            out.push_str(&format!(
238                "    client.{base_method}_by_id(\"{escaped}\"{suffix}).await.unwrap();\n"
239            ));
240        }
241        ResolvedSelector::ByText(text) => {
242            let escaped = escape_rust_str(text);
243            out.push_str(&format!(
244                "    client.{base_method}_by_text(\"{escaped}\"{suffix}).await.unwrap();\n"
245            ));
246        }
247        ResolvedSelector::Raw(sel) => {
248            let escaped = escape_rust_str(sel);
249            out.push_str(&format!(
250                "    client.{base_method}(\"{escaped}\"{suffix}).await.unwrap();\n"
251            ));
252        }
253    }
254}
255
256/// Emits a resolved `select_option` call.
257fn emit_resolved_select(out: &mut String, resolved: &ResolvedSelector, val: &str) {
258    match resolved {
259        ResolvedSelector::ById(id) => {
260            let escaped = escape_rust_str(id);
261            out.push_str(&format!(
262                "    client.select_option_by_id(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
263            ));
264        }
265        ResolvedSelector::ByText(text) => {
266            let escaped = escape_rust_str(text);
267            out.push_str(&format!(
268                "    client.select_option_by_text(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
269            ));
270        }
271        ResolvedSelector::Raw(sel) => {
272            let escaped = escape_rust_str(sel);
273            out.push_str(&format!(
274                "    client.select_option(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
275            ));
276        }
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use chrono::{Duration, Utc};
283
284    use super::*;
285    use crate::event::{AppEvent, InteractionKind, IpcCall, IpcResult};
286    use crate::recording::{RecordedEvent, RecordedSession};
287
288    fn make_session(events: Vec<RecordedEvent>) -> RecordedSession {
289        RecordedSession {
290            id: "test-session-001".to_string(),
291            started_at: Utc::now(),
292            events,
293            checkpoints: vec![],
294        }
295    }
296
297    fn interaction_event(
298        index: usize,
299        action: InteractionKind,
300        selector: &str,
301        value: Option<&str>,
302        offset_ms: i64,
303    ) -> RecordedEvent {
304        RecordedEvent {
305            index,
306            timestamp: Utc::now() + Duration::milliseconds(offset_ms),
307            event: AppEvent::DomInteraction {
308                action,
309                selector: selector.to_string(),
310                value: value.map(String::from),
311                timestamp: Utc::now() + Duration::milliseconds(offset_ms),
312                webview_label: "main".to_string(),
313            },
314        }
315    }
316
317    #[test]
318    fn empty_session_produces_valid_skeleton() {
319        let session = make_session(vec![]);
320        let code = generate_test_default(&session);
321
322        assert!(code.contains("use victauri_test::VictauriClient;"));
323        assert!(code.contains("#[tokio::test]"));
324        assert!(code.contains("async fn recorded_flow()"));
325        assert!(code.contains("VictauriClient::discover()"));
326        assert!(code.contains("Session: test-session-001"));
327    }
328
329    #[test]
330    fn click_by_id_generated_for_hash_selector() {
331        let session = make_session(vec![interaction_event(
332            0,
333            InteractionKind::Click,
334            "#submit-btn",
335            None,
336            0,
337        )]);
338        let code = generate_test_default(&session);
339
340        assert!(
341            code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
342            "expected click_by_id for # selector, got:\n{code}"
343        );
344    }
345
346    #[test]
347    fn fill_generates_correct_call() {
348        let session = make_session(vec![interaction_event(
349            0,
350            InteractionKind::Fill,
351            "input[name=\"email\"]",
352            Some("user@example.com"),
353            0,
354        )]);
355        let code = generate_test_default(&session);
356
357        assert!(code.contains(
358            "client.fill(\"input[name=\\\"email\\\"]\", \"user@example.com\").await.unwrap();"
359        ));
360    }
361
362    #[test]
363    fn ipc_comment_included_when_enabled() {
364        let session = make_session(vec![RecordedEvent {
365            index: 0,
366            timestamp: Utc::now(),
367            event: AppEvent::Ipc(IpcCall {
368                id: "c1".to_string(),
369                command: "save_settings".to_string(),
370                timestamp: Utc::now(),
371                duration_ms: Some(10),
372                result: IpcResult::Ok(serde_json::json!(true)),
373                arg_size_bytes: 0,
374                webview_label: "main".to_string(),
375            }),
376        }]);
377        let code = generate_test_default(&session);
378
379        assert!(code.contains("// IPC: save_settings completed successfully"));
380    }
381
382    #[test]
383    fn internal_victauri_ipc_skipped() {
384        let session = make_session(vec![RecordedEvent {
385            index: 0,
386            timestamp: Utc::now(),
387            event: AppEvent::Ipc(IpcCall {
388                id: "c2".to_string(),
389                command: "plugin:victauri|get_snapshot".to_string(),
390                timestamp: Utc::now(),
391                duration_ms: Some(2),
392                result: IpcResult::Ok(serde_json::json!({})),
393                arg_size_bytes: 0,
394                webview_label: "main".to_string(),
395            }),
396        }]);
397        let code = generate_test_default(&session);
398
399        assert!(!code.contains("plugin:victauri"));
400    }
401
402    #[test]
403    fn ipc_omitted_when_disabled() {
404        let session = make_session(vec![RecordedEvent {
405            index: 0,
406            timestamp: Utc::now(),
407            event: AppEvent::Ipc(IpcCall {
408                id: "c1".to_string(),
409                command: "save_settings".to_string(),
410                timestamp: Utc::now(),
411                duration_ms: Some(10),
412                result: IpcResult::Ok(serde_json::json!(true)),
413                arg_size_bytes: 0,
414                webview_label: "main".to_string(),
415            }),
416        }]);
417        let opts = CodegenOptions {
418            include_ipc_assertions: false,
419            ..CodegenOptions::default()
420        };
421        let code = generate_test(&session, &opts);
422
423        assert!(!code.contains("IPC:"));
424    }
425
426    #[test]
427    fn state_change_comment_included() {
428        let session = make_session(vec![RecordedEvent {
429            index: 0,
430            timestamp: Utc::now(),
431            event: AppEvent::StateChange {
432                key: "user.theme".to_string(),
433                timestamp: Utc::now(),
434                caused_by: Some("toggle_theme".to_string()),
435            },
436        }]);
437        let code = generate_test_default(&session);
438
439        assert!(code.contains("// State changed: user.theme"));
440    }
441
442    #[test]
443    fn custom_test_name() {
444        let session = make_session(vec![]);
445        let opts = CodegenOptions {
446            test_name: "my_custom_test".to_string(),
447            ..CodegenOptions::default()
448        };
449        let code = generate_test(&session, &opts);
450
451        assert!(code.contains("async fn my_custom_test()"));
452    }
453
454    #[test]
455    fn special_chars_escaped() {
456        let session = make_session(vec![interaction_event(
457            0,
458            InteractionKind::Fill,
459            "input",
460            Some("line1\nline2\ttab\"quote\\back"),
461            0,
462        )]);
463        let code = generate_test_default(&session);
464
465        assert!(code.contains("\\n"));
466        assert!(code.contains("\\t"));
467        assert!(code.contains("\\\""));
468        assert!(code.contains("\\\\"));
469    }
470
471    #[test]
472    fn all_interaction_kinds_generate_code() {
473        let session = make_session(vec![
474            interaction_event(0, InteractionKind::Click, "[data-testid=\"a\"]", None, 0),
475            interaction_event(
476                1,
477                InteractionKind::DoubleClick,
478                "[data-testid=\"b\"]",
479                None,
480                10,
481            ),
482            interaction_event(
483                2,
484                InteractionKind::Fill,
485                "[data-testid=\"c\"]",
486                Some("val"),
487                20,
488            ),
489            interaction_event(3, InteractionKind::KeyPress, "#d", Some("Enter"), 30),
490            interaction_event(
491                4,
492                InteractionKind::Select,
493                "[data-testid=\"e\"]",
494                Some("opt1"),
495                40,
496            ),
497            interaction_event(5, InteractionKind::Navigate, "#f", Some("/page"), 50),
498            interaction_event(6, InteractionKind::Scroll, "#g", None, 60),
499        ]);
500        let code = generate_test(
501            &session,
502            &CodegenOptions {
503                include_timing_comments: false,
504                ..CodegenOptions::default()
505            },
506        );
507
508        assert!(code.contains("client.click(\"[data-testid=\\\"a\\\"]\")"));
509        assert!(code.contains("client.double_click(\"[data-testid=\\\"b\\\"]\")"));
510        assert!(code.contains("client.fill(\"[data-testid=\\\"c\\\"]\", \"val\")"));
511        assert!(code.contains("client.press_key(\"Enter\")"));
512        assert!(code.contains("client.select_option(\"[data-testid=\\\"e\\\"]\", &[\"opt1\"])"));
513        assert!(code.contains("client.navigate(\"/page\")"));
514        assert!(code.contains("client.scroll_to(\"#g\", None, None)"));
515    }
516
517    #[test]
518    fn dom_mutation_and_window_event_skipped() {
519        let ts = Utc::now();
520        let session = make_session(vec![
521            RecordedEvent {
522                index: 0,
523                timestamp: ts,
524                event: AppEvent::DomMutation {
525                    webview_label: "main".to_string(),
526                    timestamp: ts,
527                    mutation_count: 5,
528                },
529            },
530            RecordedEvent {
531                index: 1,
532                timestamp: ts,
533                event: AppEvent::WindowEvent {
534                    label: "main".to_string(),
535                    event: "focus".to_string(),
536                    timestamp: ts,
537                },
538            },
539        ]);
540        let code = generate_test_default(&session);
541
542        // Should only have the skeleton, no interaction or assertion lines
543        assert!(!code.contains("client."));
544        assert!(!code.contains("// IPC:"));
545        assert!(!code.contains("// State"));
546    }
547
548    #[test]
549    fn default_options_are_correct() {
550        let opts = CodegenOptions::default();
551        assert_eq!(opts.test_name, "recorded_flow");
552        assert!(opts.include_ipc_assertions);
553        assert!(opts.include_state_checks);
554        assert!(opts.include_timing_comments);
555    }
556
557    // --- Timing comment tests ---
558
559    fn make_session_at(base: chrono::DateTime<Utc>, events: Vec<RecordedEvent>) -> RecordedSession {
560        RecordedSession {
561            id: "timing-session".to_string(),
562            started_at: base,
563            events,
564            checkpoints: vec![],
565        }
566    }
567
568    fn interaction_event_at(
569        base: chrono::DateTime<Utc>,
570        index: usize,
571        action: InteractionKind,
572        selector: &str,
573        value: Option<&str>,
574        offset_ms: i64,
575    ) -> RecordedEvent {
576        let ts = base + Duration::milliseconds(offset_ms);
577        RecordedEvent {
578            index,
579            timestamp: ts,
580            event: AppEvent::DomInteraction {
581                action,
582                selector: selector.to_string(),
583                value: value.map(String::from),
584                timestamp: ts,
585                webview_label: "main".to_string(),
586            },
587        }
588    }
589
590    #[test]
591    fn timing_comment_emitted_for_large_gap() {
592        let base = Utc::now();
593        let session = make_session_at(
594            base,
595            vec![
596                interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
597                interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 1000),
598            ],
599        );
600        let opts = CodegenOptions {
601            include_timing_comments: true,
602            ..CodegenOptions::default()
603        };
604        let code = generate_test(&session, &opts);
605
606        assert!(
607            code.contains("// +1000ms"),
608            "expected timing comment for 1000ms gap, got:\n{code}"
609        );
610    }
611
612    #[test]
613    fn timing_comment_omitted_for_small_gap() {
614        let base = Utc::now();
615        let session = make_session_at(
616            base,
617            vec![
618                interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
619                interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 200),
620            ],
621        );
622        let opts = CodegenOptions {
623            include_timing_comments: true,
624            ..CodegenOptions::default()
625        };
626        let code = generate_test(&session, &opts);
627
628        assert!(
629            !code.contains("// +"),
630            "expected no timing comment for 200ms gap, got:\n{code}"
631        );
632    }
633
634    // --- Selector resolution tests ---
635
636    #[test]
637    fn id_selector_emits_click_by_id() {
638        let session = make_session(vec![interaction_event(
639            0,
640            InteractionKind::Click,
641            "#my-id",
642            None,
643            0,
644        )]);
645        let code = generate_test_default(&session);
646
647        assert!(
648            code.contains("client.click_by_id(\"my-id\").await.unwrap();"),
649            "expected click_by_id, got:\n{code}"
650        );
651    }
652
653    #[test]
654    fn has_text_selector_emits_click_by_text() {
655        let session = make_session(vec![interaction_event(
656            0,
657            InteractionKind::Click,
658            "button:has-text(\"Submit\")",
659            None,
660            0,
661        )]);
662        let code = generate_test_default(&session);
663
664        assert!(
665            code.contains("client.click_by_text(\"Submit\").await.unwrap();"),
666            "expected click_by_text, got:\n{code}"
667        );
668    }
669
670    #[test]
671    fn role_has_text_selector_emits_click_by_text() {
672        let session = make_session(vec![interaction_event(
673            0,
674            InteractionKind::Click,
675            "[role=\"button\"]:has-text(\"Save\")",
676            None,
677            0,
678        )]);
679        let code = generate_test_default(&session);
680
681        assert!(
682            code.contains("client.click_by_text(\"Save\").await.unwrap();"),
683            "expected click_by_text for role selector, got:\n{code}"
684        );
685    }
686
687    #[test]
688    fn data_testid_selector_emits_raw_click() {
689        let session = make_session(vec![interaction_event(
690            0,
691            InteractionKind::Click,
692            "[data-testid=\"foo\"]",
693            None,
694            0,
695        )]);
696        let code = generate_test_default(&session);
697
698        assert!(
699            code.contains("client.click(\"[data-testid=\\\"foo\\\"]\").await.unwrap();"),
700            "expected raw click for data-testid selector, got:\n{code}"
701        );
702    }
703
704    #[test]
705    fn fill_with_id_selector_emits_fill_by_id() {
706        let session = make_session(vec![interaction_event(
707            0,
708            InteractionKind::Fill,
709            "#email",
710            Some("user@example.com"),
711            0,
712        )]);
713        let code = generate_test_default(&session);
714
715        assert!(
716            code.contains("client.fill_by_id(\"email\", \"user@example.com\").await.unwrap();"),
717            "expected fill_by_id, got:\n{code}"
718        );
719    }
720
721    #[test]
722    fn double_click_with_has_text_emits_by_text() {
723        let session = make_session(vec![interaction_event(
724            0,
725            InteractionKind::DoubleClick,
726            "span:has-text(\"Edit\")",
727            None,
728            0,
729        )]);
730        let code = generate_test_default(&session);
731
732        assert!(
733            code.contains("client.double_click_by_text(\"Edit\").await.unwrap();"),
734            "expected double_click_by_text, got:\n{code}"
735        );
736    }
737
738    #[test]
739    fn select_with_id_emits_select_option_by_id() {
740        let session = make_session(vec![interaction_event(
741            0,
742            InteractionKind::Select,
743            "#country",
744            Some("AU"),
745            0,
746        )]);
747        let code = generate_test_default(&session);
748
749        assert!(
750            code.contains("client.select_option_by_id(\"country\", &[\"AU\"]).await.unwrap();"),
751            "expected select_option_by_id, got:\n{code}"
752        );
753    }
754
755    /// Round-trip test: builds a realistic multi-event session and verifies
756    /// the generated Rust source contains valid structural elements for every
757    /// event kind — click, fill, key-press, IPC, and state-change.
758    #[test]
759    fn round_trip_realistic_session() {
760        let base = Utc::now();
761
762        let events = vec![
763            // 0: Click on #submit-btn  (should resolve to click_by_id)
764            interaction_event_at(base, 0, InteractionKind::Click, "#submit-btn", None, 0),
765            // 1: Fill on input[name=email]  (raw selector — should use client.fill)
766            interaction_event_at(
767                base,
768                1,
769                InteractionKind::Fill,
770                "input[name=email]",
771                Some("test@example.com"),
772                100,
773            ),
774            // 2: KeyPress "Enter"
775            interaction_event_at(
776                base,
777                2,
778                InteractionKind::KeyPress,
779                "body",
780                Some("Enter"),
781                200,
782            ),
783            // 3: IPC call — save_draft completed successfully
784            RecordedEvent {
785                index: 3,
786                timestamp: base + Duration::milliseconds(300),
787                event: AppEvent::Ipc(IpcCall {
788                    id: "ipc-1".to_string(),
789                    command: "save_draft".to_string(),
790                    timestamp: base + Duration::milliseconds(300),
791                    duration_ms: Some(15),
792                    result: IpcResult::Ok(serde_json::json!({"saved": true})),
793                    arg_size_bytes: 42,
794                    webview_label: "main".to_string(),
795                }),
796            },
797            // 4: StateChange caused by save_draft
798            RecordedEvent {
799                index: 4,
800                timestamp: base + Duration::milliseconds(350),
801                event: AppEvent::StateChange {
802                    key: "draft.status".to_string(),
803                    timestamp: base + Duration::milliseconds(350),
804                    caused_by: Some("save_draft".to_string()),
805                },
806            },
807        ];
808
809        let session = make_session_at(base, events);
810        let code = generate_test(&session, &CodegenOptions::default());
811
812        // --- Structural validity: the generated code is a well-formed async test ---
813        assert!(
814            code.contains("#[tokio::test]"),
815            "missing #[tokio::test] attribute:\n{code}"
816        );
817        assert!(
818            code.contains("async fn recorded_flow()"),
819            "missing async fn declaration:\n{code}"
820        );
821        assert!(
822            code.contains("VictauriClient::discover()"),
823            "missing VictauriClient::discover() call:\n{code}"
824        );
825        assert!(
826            code.ends_with("}\n"),
827            "missing closing brace at end of generated code:\n{code}"
828        );
829
830        // --- Event-specific assertions ---
831
832        // Click on #submit-btn should resolve to click_by_id (not raw click with "#submit-btn")
833        assert!(
834            code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
835            "expected click_by_id for #submit-btn:\n{code}"
836        );
837        assert!(
838            !code.contains("client.click(\"#submit-btn\")"),
839            "#submit-btn should NOT appear as raw client.click:\n{code}"
840        );
841
842        // Fill on input[name=email] — raw selector, no # prefix
843        assert!(
844            code.contains(
845                "client.fill(\"input[name=email]\", \"test@example.com\").await.unwrap();"
846            ),
847            "expected raw client.fill for input[name=email]:\n{code}"
848        );
849        assert!(
850            !code.contains("client.fill_by_id(\"input[name=email]\""),
851            "input[name=email] should NOT resolve to fill_by_id:\n{code}"
852        );
853
854        // KeyPress "Enter"
855        assert!(
856            code.contains("client.press_key(\"Enter\").await.unwrap();"),
857            "expected press_key(\"Enter\"):\n{code}"
858        );
859
860        // IPC comment for save_draft
861        assert!(
862            code.contains("// IPC: save_draft completed successfully"),
863            "expected IPC comment for save_draft:\n{code}"
864        );
865
866        // State change comment for draft.status
867        assert!(
868            code.contains("// State changed: draft.status"),
869            "expected state change comment for draft.status:\n{code}"
870        );
871
872        // --- Brace matching: count opening and closing braces ---
873        let open_braces = code.matches('{').count();
874        let close_braces = code.matches('}').count();
875        assert_eq!(
876            open_braces, close_braces,
877            "unbalanced braces: {open_braces} open vs {close_braces} close in:\n{code}"
878        );
879    }
880}