use chrono::Utc;
use crate::event::{AppEvent, InteractionKind, IpcResult};
use crate::recording::RecordedSession;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
#[non_exhaustive]
pub enum CodegenStyle {
#[default]
Direct,
Locator,
}
#[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,
pub emit_ipc_assert_calls: bool,
pub style: CodegenStyle,
}
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,
emit_ipc_assert_calls: false,
style: CodegenStyle::Direct,
}
}
}
#[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", sanitize_comment(&session.id)));
match options.style {
CodegenStyle::Direct => {
out.push_str("\nuse victauri_test::VictauriClient;\n");
if options.emit_ipc_assert_calls {
out.push_str("use victauri_test::assert_ipc_called;\n");
}
}
CodegenStyle::Locator => {
out.push_str("\nuse victauri_test::prelude::*;\n");
}
}
out.push('\n');
let test_name = sanitize_rust_identifier(&options.test_name);
out.push_str("#[tokio::test]\n");
out.push_str(&format!("async fn {test_name}() {{\n"));
out.push_str(
" let mut client = VictauriClient::discover().await.expect(\"connect to Tauri app\");\n",
);
let session_start = session.started_at;
let mut ipc_commands_seen: Vec<String> = Vec::new();
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,
..
} => match options.style {
CodegenStyle::Direct => {
emit_interaction(&mut out, action, selector, value.as_deref());
}
CodegenStyle::Locator => {
emit_locator_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;
if options.emit_ipc_assert_calls {
ipc_commands_seen.push(cmd.clone());
}
out.push_str(&format!(
" // IPC: {} completed successfully\n",
sanitize_comment(cmd)
));
}
}
AppEvent::StateChange { key, caused_by, .. }
if options.include_state_checks && caused_by.is_some() =>
{
out.push_str(&format!(
" // State changed: {}\n",
sanitize_comment(key)
));
}
_ => {}
}
}
if options.emit_ipc_assert_calls && !ipc_commands_seen.is_empty() {
out.push_str("\n // Verify IPC calls\n");
out.push_str(" let log = client.get_ipc_log(None).await.unwrap();\n");
for cmd in &ipc_commands_seen {
let escaped = escape_rust_str(cmd);
out.push_str(&format!(" assert_ipc_called(&log, \"{escaped}\");\n"));
}
}
out.push_str("}\n");
out
}
fn sanitize_comment(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\n' | '\r' => out.push(' '),
'"' | '\'' | '\\' => out.push(ch),
_ => out.extend(ch.escape_debug()),
}
}
out
}
fn sanitize_rust_identifier(s: &str) -> String {
let mut identifier = String::with_capacity(s.len().max(1));
for (index, ch) in s.chars().enumerate() {
let valid = if index == 0 {
ch.is_ascii_alphabetic() || ch == '_'
} else {
ch.is_ascii_alphanumeric() || ch == '_'
};
if valid {
identifier.push(ch);
} else {
if index == 0 && ch.is_ascii_digit() {
identifier.push('_');
}
identifier.push('_');
}
}
if identifier.is_empty() {
return "recorded_flow".to_string();
}
if RUST_KEYWORDS.contains(&identifier.as_str()) {
identifier.insert_str(0, "recorded_");
}
identifier
}
const RUST_KEYWORDS: &[&str] = &[
"Self", "abstract", "as", "async", "await", "become", "box", "break", "const", "continue",
"crate", "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if",
"impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub",
"ref", "return", "static", "struct", "super", "trait", "true", "try", "type", "typeof",
"union", "unsafe", "unsized", "use", "virtual", "where", "while", "yield",
];
fn escape_rust_str(s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\'' => escaped.push(ch),
_ => escaped.extend(ch.escape_debug()),
}
}
escaped
}
fn emit_locator_interaction(
out: &mut String,
action: &InteractionKind,
selector: &str,
value: Option<&str>,
) {
let locator = selector_to_locator(selector);
match action {
InteractionKind::Click => {
out.push_str(&format!(
" {locator}.click(&mut client).await.unwrap();\n"
));
}
InteractionKind::DoubleClick => {
out.push_str(&format!(
" {locator}.double_click(&mut client).await.unwrap();\n"
));
}
InteractionKind::Fill => {
let val = value.map_or_else(String::new, escape_rust_str);
out.push_str(&format!(
" {locator}.fill(&mut client, \"{val}\").await.unwrap();\n"
));
}
InteractionKind::KeyPress => {
let val = value.map_or_else(String::new, escape_rust_str);
out.push_str(&format!(
" {locator}.press_key(&mut client, \"{val}\").await.unwrap();\n"
));
}
InteractionKind::Select => {
let val = value.map_or_else(String::new, escape_rust_str);
out.push_str(&format!(
" {locator}.select_option(&mut client, &[\"{val}\"]).await.unwrap();\n"
));
}
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 => {
out.push_str(&format!(
" {locator}.scroll_into_view(&mut client).await.unwrap();\n"
));
}
}
}
fn selector_to_locator(selector: &str) -> String {
let (factory, arg) = match parse_recorded_selector(selector) {
RecordedSelector::TestId(id) => ("test_id", id),
RecordedSelector::Role(role) => ("role", role),
RecordedSelector::Text(text) => ("text", text),
RecordedSelector::Id(_) | RecordedSelector::Css => ("css", selector.to_string()),
};
format!("Locator::{factory}(\"{}\")", escape_rust_str(&arg))
}
#[derive(Debug, PartialEq, Eq)]
enum RecordedSelector {
TestId(String),
Id(String),
Role(String),
Text(String),
Css,
}
fn parse_recorded_selector(selector: &str) -> RecordedSelector {
if let Some(rest) = selector.strip_prefix("[data-testid=")
&& let Some((id, rest)) = css_string(rest)
&& rest == "]"
{
return RecordedSelector::TestId(id);
}
if let Some(rest) = selector.strip_prefix('#')
&& let Some(id) = css_ident(rest)
{
return RecordedSelector::Id(id);
}
if let Some(rest) = selector.strip_prefix("[role=")
&& let Some((role, rest)) = css_string(rest)
&& let Some(rest) = rest.strip_prefix(']')
{
if rest.is_empty() {
return RecordedSelector::Role(role);
}
if let Some(text) = has_text_suffix(rest) {
return RecordedSelector::Text(text);
}
return RecordedSelector::Css;
}
let tag_len = selector
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
.unwrap_or(selector.len());
if tag_len > 0
&& let Some(text) = has_text_suffix(&selector[tag_len..])
{
return RecordedSelector::Text(text);
}
RecordedSelector::Css
}
fn has_text_suffix(s: &str) -> Option<String> {
let (text, rest) = css_string(s.strip_prefix(":has-text(")?)?;
(rest == ")").then_some(text)
}
fn css_string(s: &str) -> Option<(String, &str)> {
let body = s.strip_prefix('"')?;
let mut out = String::new();
let mut chars = body.char_indices();
while let Some((i, c)) = chars.next() {
match c {
'"' => return Some((out, &body[i + 1..])),
'\\' => match css_escape(&mut chars) {
Some(Some(decoded)) => out.push(decoded),
Some(None) => {} None => return None,
},
_ => out.push(c),
}
}
None
}
fn css_ident(s: &str) -> Option<String> {
let mut out = String::new();
let mut chars = s.char_indices();
while let Some((_, c)) = chars.next() {
match c {
'\\' => out.push(css_escape(&mut chars)??),
c if c.is_alphanumeric() || c == '-' || c == '_' || !c.is_ascii() => out.push(c),
_ => return None,
}
}
(!out.is_empty()).then_some(out)
}
fn css_escape(chars: &mut std::str::CharIndices<'_>) -> Option<Option<char>> {
let (_, first) = chars.next()?;
if first == '\n' {
return Some(None);
}
let Some(mut code) = first.to_digit(16) else {
return Some(Some(first));
};
for _ in 1..6 {
let mut peek = chars.clone();
match peek.next() {
Some((_, d)) if d.is_ascii_hexdigit() => {
code = code * 16 + d.to_digit(16).unwrap_or(0);
*chars = peek;
}
_ => break,
}
}
let mut peek = chars.clone();
if matches!(peek.next(), Some((_, ' ' | '\t' | '\n'))) {
*chars = peek;
}
Some(Some(
char::from_u32(code)
.filter(|&c| c != '\0')
.unwrap_or(char::REPLACEMENT_CHARACTER),
))
}
enum ResolvedSelector {
ById(String),
ByText(String),
Raw(String),
}
fn resolve_selector(selector: &str) -> ResolvedSelector {
match parse_recorded_selector(selector) {
RecordedSelector::Id(id) => ResolvedSelector::ById(id),
RecordedSelector::Text(text) => ResolvedSelector::ByText(text),
RecordedSelector::TestId(_) | RecordedSelector::Role(_) | RecordedSelector::Css => {
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) {
let (method, arg) = match resolved {
ResolvedSelector::ById(id) => ("scroll_to_by_id", id),
ResolvedSelector::ByText(text) => ("scroll_to_by_text", text),
ResolvedSelector::Raw(sel) => ("scroll_to_by_selector", sel),
};
let escaped = escape_rust_str(arg);
out.push_str(&format!(
" client.{method}(\"{escaped}\").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(),
mocked: false,
transport: None,
}),
}]);
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(),
mocked: false,
transport: None,
}),
}]);
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(),
mocked: false,
transport: None,
}),
}]);
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 hostile_test_name_and_session_id_cannot_inject_rust() {
let mut session = make_session(Vec::new());
session.id = "session\nuse std::process::Command;".to_string();
let opts = CodegenOptions {
test_name: "x() {}\n#[test]\nfn injected".to_string(),
..CodegenOptions::default()
};
let code = generate_test(&session, &opts);
assert!(code.contains("// Session: session use std::process::Command;"));
assert!(code.contains("async fn x________test__fn_injected()"));
assert!(!code.contains("\nuse std::process::Command;"));
assert!(!code.contains("\n#[test]\nfn injected"));
}
#[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(),
mocked: false,
transport: None,
}),
},
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}"
);
}
#[test]
fn locator_style_click_by_id() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Click,
"#submit-btn",
None,
0,
)]);
let opts = CodegenOptions {
style: CodegenStyle::Locator,
include_timing_comments: false,
..CodegenOptions::default()
};
let code = generate_test(&session, &opts);
assert!(
code.contains("Locator::css(\"#submit-btn\").click(&mut client).await.unwrap();"),
"expected Locator::css for id selector, got:\n{code}"
);
assert!(code.contains("use victauri_test::prelude::*;"));
}
#[test]
fn locator_style_click_by_text() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Click,
"button:has-text(\"Save\")",
None,
0,
)]);
let opts = CodegenOptions {
style: CodegenStyle::Locator,
include_timing_comments: false,
..CodegenOptions::default()
};
let code = generate_test(&session, &opts);
assert!(
code.contains("Locator::text(\"Save\").click(&mut client).await.unwrap();"),
"expected Locator::text for has-text, got:\n{code}"
);
}
#[test]
fn locator_style_fill_by_testid() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Fill,
"[data-testid=\"email-input\"]",
Some("user@test.com"),
0,
)]);
let opts = CodegenOptions {
style: CodegenStyle::Locator,
include_timing_comments: false,
..CodegenOptions::default()
};
let code = generate_test(&session, &opts);
assert!(
code.contains(
"Locator::test_id(\"email-input\").fill(&mut client, \"user@test.com\").await.unwrap();"
),
"expected Locator::test_id for data-testid, got:\n{code}"
);
}
#[test]
fn locator_style_role_selector() {
let session = make_session(vec![interaction_event(
0,
InteractionKind::Click,
"[role=\"button\"]",
None,
0,
)]);
let opts = CodegenOptions {
style: CodegenStyle::Locator,
include_timing_comments: false,
..CodegenOptions::default()
};
let code = generate_test(&session, &opts);
assert!(
code.contains("Locator::role(\"button\").click(&mut client).await.unwrap();"),
"expected Locator::role for role selector, got:\n{code}"
);
}
#[test]
fn ipc_assert_calls_emitted() {
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(),
mocked: false,
transport: None,
}),
}]);
let opts = CodegenOptions {
emit_ipc_assert_calls: true,
..CodegenOptions::default()
};
let code = generate_test(&session, &opts);
assert!(
code.contains("assert_ipc_called(&log, \"save_settings\");"),
"expected assert_ipc_called, got:\n{code}"
);
assert!(code.contains("use victauri_test::assert_ipc_called;"));
assert!(code.contains("let log = client.get_ipc_log(None).await.unwrap();"));
}
#[test]
fn selector_to_locator_mapping() {
assert_eq!(selector_to_locator("#btn"), "Locator::css(\"#btn\")");
assert_eq!(
selector_to_locator("button:has-text(\"OK\")"),
"Locator::text(\"OK\")"
);
assert_eq!(
selector_to_locator("[data-testid=\"foo\"]"),
"Locator::test_id(\"foo\")"
);
assert_eq!(
selector_to_locator("[role=\"navigation\"]"),
"Locator::role(\"navigation\")"
);
assert_eq!(
selector_to_locator(".my-class"),
"Locator::css(\".my-class\")"
);
}
}