use chrono::Utc;
use crate::event::{AppEvent, InteractionKind, IpcResult};
use crate::recording::RecordedSession;
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct CodegenOptions {
pub test_name: String,
pub include_ipc_assertions: bool,
pub include_state_checks: bool,
pub include_timing_comments: bool,
}
impl Default for CodegenOptions {
fn default() -> Self {
Self {
test_name: "recorded_flow".to_string(),
include_ipc_assertions: true,
include_state_checks: true,
include_timing_comments: true,
}
}
}
#[must_use]
pub fn generate_test_default(session: &RecordedSession) -> String {
generate_test(session, &CodegenOptions::default())
}
#[must_use]
pub fn generate_test(session: &RecordedSession, options: &CodegenOptions) -> String {
let mut out = String::with_capacity(2048);
let date = Utc::now().format("%Y-%m-%d");
out.push_str(&format!("// Generated by victauri record -- {date}\n"));
out.push_str(&format!("// Session: {}\n", session.id));
out.push_str("\nuse victauri_test::VictauriClient;\n\n");
out.push_str("#[tokio::test]\n");
out.push_str(&format!("async fn {}() {{\n", options.test_name));
out.push_str(
" let mut client = VictauriClient::discover().await.expect(\"connect to Tauri app\");\n",
);
let session_start = session.started_at;
for (i, recorded) in session.events.iter().enumerate() {
if options.include_timing_comments {
let elapsed_ms = recorded
.timestamp
.signed_duration_since(session_start)
.num_milliseconds();
let show_timing = if i == 0 {
elapsed_ms > 500
} else {
let prev_ts = session.events[i - 1].timestamp;
let gap_ms = recorded
.timestamp
.signed_duration_since(prev_ts)
.num_milliseconds();
gap_ms > 500
};
if show_timing {
out.push_str(&format!("\n // +{elapsed_ms}ms\n"));
}
}
match &recorded.event {
AppEvent::DomInteraction {
action,
selector,
value,
..
} => {
emit_interaction(&mut out, action, selector, value.as_deref());
}
AppEvent::Ipc(call) if options.include_ipc_assertions => {
if call.command.starts_with("plugin:victauri|") {
continue;
}
if matches!(call.result, IpcResult::Ok(_)) {
let cmd = &call.command;
out.push_str(&format!(" // IPC: {cmd} completed successfully\n"));
}
}
AppEvent::StateChange { key, caused_by, .. }
if options.include_state_checks && caused_by.is_some() =>
{
out.push_str(&format!(" // State changed: {key}\n"));
}
_ => {}
}
}
out.push_str("}\n");
out
}
fn escape_rust_str(s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
other => escaped.push(other),
}
}
escaped
}
enum ResolvedSelector {
ById(String),
ByText(String),
Raw(String),
}
fn resolve_selector(selector: &str) -> ResolvedSelector {
if let Some(start) = selector.find(":has-text(\"") {
let text_start = start + ":has-text(\"".len();
if let Some(end) = selector[text_start..].find("\")") {
let text = &selector[text_start..text_start + end];
return ResolvedSelector::ByText(text.to_string());
}
}
if selector.starts_with('#') && !selector[1..].contains(' ') {
let id = &selector[1..];
return ResolvedSelector::ById(id.to_string());
}
ResolvedSelector::Raw(selector.to_string())
}
fn emit_interaction(
out: &mut String,
action: &InteractionKind,
selector: &str,
value: Option<&str>,
) {
let resolved = resolve_selector(selector);
match action {
InteractionKind::Click => {
emit_resolved_call(out, "click", &resolved, None);
}
InteractionKind::DoubleClick => {
emit_resolved_call(out, "double_click", &resolved, None);
}
InteractionKind::Fill => {
let val = value.map_or_else(String::new, escape_rust_str);
emit_resolved_call(out, "fill", &resolved, Some(&val));
}
InteractionKind::KeyPress => {
let val = value.map_or_else(String::new, escape_rust_str);
out.push_str(&format!(
" client.press_key(\"{val}\").await.unwrap();\n"
));
}
InteractionKind::Select => {
let val = value.map_or_else(String::new, escape_rust_str);
emit_resolved_select(out, &resolved, &val);
}
InteractionKind::Navigate => {
let val = value.map_or_else(String::new, escape_rust_str);
out.push_str(&format!(" client.navigate(\"{val}\").await.unwrap();\n"));
}
InteractionKind::Scroll => {
emit_resolved_scroll(out, &resolved);
}
}
}
fn emit_resolved_call(
out: &mut String,
base_method: &str,
resolved: &ResolvedSelector,
extra_arg: Option<&str>,
) {
let suffix = extra_arg.map_or_else(String::new, |v| format!(", \"{v}\""));
match resolved {
ResolvedSelector::ById(id) => {
let escaped = escape_rust_str(id);
out.push_str(&format!(
" client.{base_method}_by_id(\"{escaped}\"{suffix}).await.unwrap();\n"
));
}
ResolvedSelector::ByText(text) => {
let escaped = escape_rust_str(text);
out.push_str(&format!(
" client.{base_method}_by_text(\"{escaped}\"{suffix}).await.unwrap();\n"
));
}
ResolvedSelector::Raw(sel) => {
let escaped = escape_rust_str(sel);
out.push_str(&format!(
" client.{base_method}_by_selector(\"{escaped}\"{suffix}).await.unwrap();\n"
));
}
}
}
fn emit_resolved_select(out: &mut String, resolved: &ResolvedSelector, val: &str) {
match resolved {
ResolvedSelector::ById(id) => {
let escaped = escape_rust_str(id);
out.push_str(&format!(
" client.select_option_by_id(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
));
}
ResolvedSelector::ByText(text) => {
let escaped = escape_rust_str(text);
out.push_str(&format!(
" client.select_option_by_text(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
));
}
ResolvedSelector::Raw(sel) => {
let escaped = escape_rust_str(sel);
out.push_str(&format!(
" client.select_option_by_selector(\"{escaped}\", &[\"{val}\"]).await.unwrap();\n"
));
}
}
}
fn emit_resolved_scroll(out: &mut String, resolved: &ResolvedSelector) {
match resolved {
ResolvedSelector::ById(id) => {
let escaped = escape_rust_str(id);
out.push_str(&format!(
" client.scroll_to_by_id(\"{escaped}\").await.unwrap();\n"
));
}
ResolvedSelector::ByText(_) | ResolvedSelector::Raw(_) => {
let sel = match resolved {
ResolvedSelector::ByText(t) => escape_rust_str(t),
ResolvedSelector::Raw(s) => escape_rust_str(s),
_ => unreachable!(),
};
out.push_str(&format!(
" client.scroll_to_by_selector(\"{sel}\").await.unwrap();\n"
));
}
}
}
#[cfg(test)]
mod tests {
use chrono::{Duration, Utc};
use super::*;
use crate::event::{AppEvent, InteractionKind, IpcCall, IpcResult};
use crate::recording::{RecordedEvent, RecordedSession};
fn make_session(events: Vec<RecordedEvent>) -> RecordedSession {
RecordedSession {
id: "test-session-001".to_string(),
started_at: Utc::now(),
events,
checkpoints: vec![],
}
}
fn interaction_event(
index: usize,
action: InteractionKind,
selector: &str,
value: Option<&str>,
offset_ms: i64,
) -> RecordedEvent {
RecordedEvent {
index,
timestamp: Utc::now() + Duration::milliseconds(offset_ms),
event: AppEvent::DomInteraction {
action,
selector: selector.to_string(),
value: value.map(String::from),
timestamp: Utc::now() + Duration::milliseconds(offset_ms),
webview_label: "main".to_string(),
},
}
}
#[test]
fn empty_session_produces_valid_skeleton() {
let session = make_session(vec![]);
let code = generate_test_default(&session);
assert!(code.contains("use victauri_test::VictauriClient;"));
assert!(code.contains("#[tokio::test]"));
assert!(code.contains("async fn recorded_flow()"));
assert!(code.contains("VictauriClient::discover()"));
assert!(code.contains("Session: test-session-001"));
}
#[test]
fn click_by_id_generated_for_hash_selector() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Click,
"#submit-btn",
None,
0,
)]);
let code = generate_test_default(&session);
assert!(
code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
"expected click_by_id for # selector, got:\n{code}"
);
}
#[test]
fn fill_generates_correct_call() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Fill,
"input[name=\"email\"]",
Some("user@example.com"),
0,
)]);
let code = generate_test_default(&session);
assert!(code.contains(
"client.fill_by_selector(\"input[name=\\\"email\\\"]\", \"user@example.com\").await.unwrap();"
));
}
#[test]
fn ipc_comment_included_when_enabled() {
let session = make_session(vec![RecordedEvent {
index: 0,
timestamp: Utc::now(),
event: AppEvent::Ipc(IpcCall {
id: "c1".to_string(),
command: "save_settings".to_string(),
timestamp: Utc::now(),
duration_ms: Some(10),
result: IpcResult::Ok(serde_json::json!(true)),
arg_size_bytes: 0,
webview_label: "main".to_string(),
}),
}]);
let code = generate_test_default(&session);
assert!(code.contains("// IPC: save_settings completed successfully"));
}
#[test]
fn internal_victauri_ipc_skipped() {
let session = make_session(vec![RecordedEvent {
index: 0,
timestamp: Utc::now(),
event: AppEvent::Ipc(IpcCall {
id: "c2".to_string(),
command: "plugin:victauri|get_snapshot".to_string(),
timestamp: Utc::now(),
duration_ms: Some(2),
result: IpcResult::Ok(serde_json::json!({})),
arg_size_bytes: 0,
webview_label: "main".to_string(),
}),
}]);
let code = generate_test_default(&session);
assert!(!code.contains("plugin:victauri"));
}
#[test]
fn ipc_omitted_when_disabled() {
let session = make_session(vec![RecordedEvent {
index: 0,
timestamp: Utc::now(),
event: AppEvent::Ipc(IpcCall {
id: "c1".to_string(),
command: "save_settings".to_string(),
timestamp: Utc::now(),
duration_ms: Some(10),
result: IpcResult::Ok(serde_json::json!(true)),
arg_size_bytes: 0,
webview_label: "main".to_string(),
}),
}]);
let opts = CodegenOptions {
include_ipc_assertions: false,
..CodegenOptions::default()
};
let code = generate_test(&session, &opts);
assert!(!code.contains("IPC:"));
}
#[test]
fn state_change_comment_included() {
let session = make_session(vec![RecordedEvent {
index: 0,
timestamp: Utc::now(),
event: AppEvent::StateChange {
key: "user.theme".to_string(),
timestamp: Utc::now(),
caused_by: Some("toggle_theme".to_string()),
},
}]);
let code = generate_test_default(&session);
assert!(code.contains("// State changed: user.theme"));
}
#[test]
fn custom_test_name() {
let session = make_session(vec![]);
let opts = CodegenOptions {
test_name: "my_custom_test".to_string(),
..CodegenOptions::default()
};
let code = generate_test(&session, &opts);
assert!(code.contains("async fn my_custom_test()"));
}
#[test]
fn special_chars_escaped() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Fill,
"input",
Some("line1\nline2\ttab\"quote\\back"),
0,
)]);
let code = generate_test_default(&session);
assert!(code.contains("\\n"));
assert!(code.contains("\\t"));
assert!(code.contains("\\\""));
assert!(code.contains("\\\\"));
}
#[test]
fn all_interaction_kinds_generate_code() {
let session = make_session(vec![
interaction_event(0, InteractionKind::Click, "[data-testid=\"a\"]", None, 0),
interaction_event(
1,
InteractionKind::DoubleClick,
"[data-testid=\"b\"]",
None,
10,
),
interaction_event(
2,
InteractionKind::Fill,
"[data-testid=\"c\"]",
Some("val"),
20,
),
interaction_event(3, InteractionKind::KeyPress, "#d", Some("Enter"), 30),
interaction_event(
4,
InteractionKind::Select,
"[data-testid=\"e\"]",
Some("opt1"),
40,
),
interaction_event(5, InteractionKind::Navigate, "#f", Some("/page"), 50),
interaction_event(6, InteractionKind::Scroll, "#g", None, 60),
]);
let code = generate_test(
&session,
&CodegenOptions {
include_timing_comments: false,
..CodegenOptions::default()
},
);
assert!(code.contains("client.click_by_selector(\"[data-testid=\\\"a\\\"]\")"));
assert!(code.contains("client.double_click_by_selector(\"[data-testid=\\\"b\\\"]\")"));
assert!(code.contains("client.fill_by_selector(\"[data-testid=\\\"c\\\"]\", \"val\")"));
assert!(code.contains("client.press_key(\"Enter\")"));
assert!(code.contains(
"client.select_option_by_selector(\"[data-testid=\\\"e\\\"]\", &[\"opt1\"])"
));
assert!(code.contains("client.navigate(\"/page\")"));
assert!(code.contains("client.scroll_to_by_id(\"g\")"));
}
#[test]
fn dom_mutation_and_window_event_skipped() {
let ts = Utc::now();
let session = make_session(vec![
RecordedEvent {
index: 0,
timestamp: ts,
event: AppEvent::DomMutation {
webview_label: "main".to_string(),
timestamp: ts,
mutation_count: 5,
},
},
RecordedEvent {
index: 1,
timestamp: ts,
event: AppEvent::WindowEvent {
label: "main".to_string(),
event: "focus".to_string(),
timestamp: ts,
},
},
]);
let code = generate_test_default(&session);
assert!(!code.contains("client."));
assert!(!code.contains("// IPC:"));
assert!(!code.contains("// State"));
}
#[test]
fn default_options_are_correct() {
let opts = CodegenOptions::default();
assert_eq!(opts.test_name, "recorded_flow");
assert!(opts.include_ipc_assertions);
assert!(opts.include_state_checks);
assert!(opts.include_timing_comments);
}
fn make_session_at(base: chrono::DateTime<Utc>, events: Vec<RecordedEvent>) -> RecordedSession {
RecordedSession {
id: "timing-session".to_string(),
started_at: base,
events,
checkpoints: vec![],
}
}
fn interaction_event_at(
base: chrono::DateTime<Utc>,
index: usize,
action: InteractionKind,
selector: &str,
value: Option<&str>,
offset_ms: i64,
) -> RecordedEvent {
let ts = base + Duration::milliseconds(offset_ms);
RecordedEvent {
index,
timestamp: ts,
event: AppEvent::DomInteraction {
action,
selector: selector.to_string(),
value: value.map(String::from),
timestamp: ts,
webview_label: "main".to_string(),
},
}
}
#[test]
fn timing_comment_emitted_for_large_gap() {
let base = Utc::now();
let session = make_session_at(
base,
vec![
interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 1000),
],
);
let opts = CodegenOptions {
include_timing_comments: true,
..CodegenOptions::default()
};
let code = generate_test(&session, &opts);
assert!(
code.contains("// +1000ms"),
"expected timing comment for 1000ms gap, got:\n{code}"
);
}
#[test]
fn timing_comment_omitted_for_small_gap() {
let base = Utc::now();
let session = make_session_at(
base,
vec![
interaction_event_at(base, 0, InteractionKind::Click, ".btn-a", None, 0),
interaction_event_at(base, 1, InteractionKind::Click, ".btn-b", None, 200),
],
);
let opts = CodegenOptions {
include_timing_comments: true,
..CodegenOptions::default()
};
let code = generate_test(&session, &opts);
assert!(
!code.contains("// +"),
"expected no timing comment for 200ms gap, got:\n{code}"
);
}
#[test]
fn id_selector_emits_click_by_id() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Click,
"#my-id",
None,
0,
)]);
let code = generate_test_default(&session);
assert!(
code.contains("client.click_by_id(\"my-id\").await.unwrap();"),
"expected click_by_id, got:\n{code}"
);
}
#[test]
fn has_text_selector_emits_click_by_text() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Click,
"button:has-text(\"Submit\")",
None,
0,
)]);
let code = generate_test_default(&session);
assert!(
code.contains("client.click_by_text(\"Submit\").await.unwrap();"),
"expected click_by_text, got:\n{code}"
);
}
#[test]
fn role_has_text_selector_emits_click_by_text() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Click,
"[role=\"button\"]:has-text(\"Save\")",
None,
0,
)]);
let code = generate_test_default(&session);
assert!(
code.contains("client.click_by_text(\"Save\").await.unwrap();"),
"expected click_by_text for role selector, got:\n{code}"
);
}
#[test]
fn data_testid_selector_emits_click_by_selector() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Click,
"[data-testid=\"foo\"]",
None,
0,
)]);
let code = generate_test_default(&session);
assert!(
code.contains(
"client.click_by_selector(\"[data-testid=\\\"foo\\\"]\").await.unwrap();"
),
"expected click_by_selector for data-testid selector, got:\n{code}"
);
}
#[test]
fn fill_with_id_selector_emits_fill_by_id() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Fill,
"#email",
Some("user@example.com"),
0,
)]);
let code = generate_test_default(&session);
assert!(
code.contains("client.fill_by_id(\"email\", \"user@example.com\").await.unwrap();"),
"expected fill_by_id, got:\n{code}"
);
}
#[test]
fn double_click_with_has_text_emits_by_text() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::DoubleClick,
"span:has-text(\"Edit\")",
None,
0,
)]);
let code = generate_test_default(&session);
assert!(
code.contains("client.double_click_by_text(\"Edit\").await.unwrap();"),
"expected double_click_by_text, got:\n{code}"
);
}
#[test]
fn select_with_id_emits_select_option_by_id() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Select,
"#country",
Some("AU"),
0,
)]);
let code = generate_test_default(&session);
assert!(
code.contains("client.select_option_by_id(\"country\", &[\"AU\"]).await.unwrap();"),
"expected select_option_by_id, got:\n{code}"
);
}
#[test]
fn round_trip_realistic_session() {
let base = Utc::now();
let events = vec![
interaction_event_at(base, 0, InteractionKind::Click, "#submit-btn", None, 0),
interaction_event_at(
base,
1,
InteractionKind::Fill,
"input[name=email]",
Some("test@example.com"),
100,
),
interaction_event_at(
base,
2,
InteractionKind::KeyPress,
"body",
Some("Enter"),
200,
),
RecordedEvent {
index: 3,
timestamp: base + Duration::milliseconds(300),
event: AppEvent::Ipc(IpcCall {
id: "ipc-1".to_string(),
command: "save_draft".to_string(),
timestamp: base + Duration::milliseconds(300),
duration_ms: Some(15),
result: IpcResult::Ok(serde_json::json!({"saved": true})),
arg_size_bytes: 42,
webview_label: "main".to_string(),
}),
},
RecordedEvent {
index: 4,
timestamp: base + Duration::milliseconds(350),
event: AppEvent::StateChange {
key: "draft.status".to_string(),
timestamp: base + Duration::milliseconds(350),
caused_by: Some("save_draft".to_string()),
},
},
];
let session = make_session_at(base, events);
let code = generate_test(&session, &CodegenOptions::default());
assert!(
code.contains("#[tokio::test]"),
"missing #[tokio::test] attribute:\n{code}"
);
assert!(
code.contains("async fn recorded_flow()"),
"missing async fn declaration:\n{code}"
);
assert!(
code.contains("VictauriClient::discover()"),
"missing VictauriClient::discover() call:\n{code}"
);
assert!(
code.ends_with("}\n"),
"missing closing brace at end of generated code:\n{code}"
);
assert!(
code.contains("client.click_by_id(\"submit-btn\").await.unwrap();"),
"expected click_by_id for #submit-btn:\n{code}"
);
assert!(
!code.contains("client.click(\"#submit-btn\")"),
"#submit-btn should NOT appear as raw client.click:\n{code}"
);
assert!(
code.contains(
"client.fill_by_selector(\"input[name=email]\", \"test@example.com\").await.unwrap();"
),
"expected fill_by_selector for input[name=email]:\n{code}"
);
assert!(
!code.contains("client.fill_by_id(\"input[name=email]\""),
"input[name=email] should NOT resolve to fill_by_id:\n{code}"
);
assert!(
code.contains("client.press_key(\"Enter\").await.unwrap();"),
"expected press_key(\"Enter\"):\n{code}"
);
assert!(
code.contains("// IPC: save_draft completed successfully"),
"expected IPC comment for save_draft:\n{code}"
);
assert!(
code.contains("// State changed: draft.status"),
"expected state change comment for draft.status:\n{code}"
);
let open_braces = code.matches('{').count();
let close_braces = code.matches('}').count();
assert_eq!(
open_braces, close_braces,
"unbalanced braces: {open_braces} open vs {close_braces} close in:\n{code}"
);
}
}