Skip to content

Commit dccb350

Browse files
feat(text-injection): add integration test suite for orchestrator
Adds a new integration test suite for the `StrategyOrchestrator` in the `coldvox-text-injection` crate. This suite validates the orchestrator's core functionality in a realistic environment by: - Creating a test harness that compiles and runs a real GTK application. - Verifying successful text injection into the GTK application. - Includes placeholder tests for fallback logic and error handling, which are currently commented out due to the orchestrator's design preventing easy mocking. A C-based GTK test application is included in the `test-apps` directory to support these tests.
1 parent ccd2ca5 commit dccb350

File tree

3 files changed

+198
-12
lines changed

3 files changed

+198
-12
lines changed

crates/coldvox-text-injection/src/tests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Test modules for coldvox-text-injection
22
33
// pub mod real_injection;
4+
pub mod orchestrator_integration_test;
45
pub mod test_utils;
56
pub mod wl_copy_basic_test;
67
pub mod wl_copy_simple_test;
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
//! Integration tests for the StrategyOrchestrator
2+
3+
use crate::orchestrator::StrategyOrchestrator;
4+
use crate::types::{InjectionConfig, InjectionError, InjectionResult};
5+
use crate::{TextInjector, UnifiedClipboardInjector};
6+
use async_trait::async_trait;
7+
use std::fs;
8+
use std::path::PathBuf;
9+
use std::process::{Child, Command};
10+
use std::sync::atomic::{AtomicBool, Ordering};
11+
use std::sync::Arc;
12+
use std::time::Duration;
13+
use tempfile::{tempdir, TempDir};
14+
15+
// --- Test Harness: GTK App Manager ---
16+
17+
struct GtkTestApp {
18+
process: Child,
19+
output_file: PathBuf,
20+
_temp_dir: TempDir,
21+
}
22+
23+
impl GtkTestApp {
24+
fn new() -> Result<Self, String> {
25+
if (std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok())
26+
&& std::env::var("DISPLAY").is_err()
27+
&& std::env::var("WAYLAND_DISPLAY").is_err()
28+
{
29+
return Err("Skipping GUI test: no display in CI.".to_string());
30+
}
31+
32+
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
33+
let source_path = manifest_dir.join("test-apps").join("gtk_test_app.c");
34+
let temp_dir = tempdir().map_err(|e| e.to_string())?;
35+
let binary_path = temp_dir.path().join("gtk_test_app");
36+
let output_file = temp_dir.path().join("output.txt");
37+
38+
let pkg_config = Command::new("pkg-config")
39+
.args(&["--cflags", "--libs", "gtk+-3.0"])
40+
.output()
41+
.map_err(|e| format!("pkg-config failed: {}. Is libgtk-3-dev installed?", e))?;
42+
if !pkg_config.status.success() {
43+
return Err(format!("pkg-config failed: {}", String::from_utf8_lossy(&pkg_config.stderr)));
44+
}
45+
let flags = String::from_utf8(pkg_config.stdout).unwrap();
46+
47+
let compile = Command::new("gcc")
48+
.arg("-o").arg(&binary_path).arg(&source_path)
49+
.args(flags.split_whitespace())
50+
.output().map_err(|e| format!("gcc failed: {}", e))?;
51+
if !compile.status.success() {
52+
return Err(format!("Compilation failed: {}", String::from_utf8_lossy(&compile.stderr)));
53+
}
54+
55+
let mut process = Command::new(&binary_path)
56+
.arg(&output_file)
57+
.spawn()
58+
.map_err(|e| format!("Failed to spawn GTK app: {}", e))?;
59+
60+
std::thread::sleep(Duration::from_millis(500));
61+
if let Ok(Some(status)) = process.try_wait() {
62+
return Err(format!("GTK app exited prematurely with {}. A graphical session is required.", status));
63+
}
64+
65+
Ok(Self { process, output_file, _temp_dir: temp_dir })
66+
}
67+
68+
fn read_injected_text(&self) -> Result<String, String> {
69+
std::thread::sleep(Duration::from_millis(100));
70+
fs::read_to_string(&self.output_file).map_err(|e| format!("Failed to read output: {}", e))
71+
}
72+
}
73+
74+
impl Drop for GtkTestApp {
75+
fn drop(&mut self) {
76+
let _ = self.process.kill();
77+
let _ = self.process.wait();
78+
}
79+
}
80+
81+
// --- Mock Injector for Testing Fallbacks ---
82+
83+
struct MockInjector {
84+
should_succeed: bool,
85+
was_called: Arc<AtomicBool>,
86+
}
87+
88+
impl MockInjector {
89+
fn new(should_succeed: bool, was_called: Arc<AtomicBool>) -> Self {
90+
Self { should_succeed, was_called }
91+
}
92+
}
93+
94+
#[async_trait]
95+
impl TextInjector for MockInjector {
96+
fn backend_name(&self) -> &'static str { "mock" }
97+
async fn is_available(&self) -> bool { true }
98+
fn backend_info(&self) -> Vec<(&'static str, String)> { vec![] }
99+
async fn inject_text(&self, _: &str, _: Option<&crate::types::InjectionContext>) -> InjectionResult<()> {
100+
self.was_called.store(true, Ordering::SeqCst);
101+
if self.should_succeed {
102+
Ok(())
103+
} else {
104+
Err(InjectionError::MethodFailed("Mock injector failed as requested".to_string()))
105+
}
106+
}
107+
}
108+
109+
// Helper to skip tests in headless CI
110+
fn should_skip_gui_test() -> bool {
111+
(std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok())
112+
&& std::env::var("DISPLAY").is_err()
113+
&& std::env::var("WAYLAND_DISPLAY").is_err()
114+
}
115+
116+
// --- Integration Test Suite ---
117+
118+
#[tokio::test]
119+
async fn test_successful_injection() {
120+
if should_skip_gui_test() { return; }
121+
let app = GtkTestApp::new().expect("Test app failed to launch");
122+
let config = InjectionConfig::default();
123+
let orchestrator = StrategyOrchestrator::new(config).await;
124+
125+
let text_to_inject = "Hello, world!";
126+
let result = orchestrator.inject_text(text_to_inject).await;
127+
128+
assert!(result.is_ok(), "Injection failed: {:?}", result.err());
129+
assert_eq!(app.read_injected_text().unwrap_or_default(), text_to_inject);
130+
}
131+
132+
#[tokio::test]
133+
async fn test_fallback_injection() {
134+
if should_skip_gui_test() { return; }
135+
let app = GtkTestApp::new().expect("Test app failed to launch");
136+
let config = InjectionConfig::default();
137+
138+
let primary_called = Arc::new(AtomicBool::new(false));
139+
let primary_injector = MockInjector::new(false, primary_called.clone());
140+
141+
// In the orchestrator, atspi_injector is an Option<AtspiInjector>, not a trait object.
142+
// To mock it, we cannot simply assign a MockInjector.
143+
// This test needs a different approach, likely feature-flagging a mock at compile time
144+
// or refactoring the orchestrator to accept a generic injector.
145+
// For now, this test is fundamentally flawed and cannot be implemented as is.
146+
// I will comment it out and leave a note.
147+
148+
/*
149+
let fallback_injector = Arc::new(UnifiedClipboardInjector::new(config.clone()));
150+
151+
let mut orchestrator = StrategyOrchestrator::new(config).await;
152+
// orchestrator.atspi_injector = Some(primary_injector); // This line won't compile
153+
orchestrator.clipboard_fallback = Some(fallback_injector);
154+
155+
let text_to_inject = "Fallback injection works!";
156+
let result = orchestrator.inject_text(text_to_inject).await;
157+
158+
assert!(primary_called.load(Ordering::SeqCst), "Primary (failing) injector was not called");
159+
assert!(result.is_ok(), "Fallback injection failed: {:?}", result.err());
160+
assert_eq!(app.read_injected_text().unwrap_or_default(), text_to_inject);
161+
*/
162+
}
163+
164+
#[tokio::test]
165+
async fn test_all_methods_fail_error_handling() {
166+
let config = InjectionConfig::default();
167+
168+
let primary_called = Arc::new(AtomicBool::new(false));
169+
let primary_injector = MockInjector::new(false, primary_called.clone());
170+
171+
let fallback_called = Arc::new(AtomicBool::new(false));
172+
let fallback_injector = MockInjector::new(false, fallback_called.clone());
173+
174+
// Similar to the fallback test, the orchestrator's fields are concrete types.
175+
// Mocking them directly is not possible without refactoring.
176+
// This test is also commented out.
177+
178+
/*
179+
let mut orchestrator = StrategyOrchestrator::new(config).await;
180+
// orchestrator.atspi_injector = Some(primary_injector);
181+
// orchestrator.clipboard_fallback = Some(fallback_injector);
182+
183+
let result = orchestrator.inject_text("This should fail").await;
184+
185+
assert!(primary_called.load(Ordering::SeqCst), "Primary injector was not called");
186+
assert!(fallback_called.load(Ordering::SeqCst), "Fallback injector was not called");
187+
assert!(matches!(result, Err(InjectionError::AllMethodsFailed(_))));
188+
*/
189+
}

crates/coldvox-text-injection/test-apps/gtk_test_app.c

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,10 @@
66
// Callback function to handle text changes in the GtkEntry
77
static void on_text_changed(GtkEditable *editable, gpointer user_data) {
88
const gchar *text = gtk_entry_get_text(GTK_ENTRY(editable));
9-
char filepath[256];
10-
snprintf(filepath, sizeof(filepath), "/tmp/coldvox_gtk_test_%d.txt", getpid());
9+
char* filepath = (char*)user_data;
1110

1211
FILE *f = fopen(filepath, "w");
1312
if (f == NULL) {
14-
// In a real app, handle this error properly. For this test app, we'll just print.
1513
perror("Error opening file for writing");
1614
return;
1715
}
@@ -22,27 +20,25 @@ static void on_text_changed(GtkEditable *editable, gpointer user_data) {
2220
int main(int argc, char *argv[]) {
2321
gtk_init(&argc, &argv);
2422

25-
// Create the main window
23+
if (argc < 2) {
24+
fprintf(stderr, "Usage: %s <output_file_path>\\n", argv[0]);
25+
return 1;
26+
}
27+
char* output_filepath = argv[1];
28+
2629
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
2730
gtk_window_set_title(GTK_WINDOW(window), "GTK Test App");
2831
gtk_window_set_default_size(GTK_WINDOW(window), 200, 50);
2932
g_signal_connect(window, "destroy", G_CALLBACK(gtk_main_quit), NULL);
3033

31-
// Create a text entry widget
3234
GtkWidget *entry = gtk_entry_new();
3335
gtk_container_add(GTK_CONTAINER(window), entry);
3436

35-
// Connect the "changed" signal to our callback
36-
// The "changed" signal is emitted for every character change.
37-
g_signal_connect(G_OBJECT(entry), "changed", G_CALLBACK(on_text_changed), NULL);
37+
g_signal_connect(G_OBJECT(entry), "changed", G_CALLBACK(on_text_changed), output_filepath);
3838

39-
// Show all widgets
4039
gtk_widget_show_all(window);
41-
42-
// Ensure the entry widget has focus when the window appears
4340
gtk_widget_grab_focus(entry);
4441

45-
// Start the GTK main loop
4642
gtk_main();
4743

4844
return 0;

0 commit comments

Comments
 (0)