Skip to main content

screen_record/
test_mode.rs

1use std::path::{Path, PathBuf};
2use std::sync::OnceLock;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::time::{Duration, Instant};
5
6use crate::cli::{ContainerFormat, ImageFormat};
7use crate::error::CliError;
8use crate::types::{AppInfo, DisplayInfo, Rect, ShareableContent, WindowInfo};
9use nils_common::env as shared_env;
10
11static CTRL_C_INSTALLED: OnceLock<Result<(), CliError>> = OnceLock::new();
12static STOP_REQUESTED: AtomicBool = AtomicBool::new(false);
13
14pub fn enabled() -> bool {
15    shared_env::env_truthy("AGENTS_SCREEN_RECORD_TEST_MODE")
16}
17
18pub fn shareable_content() -> ShareableContent {
19    let displays = vec![DisplayInfo {
20        id: 1,
21        width: 1440,
22        height: 900,
23    }];
24
25    let windows = vec![
26        WindowInfo {
27            id: 100,
28            owner_name: "Terminal".to_string(),
29            title: "Inbox".to_string(),
30            bounds: Rect {
31                x: 0,
32                y: 0,
33                width: 1200,
34                height: 800,
35            },
36            on_screen: true,
37            active: true,
38            owner_pid: 111,
39            z_order: 0,
40        },
41        WindowInfo {
42            id: 101,
43            owner_name: "Terminal".to_string(),
44            title: "Docs".to_string(),
45            bounds: Rect {
46                x: 40,
47                y: 40,
48                width: 1100,
49                height: 760,
50            },
51            on_screen: true,
52            active: false,
53            owner_pid: 111,
54            z_order: 1,
55        },
56        WindowInfo {
57            id: 200,
58            owner_name: "Finder".to_string(),
59            title: "Finder".to_string(),
60            bounds: Rect {
61                x: 80,
62                y: 80,
63                width: 900,
64                height: 600,
65            },
66            on_screen: true,
67            active: false,
68            owner_pid: 222,
69            z_order: 2,
70        },
71    ];
72
73    let apps = vec![
74        AppInfo {
75            name: "Terminal".to_string(),
76            pid: 111,
77            bundle_id: "com.apple.Terminal".to_string(),
78        },
79        AppInfo {
80            name: "Finder".to_string(),
81            pid: 222,
82            bundle_id: "com.apple.Finder".to_string(),
83        },
84    ];
85
86    ShareableContent {
87        displays,
88        windows,
89        apps,
90    }
91}
92
93pub fn record_fixture(path: &Path, format: ContainerFormat) -> Result<(), CliError> {
94    let source = fixture_path(format);
95    if !source.exists() {
96        return Err(CliError::runtime(format!(
97            "fixture not found: {}",
98            source.display()
99        )));
100    }
101
102    if let Some(parent) = path.parent() {
103        std::fs::create_dir_all(parent)
104            .map_err(|err| CliError::runtime(format!("failed to create output dir: {err}")))?;
105    }
106
107    std::fs::copy(&source, path)
108        .map_err(|err| CliError::runtime(format!("failed to write output: {err}")))?;
109    Ok(())
110}
111
112pub fn record_fixture_for_duration(
113    duration: u64,
114    path: &Path,
115    format: ContainerFormat,
116) -> Result<(), CliError> {
117    if realtime_recording_enabled() {
118        install_ctrlc_handler()?;
119        STOP_REQUESTED.store(false, Ordering::SeqCst);
120        wait_until_duration_or_stop(duration);
121    }
122
123    if fail_recording_after_partial_write() {
124        if let Some(parent) = path.parent() {
125            std::fs::create_dir_all(parent)
126                .map_err(|err| CliError::runtime(format!("failed to create output dir: {err}")))?;
127        }
128        std::fs::write(path, b"")
129            .map_err(|err| CliError::runtime(format!("failed to write output: {err}")))?;
130        return Err(CliError::runtime(
131            "failed to append sample buffer: The operation could not be completed",
132        ));
133    }
134
135    record_fixture(path, format)
136}
137
138pub fn screenshot_fixture(path: &Path, format: ImageFormat) -> Result<(), CliError> {
139    let source = screenshot_fixture_path(format);
140    if !source.exists() {
141        return Err(CliError::runtime(format!(
142            "fixture not found: {}",
143            source.display()
144        )));
145    }
146
147    if let Some(parent) = path.parent() {
148        std::fs::create_dir_all(parent)
149            .map_err(|err| CliError::runtime(format!("failed to create output dir: {err}")))?;
150    }
151
152    std::fs::copy(&source, path)
153        .map_err(|err| CliError::runtime(format!("failed to write output: {err}")))?;
154    Ok(())
155}
156
157pub struct TestWriter {
158    path: PathBuf,
159    format: ContainerFormat,
160    appended: bool,
161}
162
163impl TestWriter {
164    pub fn new(path: &Path, format: ContainerFormat) -> Self {
165        Self {
166            path: path.to_path_buf(),
167            format,
168            appended: false,
169        }
170    }
171
172    pub fn append_frame(&mut self, _data: &[u8]) -> Result<(), CliError> {
173        self.appended = true;
174        Ok(())
175    }
176
177    pub fn finish(self) -> Result<(), CliError> {
178        if !self.appended {
179            return Err(CliError::runtime("no frames appended"));
180        }
181        record_fixture(&self.path, self.format)
182    }
183}
184
185fn realtime_recording_enabled() -> bool {
186    shared_env::env_truthy("AGENTS_SCREEN_RECORD_TEST_MODE_REALTIME")
187}
188
189fn fail_recording_after_partial_write() -> bool {
190    shared_env::env_truthy("AGENTS_SCREEN_RECORD_TEST_MODE_FAIL_APPEND")
191}
192
193fn install_ctrlc_handler() -> Result<(), CliError> {
194    CTRL_C_INSTALLED
195        .get_or_init(|| {
196            ctrlc::set_handler(|| {
197                STOP_REQUESTED.store(true, Ordering::SeqCst);
198            })
199            .map_err(|err| CliError::runtime(format!("failed to set Ctrl-C handler: {err}")))?;
200            Ok(())
201        })
202        .clone()
203}
204
205fn wait_until_duration_or_stop(duration: u64) {
206    let deadline = Instant::now() + Duration::from_secs(duration);
207    while Instant::now() < deadline {
208        if STOP_REQUESTED.load(Ordering::SeqCst) {
209            break;
210        }
211        std::thread::sleep(Duration::from_millis(25));
212    }
213}
214
215fn fixture_path(format: ContainerFormat) -> PathBuf {
216    let filename = match format {
217        ContainerFormat::Mov => "sample.mov",
218        ContainerFormat::Mp4 => "sample.mp4",
219    };
220    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
221        .join("tests")
222        .join("fixtures")
223        .join(filename)
224}
225
226fn screenshot_fixture_path(format: ImageFormat) -> PathBuf {
227    let filename = match format {
228        ImageFormat::Png => "sample.png",
229        ImageFormat::Jpg => "sample.jpg",
230        ImageFormat::Webp => "sample.webp",
231    };
232    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
233        .join("tests")
234        .join("fixtures")
235        .join(filename)
236}
237
238#[cfg(test)]
239mod tests {
240    use super::enabled;
241    use nils_test_support::{EnvGuard, GlobalStateLock};
242
243    #[test]
244    fn enabled_returns_false_when_env_missing() {
245        let lock = GlobalStateLock::new();
246        let _guard = EnvGuard::remove(&lock, "AGENTS_SCREEN_RECORD_TEST_MODE");
247        assert!(!enabled());
248    }
249
250    #[test]
251    fn enabled_accepts_expected_truthy_values() {
252        let lock = GlobalStateLock::new();
253        for value in ["1", "true", " yes ", "ON"] {
254            let _guard = EnvGuard::set(&lock, "AGENTS_SCREEN_RECORD_TEST_MODE", value);
255            assert!(enabled(), "expected truthy value: {value}");
256        }
257    }
258
259    #[test]
260    fn enabled_rejects_falsey_and_unknown_values() {
261        let lock = GlobalStateLock::new();
262        for value in ["", "0", "false", "no", "off", "y", "enabled"] {
263            let _guard = EnvGuard::set(&lock, "AGENTS_SCREEN_RECORD_TEST_MODE", value);
264            assert!(!enabled(), "expected falsey value: {value}");
265        }
266    }
267}