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 env_truthy("CODEX_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 env_truthy(name: &str) -> bool {
186 let value = std::env::var_os(name).map(|raw| raw.to_string_lossy().into_owned());
187 shared_env::is_truthy_or(value.as_deref().map(str::trim), false)
188}
189
190fn realtime_recording_enabled() -> bool {
191 env_truthy("CODEX_SCREEN_RECORD_TEST_MODE_REALTIME")
192}
193
194fn fail_recording_after_partial_write() -> bool {
195 env_truthy("CODEX_SCREEN_RECORD_TEST_MODE_FAIL_APPEND")
196}
197
198fn install_ctrlc_handler() -> Result<(), CliError> {
199 CTRL_C_INSTALLED
200 .get_or_init(|| {
201 ctrlc::set_handler(|| {
202 STOP_REQUESTED.store(true, Ordering::SeqCst);
203 })
204 .map_err(|err| CliError::runtime(format!("failed to set Ctrl-C handler: {err}")))?;
205 Ok(())
206 })
207 .clone()
208}
209
210fn wait_until_duration_or_stop(duration: u64) {
211 let deadline = Instant::now() + Duration::from_secs(duration);
212 while Instant::now() < deadline {
213 if STOP_REQUESTED.load(Ordering::SeqCst) {
214 break;
215 }
216 std::thread::sleep(Duration::from_millis(25));
217 }
218}
219
220fn fixture_path(format: ContainerFormat) -> PathBuf {
221 let filename = match format {
222 ContainerFormat::Mov => "sample.mov",
223 ContainerFormat::Mp4 => "sample.mp4",
224 };
225 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
226 .join("tests")
227 .join("fixtures")
228 .join(filename)
229}
230
231fn screenshot_fixture_path(format: ImageFormat) -> PathBuf {
232 let filename = match format {
233 ImageFormat::Png => "sample.png",
234 ImageFormat::Jpg => "sample.jpg",
235 ImageFormat::Webp => "sample.webp",
236 };
237 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
238 .join("tests")
239 .join("fixtures")
240 .join(filename)
241}
242
243#[cfg(test)]
244mod tests {
245 use super::enabled;
246 use nils_test_support::{EnvGuard, GlobalStateLock};
247
248 #[test]
249 fn enabled_returns_false_when_env_missing() {
250 let lock = GlobalStateLock::new();
251 let _guard = EnvGuard::remove(&lock, "CODEX_SCREEN_RECORD_TEST_MODE");
252 assert!(!enabled());
253 }
254
255 #[test]
256 fn enabled_accepts_expected_truthy_values() {
257 let lock = GlobalStateLock::new();
258 for value in ["1", "true", " yes ", "ON"] {
259 let _guard = EnvGuard::set(&lock, "CODEX_SCREEN_RECORD_TEST_MODE", value);
260 assert!(enabled(), "expected truthy value: {value}");
261 }
262 }
263
264 #[test]
265 fn enabled_rejects_falsey_and_unknown_values() {
266 let lock = GlobalStateLock::new();
267 for value in ["", "0", "false", "no", "off", "y", "enabled"] {
268 let _guard = EnvGuard::set(&lock, "CODEX_SCREEN_RECORD_TEST_MODE", value);
269 assert!(!enabled(), "expected falsey value: {value}");
270 }
271 }
272}