Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/integration/driver.rs
1use super::{
2 action_log::{self, ActionLog, ACTION_LOG_KEY},
3 artifacts::{self, TestArtifacts, ARTIFACTS_KEY},
4 overlay::{OverlayLog, OVERLAY_LOG_KEY},
5 step::{run_step, AssertionOutcome, StepDataMap, TestStep},
6 video_recorder::{self, VideoRecorder, VIDEO_RECORDER_KEY},
7 RootDir, TestSetupUtils,
8};
9 
10const RUNTIME_TAG_FAILED_STEP_GROUP_NAME: &str = "failed_step_group_name";
11const RUNTIME_TAG_FAILED_ASSERTION_NAME: &str = "failed_assertion_name";
12pub const RUNTIME_TAG_FAILURE_REASON: &str = "failure_reason";
13 
14#[cfg(feature = "integration_tests")]
15use crate::r#async::Timer;
16use crate::{
17 integration::step::PersistedDataMap, platform::TerminationMode, r#async::FutureExt as _, App,
18 WindowId,
19};
20use futures::{Future, FutureExt};
21use instant::{Duration, Instant};
22#[cfg(not(target_family = "wasm"))]
23use std::sync::atomic::Ordering;
24use std::{
25 backtrace::BacktraceStatus,
26 collections::VecDeque,
27 panic::AssertUnwindSafe,
28 path::PathBuf,
29 pin::Pin,
30 sync::{atomic::AtomicBool, Arc},
31};
32 
33pub type SetupFn = Box<dyn FnMut(&mut TestSetupUtils) + 'static>;
34pub type OnFinishFn = Box<
35 dyn FnMut(&mut App, WindowId, &mut PersistedDataMap) -> Pin<Box<dyn Future<Output = ()> + Send>>
36 + 'static,
37>;
38 
39pub struct Builder {
40 use_real_display: bool,
41 steps: VecDeque<TestStep>,
42 should_run_test: Box<dyn FnMut() -> bool>,
43 setup: Option<SetupFn>,
44 cleanup: Box<dyn FnMut(&mut TestSetupUtils) + 'static>,
45 /// The callback to run before the app quits (on success, failure, or cancel).
46 /// Note that this cannot run if the app panics, so make sure your assertions don't panic if you rely on this.
47 /// Also, this function relies on the presence of an active window after the test steps have finished.
48 on_finish: Option<OnFinishFn>,
49 timeout: Option<Duration>,
50 root_dir: RootDirKind,
51 step_group_name_to_apply_to_new_steps: Option<String>,
52 static_persisted_data: PersistedDataMap,
53}
54 
55impl Builder {
56 pub fn new(work_dir: PathBuf) -> Self {
57 let mut persisted_data = PersistedDataMap::default();
58 persisted_data.insert("platform".to_string(), std::env::consts::OS.to_string());
59 persisted_data.insert(
60 "architecture".to_string(),
61 std::env::consts::ARCH.to_string(),
62 );
63 Self {
64 use_real_display: false,
65 steps: Default::default(),
66 should_run_test: Box::new(|| true),
67 setup: None,
68 cleanup: Box::new(|_| {}),
69 on_finish: None,
70 timeout: None,
71 root_dir: RootDirKind::Named { work_dir },
72 step_group_name_to_apply_to_new_steps: None,
73 static_persisted_data: persisted_data,
74 }
75 }
76 
77 pub fn set_should_run_test<P>(mut self, predicate: P) -> Self
78 where
79 P: FnMut() -> bool + 'static,
80 {
81 self.should_run_test = Box::new(predicate);
82 self
83 }
84 
85 pub fn with_real_display(mut self) -> Self {
86 self.use_real_display = true;
87 self
88 }
89 
90 /// Applies to every TestStep added after this call in the builder, unless
91 /// TestStep already has Some step_group_name.
92 pub fn with_step_group_name(mut self, step_group_name: &str) -> Self {
93 self.step_group_name_to_apply_to_new_steps = Some(step_group_name.to_string());
94 self
95 }
96 
97 pub fn with_step(mut self, mut step: TestStep) -> Self {
98 if step.step_group_name.is_none() {
99 step.step_group_name = self.step_group_name_to_apply_to_new_steps.clone();
100 }
101 self.steps.push_back(step);
102 self
103 }
104 
105 pub fn with_steps(mut self, mut steps: Vec<TestStep>) -> Self {
106 for step in &mut steps {
107 if step.step_group_name.is_none() {
108 step.step_group_name = self.step_group_name_to_apply_to_new_steps.clone();
109 }
110 }
111 self.steps.extend(steps);
112 self
113 }
114 
115 pub fn with_setup<C>(mut self, callback: C) -> Self
116 where
117 C: FnMut(&mut TestSetupUtils) + 'static,
118 {
119 assert!(
120 self.setup.is_none(),
121 "Can only register a single callback using with_setup!"
122 );
123 self.setup = Some(Box::new(callback));
124 self
125 }
126 
127 pub fn with_static_persisted_data(mut self, data: PersistedDataMap) -> Self {
128 self.static_persisted_data.extend(data);
129 self
130 }
131 
132 pub fn with_cleanup<C>(mut self, callback: C) -> Self
133 where
134 C: FnMut(&mut TestSetupUtils) + 'static,
135 {
136 self.cleanup = Box::new(callback);
137 self
138 }
139 
140 pub fn with_on_finish<C>(mut self, callback: C) -> Self
141 where
142 C: FnMut(
143 &mut App,
144 WindowId,
145 &mut PersistedDataMap,
146 ) -> Pin<Box<dyn Future<Output = ()> + Send>>
147 + 'static,
148 {
149 self.on_finish = Some(Box::new(callback));
150 self
151 }
152 
153 pub fn with_timeout(mut self, timeout: Duration) -> Self {
154 self.timeout = Some(timeout);
155 self
156 }
157 
158 /// Configures the test to run with its root directory under the /tmp
159 /// directory instead of under CARGO_TARGET_TMPDIR.
160 pub fn use_tmp_filesystem_for_test_root_directory(mut self) -> Self {
161 self.root_dir = RootDirKind::TemporaryDirectory;
162 self
163 }
164 
165 pub fn build(self, test_name: &str, create_temp_dir_for_test: bool) -> TestDriver {
166 let test_setup = TestSetupUtils::new(self.root_dir.into_root(test_name));
167 
168 let mut driver = TestDriver {
169 steps: self.steps,
170 test_name: test_name.to_string(),
171 test_setup,
172 should_run_test: self.should_run_test,
173 setup: self.setup.unwrap_or_else(|| Box::new(|_| {})),
174 cleanup: self.cleanup,
175 on_finish: self.on_finish,
176 timeout: self.timeout,
177 persisted_data: self.static_persisted_data,
178 };
179 
180 driver.setup(create_temp_dir_for_test);
181 
182 driver
183 }
184}
185 
186/// Configuration for the test's root directory. The home directory and user data directory
187/// are set based on this.
188pub enum RootDirKind {
189 /// Create a directory named after the test, under the temporary working directory.
190 Named { work_dir: PathBuf },
191 /// Create a new, anonymous temporary directory for this test.
192 TemporaryDirectory,
193}
194 
195impl RootDirKind {
196 fn into_root(self, test_name: &str) -> RootDir {
197 match self {
198 RootDirKind::Named { mut work_dir } => {
199 work_dir.push(test_name);
200 RootDir::Path(work_dir)
201 }
202 RootDirKind::TemporaryDirectory => RootDir::TempDir(
203 tempfile::tempdir().expect("should not fail to create temporary directory"),
204 ),
205 }
206 }
207}
208 
209/// The TestDriver records a series of test steps and executes them against the app
210/// when run_test is called.
211pub struct TestDriver {
212 steps: VecDeque<TestStep>,
213 test_name: String,
214 test_setup: TestSetupUtils,
215 should_run_test: Box<dyn FnMut() -> bool>,
216 setup: Box<dyn FnMut(&mut TestSetupUtils) + 'static>,
217 cleanup: Box<dyn FnMut(&mut TestSetupUtils) + 'static>,
218 /// The callback to run before the app quits (on success, failure, or cancel).
219 /// Note that this cannot run if the app panics, so make sure your assertions don't panic if you rely on this.
220 /// Also, this function relies on the presence of an active window after the test steps have finished.
221 on_finish: Option<OnFinishFn>,
222 timeout: Option<Duration>,
223 persisted_data: PersistedDataMap,
224}
225 
226pub const RERUN_EXIT_CODE: i32 = 127;
227 
228/// The result of running a single integration test step. This does not include results that panic
229/// or exit the driver process (failures and cancellations).
230enum StepResult {
231 Success,
232 PreconditionFailed,
233}
234 
235impl TestDriver {
236 /// Executes the test steps, performing assertions against application state,
237 /// and then cleans up test-only state as necessary.
238 ///
239 /// In integration tests, this task is automatically spawned on the foreground
240 /// executor after initializing the application.
241 pub async fn run_test_and_cleanup(mut self, mut app: App) {
242 if !(self.should_run_test)() {
243 log::info!("Skipping test ...");
244 app.as_mut()
245 .terminate_app(TerminationMode::ForceTerminate, None);
246 return;
247 }
248 
249 // Safety: We can use `AssertUnwindSafe` here because we aren't accessing any captured data
250 // and are immediately terminating the app after a panic
251 let test_result = AssertUnwindSafe(self.run_steps_and_determine_rerun(&mut app))
252 .catch_unwind()
253 .await;
254 
255 if test_result
256 .as_ref()
257 .is_ok_and(|attempt_rerun| !attempt_rerun)
258 {
259 let window_id = app.read(|ctx| {
260 let windowing_state = ctx.windows();
261 windowing_state.active_window()
262 });
263 if let Some(window_id) = window_id {
264 self.run_on_finish_and_export_tags(&mut app, window_id)
265 .await;
266 }
267 }
268 
269 // Make sure we perform any necessary cleanup steps in case we end up
270 // calling `std::process::exit()`, which doesn't `Drop` things.
271 self.cleanup();
272 
273 match test_result {
274 Ok(should_rerun) => {
275 if should_rerun {
276 std::process::exit(RERUN_EXIT_CODE);
277 } else {
278 app.as_mut()
279 .terminate_app(TerminationMode::ForceTerminate, None);
280 }
281 }
282 Err(panic_data) => {
283 match get_panic_message(&panic_data) {
284 Some(message) => eprintln!("\n{message}\n"),
285 None => eprintln!("\nTest failed (No additional information available)\n"),
286 }
287 // Exit with a non-zero status so that the test function knows that we failed
288 std::process::exit(1);
289 }
290 }
291 }
292 
293 async fn run_steps_and_determine_rerun(&mut self, app: &mut App) -> bool {
294 let steps: Vec<TestStep> = self.steps.drain(..).collect();
295 log::info!("Spawning integration test with {} steps", steps.len());
296 
297 // Set up Ctrl+C handler to ensure on_finish runs
298 let sigint_received = Arc::new(AtomicBool::new(false));
299 #[cfg(not(target_family = "wasm"))]
300 let sigint_received_clone = sigint_received.clone();
301 
302 #[cfg(not(target_family = "wasm"))]
303 ctrlc::set_handler(move || {
304 log::info!("Received Ctrl+C in test driver");
305 sigint_received_clone.store(true, Ordering::Relaxed);
306 })
307 .expect("Error setting Ctrl-C handler");
308 
309 // If the test was configured with a timeout, spawn a thread to kill
310 // the test when the timeout is reached.
311 //
312 // We do this with a dedicated, detached thread to ensure that no deadlocks or
313 // other issues that can tie up a thread prevent this logic from running.
314 if let Some(timeout) = self.timeout {
315 let _ = std::thread::Builder::new()
316 .name("test-timeout-watchdog".to_string())
317 .spawn(move || {
318 std::thread::sleep(timeout);
319 log::warn!(
320 "Test reached timeout after {}s; terminating...",
321 timeout.as_secs()
322 );
323 std::process::exit(2);
324 });
325 }
326 
327 let mut step_data_map = StepDataMap::default();
328 self.configure_capture_recording(app, &mut step_data_map);
329 
330 for mut step in steps {
331 match self
332 .run_single_step_with_retries(&mut step, app, &mut step_data_map, &sigint_received)
333 .await
334 {
335 StepResult::Success => {
336 self.handle_post_step_capture(app, &mut step_data_map).await;
337 }
338 StepResult::PreconditionFailed => {
339 self.finalize_recording(&mut step_data_map).await;
340 return true;
341 }
342 }
343 }
344 
345 self.finalize_recording(&mut step_data_map).await;
346 false
347 }
348 fn configure_capture_recording(&self, app: &mut App, step_data_map: &mut StepDataMap) {
349 #[cfg(not(feature = "integration_tests"))]
350 let _ = app;
351 let test_artifacts = TestArtifacts::new(&self.test_name);
352 log::info!(
353 "Test artifacts directory: {}",
354 test_artifacts.dir().display()
355 );
356 step_data_map.insert(ARTIFACTS_KEY, test_artifacts);
357 step_data_map.insert(VIDEO_RECORDER_KEY, VideoRecorder::new());
358 step_data_map.insert(ACTION_LOG_KEY, ActionLog::new());
359 step_data_map.insert(OVERLAY_LOG_KEY, OverlayLog::new());
360 
361 #[cfg(feature = "integration_tests")]
362 {
363 let capture_state = video_recorder::get_recorder(step_data_map)
364 .map(|recorder| recorder.capture_loop_state());
365 if let Some(state) = capture_state {
366 let app_clone = app.clone();
367 app.foreground_executor()
368 .spawn(video_recorder::run_capture_loop(app_clone, state))
369 .detach();
370 }
371 
372 if let Some(scale) = app.read(|ctx| {
373 let windows = ctx.windows();
374 windows
375 .active_window()
376 .and_then(|id| windows.platform_window(id))
377 .map(|window| window.as_ctx().backing_scale_factor())
378 }) {
379 if let Some(overlay_log) = super::overlay::get_overlay_log_mut(step_data_map) {
380 overlay_log.set_scale_factor(scale);
381 }
382 }
383 }
384 
385 if video_recording_enabled_for_test(&self.test_name) {
386 if let Some(recorder) = video_recorder::get_recorder_mut(step_data_map) {
387 recorder.start_recording();
388 log::info!(
389 "VideoRecorder: auto-started recording for '{}' via {}",
390 self.test_name,
391 video_recorder::VIDEO_ENABLED_ENV_VAR
392 );
393 }
394 if let Some(log) = action_log::get_action_log_mut(step_data_map) {
395 log.set_recording_start(Instant::now());
396 log.record("Recording started (auto via env var)");
397 }
398 }
399 }
400 
401 async fn handle_post_step_capture(&self, app: &mut App, step_data_map: &mut StepDataMap) {
402 let screenshot_filename: Option<String> = step_data_map
403 .get::<_, String>(video_recorder::SCREENSHOT_PATH_KEY)
404 .cloned();
405 let needs_capture = screenshot_filename
406 .as_ref()
407 .is_some_and(|filename| !filename.is_empty());
408 
409 if !needs_capture {
410 return;
411 }
412 
413 let window = match app.read(|ctx| {
414 let windowing_state = ctx.windows();
415 let wid = windowing_state.active_window();
416 wid.and_then(|id| windowing_state.platform_window(id))
417 }) {
418 Some(window) => window,
419 None => return,
420 };
421 
422 let (tx, rx) = futures::channel::oneshot::channel();
423 
424 window
425 .as_ctx()
426 .request_frame_capture(Box::new(move |frame| {
427 let _ = tx.send(frame);
428 }));
429 window.as_ctx().request_redraw();
430 let frame = match rx.with_timeout(Duration::from_secs(5)).await {
431 Ok(Ok(frame)) => frame,
432 _ => {
433 log::warn!("VideoRecorder: frame capture timed out after step");
434 return;
435 }
436 };
437 
438 if let Some(filename) = screenshot_filename.filter(|filename| !filename.is_empty()) {
439 let path = artifacts::get_artifacts(step_data_map)
440 .map(|artifacts| artifacts.path(&filename))
441 .unwrap_or_else(|| PathBuf::from(&filename));
442 if let Err(e) = video_recorder::save_captured_frame_as_png(&frame, &path) {
443 log::error!(
444 "VideoRecorder: failed to save screenshot to {}: {e}",
445 path.display()
446 );
447 } else {
448 log::info!("VideoRecorder: screenshot saved to {}", path.display());
449 }
450 step_data_map.insert(video_recorder::SCREENSHOT_PATH_KEY, String::new());
451 }
452 }
453 
454 async fn finalize_recording(&self, step_data_map: &mut StepDataMap) {
455 #[cfg(feature = "integration_tests")]
456 {
457 if let Some(recorder) = video_recorder::get_recorder(step_data_map) {
458 recorder.stop_capture_loop();
459 }
460 Timer::at(Instant::now() + std::time::Duration::from_millis(50)).await;
461 }
462 
463 let artifacts_dir =
464 artifacts::get_artifacts(step_data_map).map(|artifacts| artifacts.dir().to_path_buf());
465 
466 let overlay_log: Option<OverlayLog> =
467 step_data_map.remove::<_, OverlayLog>(OVERLAY_LOG_KEY);
468 if let Some(recorder) = video_recorder::get_recorder_mut(step_data_map) {
469 recorder.stop_recording();
470 if recorder.frame_count() > 0 {
471 if let Some(ref dir) = artifacts_dir {
472 let output = dir.join("recording.mp4");
473 if let Err(e) = recorder.finalize(&output, overlay_log.as_ref()) {
474 log::error!("VideoRecorder: finalization failed: {e}");
475 }
476 }
477 }
478 }
479 
480 if let Some(ref dir) = artifacts_dir {
481 let log_output = dir.join("recording.log");
482 if let Some(action_log) = action_log::get_action_log(step_data_map) {
483 if let Err(e) = action_log.write_to_file(&log_output) {
484 log::error!("ActionLog: finalization failed: {e}");
485 }
486 }
487 }
488 }
489 pub(crate) fn setup(&mut self, create_temp_dir_for_test: bool) {
490 if create_temp_dir_for_test {
491 self.test_setup.create_temp_dir_for_test();
492 self.test_setup.set_home_dir_for_test();
493 }
494 (self.setup)(&mut self.test_setup);
495 }
496 
497 pub(crate) fn cleanup(&mut self) {
498 self.test_setup.cleanup_env();
499 self.test_setup.cleanup_dir();
500 (self.cleanup)(&mut self.test_setup);
501 }
502 
503 fn export_runtime_tags(&self) {
504 if let Ok(output_file) = std::env::var("RUNTIME_TAGS_OUTPUT_FILE") {
505 match serde_json::to_string_pretty(&self.persisted_data) {
506 Ok(json_content) => match std::fs::write(&output_file, json_content) {
507 Ok(_) => log::info!("Runtime tags exported to: {output_file}"),
508 Err(e) => {
509 log::error!("Failed to write runtime tags to file {output_file}: {e}")
510 }
511 },
512 Err(e) => log::error!("Failed to serialize runtime tags to JSON: {e}"),
513 }
514 } else {
515 log::debug!("RUNTIME_TAGS_OUTPUT_FILE environment variable not set, skipping runtime tags export");
516 }
517 }
518 
519 /// Runs the on_finish callback and exports runtime tags. This should be called
520 /// everywhere on_finish is invoked to ensure runtime tags are always exported.
521 async fn run_on_finish_and_export_tags(&mut self, app: &mut App, window_id: WindowId) {
522 if let Some(ref mut on_finish) = self.on_finish {
523 let future = (on_finish)(app, window_id, &mut self.persisted_data);
524 future.await;
525 }
526 self.export_runtime_tags();
527 }
528 
529 /// Run a single step of an integration test.
530 ///
531 /// If the step has retries configured, it will be attempted up to `retries + 1` times:
532 /// * [`AssertionOutcome::Success`], [`AssertionOutcome::SuccessWithData`] succeed immediately
533 /// * [`AssertionOutcome::PreconditionFailed`] ends the entire test
534 /// * [`AssertionOutcome::Failure`] and [`AssertionOutcome::ImmediateFailure`] may be retried
535 ///
536 /// If the step succeeds or fails preconditions, this returns a [`StepResult`]. If it fails,
537 /// this panics with a failure message.
538 ///
539 /// If the test is canceled, this exits the process immediately.
540 async fn run_single_step_with_retries(
541 &mut self,
542 step: &mut TestStep,
543 app: &mut App,
544 step_data_map: &mut StepDataMap,
545 sigint_received: &AtomicBool,
546 ) -> StepResult {
547 let (window_id, window) = app.read(|ctx| {
548 let windowing_state = ctx.windows();
549 let window_id = windowing_state
550 .active_window()
551 .expect("should be an active window in integration tests");
552 let window = windowing_state
553 .platform_window(window_id)
554 .expect("should be a platform window");
555 (window_id, window)
556 });
557 
558 // Retry logic for the step
559 let mut retry_attempt = 0;
560 let max_attempts = step.retries() + 1; // +1 for the original attempt
561 
562 'retries: loop {
563 retry_attempt += 1;
564 
565 if step.retries() > 0 {
566 log::info!(
567 "Running test step '{}' on window id {:?} (attempt {}/{})",
568 step.name(),
569 window_id,
570 retry_attempt,
571 max_attempts
572 );
573 } else {
574 log::info!(
575 "Running test step '{}' on window id {:?}",
576 step.name(),
577 window_id
578 );
579 }
580 
581 if let Some(log) = action_log::get_action_log_mut(step_data_map) {
582 log.record(format!("Step started: {}", step.name()));
583 }
584 
585 match run_step(
586 step,
587 app,
588 window_id,
589 window.as_ref(),
590 step_data_map,
591 &mut self.test_setup,
592 sigint_received,
593 )
594 .await
595 {
596 AssertionOutcome::Success | AssertionOutcome::SuccessWithData(_) => {
597 if retry_attempt > 1 {
598 log::info!(
599 "Test step '{}' succeeded after {} attempts.",
600 step.name(),
601 retry_attempt
602 );
603 } else {
604 log::info!("Test step '{}' succeeded.", step.name());
605 }
606 if let Some(log) = action_log::get_action_log_mut(step_data_map) {
607 log.record(format!("Step succeeded: {}", step.name()));
608 }
609 return StepResult::Success;
610 }
611 AssertionOutcome::Failure {
612 message,
613 backtrace,
614 failed_assertion_name,
615 } => {
616 if retry_attempt < max_attempts {
617 log::warn!(
618 "Test step '{}' failed (attempt {}/{}): {}. Retrying...",
619 step.name(),
620 retry_attempt,
621 max_attempts,
622 message
623 );
624 continue 'retries; // Retry the step
625 } else {
626 // All retries exhausted, fail the test
627 let backtrace_message = match backtrace.status() {
628 BacktraceStatus::Captured => format!("{backtrace}"),
629 _ => "(Backtrace disabled; run with `RUST_BACKTRACE=1` environment variable to display a backtrace)".into(),
630 };
631 let step_group_name =
632 step.step_group_name.as_deref().unwrap_or("Unspecified");
633 self.persisted_data.insert(
634 RUNTIME_TAG_FAILED_STEP_GROUP_NAME.to_owned(),
635 step_group_name.to_owned(),
636 );
637 let failed_assertion_name =
638 failed_assertion_name.unwrap_or("Unspecified".to_owned());
639 self.persisted_data.insert(
640 RUNTIME_TAG_FAILED_ASSERTION_NAME.to_owned(),
641 failed_assertion_name,
642 );
643 self.persisted_data
644 .insert(RUNTIME_TAG_FAILURE_REASON.to_owned(), message.clone());
645 self.run_on_finish_and_export_tags(app, window_id).await;
646 panic!(
647 "Test step '{}' failed after {} attempts: {message}\nFailed in step group: {step_group_name}\n{backtrace_message}",
648 step.name(),
649 max_attempts,
650 );
651 }
652 }
653 AssertionOutcome::ImmediateFailure {
654 message,
655 backtrace,
656 failed_assertion_name,
657 } => {
658 if retry_attempt < max_attempts {
659 log::warn!(
660 "Test step '{}' failed (attempt {}/{}): {}. Retrying...",
661 step.name(),
662 retry_attempt,
663 max_attempts,
664 message
665 );
666 continue 'retries; // Retry the step
667 } else {
668 // All retries exhausted, fail the test
669 let backtrace_message = match backtrace.status() {
670 BacktraceStatus::Captured => format!("{backtrace}"),
671 _ => "(Backtrace disabled; run with `RUST_BACKTRACE=1` environment variable to display a backtrace)".into(),
672 };
673 let step_group_name =
674 step.step_group_name.as_deref().unwrap_or("Unspecified");
675 self.persisted_data.insert(
676 RUNTIME_TAG_FAILED_STEP_GROUP_NAME.to_owned(),
677 step_group_name.to_owned(),
678 );
679 let failed_assertion_name =
680 failed_assertion_name.unwrap_or("Unspecified".to_owned());
681 self.persisted_data.insert(
682 RUNTIME_TAG_FAILED_ASSERTION_NAME.to_owned(),
683 failed_assertion_name,
684 );
685 self.persisted_data
686 .insert(RUNTIME_TAG_FAILURE_REASON.to_owned(), message.clone());
687 self.run_on_finish_and_export_tags(app, window_id).await;
688 panic!(
689 "Test step '{}' failed after {} attempts: {message}\nFailed in step group: {step_group_name}\n{backtrace_message}",
690 step.name(),
691 max_attempts,
692 );
693 }
694 }
695 AssertionOutcome::Canceled => {
696 // Early exit on cancellation.
697 log::info!("Test step '{}' canceled, running on_finish...", step.name());
698 self.run_on_finish_and_export_tags(app, window_id).await;
699 log::info!("on_finish complete, exiting");
700 std::process::exit(0);
701 }
702 AssertionOutcome::PreconditionFailed(msg) => {
703 // End the test, but don't fail it.
704 log::warn!(
705 "Test step '{}' precondition failed because of '{}' - attempting a re-run.",
706 step.name(),
707 msg
708 );
709 return StepResult::PreconditionFailed;
710 }
711 }
712 }
713 }
714}
715 
716/// Returns whether video recording should be enabled for the given test name.
717///
718/// Checks the `WARP_INTEGRATION_TEST_VIDEO` environment variable:
719/// - Unset or empty → recording disabled.
720/// - `"1"` or `"all"` → recording enabled for every test.
721/// - Any other value → treated as a comma-separated list of test names;
722/// recording is enabled only when `test_name` appears in the list.
723///
724/// Example:
725/// ```sh
726/// # Record all tests
727/// WARP_INTEGRATION_TEST_VIDEO=1 cargo nextest run ...
728///
729/// # Record only specific tests
730/// WARP_INTEGRATION_TEST_VIDEO=test_foo,test_bar cargo nextest run ...
731/// ```
732fn video_recording_enabled_for_test(test_name: &str) -> bool {
733 let Ok(value) = std::env::var(video_recorder::VIDEO_ENABLED_ENV_VAR) else {
734 return false;
735 };
736 let value = value.trim();
737 if value.is_empty() {
738 return false;
739 }
740 if value == "1" || value == "all" {
741 return true;
742 }
743 value.split(',').any(|name| name.trim() == test_name)
744}
745 
746/// Given a value retrieved from catching an unwinding panic, returns
747/// the panic message, if one is available.
748fn get_panic_message(panic: &Box<dyn std::any::Any + Send>) -> Option<&str> {
749 panic
750 // If a panic or assert is invoked in a way that includes a format
751 // string and arguments, the panic data will be an owned string.
752 .downcast_ref::<String>()
753 .map(String::as_str)
754 // Otherwise, it might be a static string reference (if there are no values
755 // that need to be interpolated at runtime).
756 .or_else(|| {
757 panic
758 .downcast_ref::<&'static str>()
759 .map(std::ops::Deref::deref)
760 })
761}
762