Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
@@ -0,0 +1,126 @@
use crate::Event;
use instant::Instant;
use std::{io::Write, path::Path, time::Duration};
/// Well-known key used to store the `ActionLog` inside `StepDataMap`.
pub const ACTION_LOG_KEY: &str = "action_log";
/// A single event recorded in the action log.
pub struct ActionEntry {
/// Wall-clock instant this entry was recorded.
recorded_at: Instant,
/// Human-readable description of the event.
description: String,
}
/// Returns a concise, human-readable description of an event for the action log.
pub fn event_description(event: &Event) -> String {
match event {
Event::KeyDown { chars, .. } => format!("KeyDown '{chars}'"),
Event::TypedCharacters { chars } => format!("TypedCharacters '{chars}'"),
Event::LeftMouseDown { .. } => "LeftMouseDown".to_string(),
Event::LeftMouseUp { .. } => "LeftMouseUp".to_string(),
Event::LeftMouseDragged { .. } => "LeftMouseDragged".to_string(),
Event::RightMouseDown { .. } => "RightMouseDown".to_string(),
Event::MiddleMouseDown { .. } => "MiddleMouseDown".to_string(),
Event::MouseMoved { .. } => "MouseMoved".to_string(),
Event::ScrollWheel { .. } => "ScrollWheel".to_string(),
Event::ModifierStateChanged { .. } => "ModifierStateChanged".to_string(),
Event::ModifierKeyChanged { .. } => "ModifierKeyChanged".to_string(),
Event::DragAndDropFiles { .. } => "DragAndDropFiles".to_string(),
Event::DragFiles { .. } => "DragFiles".to_string(),
Event::DragFileExit => "DragFileExit".to_string(),
Event::SetMarkedText { .. } => "SetMarkedText".to_string(),
Event::ClearMarkedText => "ClearMarkedText".to_string(),
Event::ForwardMouseDown { .. } => "ForwardMouseDown".to_string(),
Event::BackMouseDown { .. } => "BackMouseDown".to_string(),
}
}
/// Accumulates timestamped test events during an integration test run.
///
/// When recording is active, `write_to_file` renders each entry with its
/// offset into the recording (e.g. `[+00:03.142]`). If recording was never
/// started the offset is computed relative to the first entry instead so
/// the log is still useful.
#[derive(Default)]
pub struct ActionLog {
entries: Vec<ActionEntry>,
recording_start: Option<Instant>,
}
impl ActionLog {
pub fn new() -> Self {
Self::default()
}
/// Marks the instant at which recording started. All log entries
/// will be displayed with offsets relative to this instant.
pub fn set_recording_start(&mut self, start: Instant) {
self.recording_start = Some(start);
}
/// Appends an entry with the current wall-clock time.
pub fn record(&mut self, description: impl Into<String>) {
self.entries.push(ActionEntry {
recorded_at: Instant::now(),
description: description.into(),
});
}
/// Writes the action log to a plain-text file.
///
/// Each line has the form:
/// ```text
/// [+MM:SS.mmm] description
/// ```
/// The offset is relative to `recording_start` (or to the first entry's
/// timestamp if recording was never explicitly started).
pub fn write_to_file(&self, path: &Path) -> anyhow::Result<()> {
if self.entries.is_empty() {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let base = self.recording_start.unwrap_or(self.entries[0].recorded_at);
let file = std::fs::File::create(path)?;
let mut writer = std::io::BufWriter::new(file);
for entry in &self.entries {
let offset = entry
.recorded_at
.checked_duration_since(base)
.unwrap_or(Duration::ZERO);
let total_secs = offset.as_secs();
let minutes = total_secs / 60;
let seconds = total_secs % 60;
let millis = offset.subsec_millis();
writeln!(
writer,
"[+{minutes:02}:{seconds:02}.{millis:03}] {}",
entry.description
)?;
}
log::info!(
"ActionLog: wrote {} entries to {}",
self.entries.len(),
path.display()
);
Ok(())
}
}
/// Helper to retrieve a mutable reference to the log from a `StepDataMap`.
pub fn get_action_log_mut(step_data_map: &mut super::step::StepDataMap) -> Option<&mut ActionLog> {
step_data_map.get_mut::<_, ActionLog>(ACTION_LOG_KEY)
}
/// Helper to retrieve a shared reference to the log from a `StepDataMap`.
pub fn get_action_log(step_data_map: &super::step::StepDataMap) -> Option<&ActionLog> {
step_data_map.get::<_, ActionLog>(ACTION_LOG_KEY)
}
@@ -0,0 +1,34 @@
use std::path::{Path, PathBuf};
pub const ARTIFACTS_KEY: &str = "test_artifacts";
pub const ARTIFACTS_DIR_ENV_VAR: &str = "WARP_INTEGRATION_TEST_ARTIFACTS_DIR";
pub struct TestArtifacts {
dir: PathBuf,
}
impl TestArtifacts {
pub fn new(test_name: &str) -> Self {
let root = std::env::var(ARTIFACTS_DIR_ENV_VAR)
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("warp_integration_test_artifacts"));
let timestamp = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S").to_string();
let dir = root.join(test_name).join(timestamp);
std::fs::create_dir_all(&dir).ok();
Self { dir }
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn path(&self, filename: &str) -> PathBuf {
self.dir.join(filename)
}
}
pub fn get_artifacts(step_data_map: &super::step::StepDataMap) -> Option<&TestArtifacts> {
step_data_map.get::<_, TestArtifacts>(ARTIFACTS_KEY)
}
@@ -0,0 +1,488 @@
use crate::platform::CapturedFrame;
use image::ImageEncoder;
use instant::Instant;
use std::{
path::Path,
sync::{Arc, Mutex},
};
/// The lifecycle state of the capture recorder / capture loop.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RecorderState {
Idle,
Recording,
Stopping,
}
/// Well-known key used to store the `CaptureRecorder` inside `StepDataMap`.
pub const CAPTURE_RECORDER_KEY: &str = "capture_recorder";
/// Well-known key prefix for screenshot path requests stored in `StepDataMap`.
pub const SCREENSHOT_PATH_KEY: &str = "pending_screenshot_path";
/// Environment variable that enables automatic video recording for all
/// integration test steps. When set, the driver starts recording at the
/// beginning of the test and writes the video on completion.
pub const CAPTURE_RECORDING_ENABLED_ENV_VAR: &str = "WARP_INTEGRATION_TEST_VIDEO";
/// A captured frame paired with the wall-clock time it was taken.
struct TimestampedFrame {
frame: CapturedFrame,
#[allow(dead_code)]
captured_at: Instant,
}
/// Mutable state shared between `CaptureRecorder` and `CaptureLoopState`.
/// All access is serialised through a single `Mutex`.
#[allow(dead_code)]
struct SharedState {
recorder_state: RecorderState,
raw_frames: Vec<TimestampedFrame>,
h264_buf: Vec<u8>,
encoded_frame_count: u32,
dimensions: Option<(u32, u32)>,
encoding_in_progress: bool,
}
impl Default for SharedState {
fn default() -> Self {
Self {
recorder_state: RecorderState::Idle,
raw_frames: Vec::new(),
h264_buf: Vec::new(),
encoded_frame_count: 0,
dimensions: None,
encoding_in_progress: false,
}
}
}
/// Shared handle passed to the capture loop task.
#[allow(dead_code)]
pub struct CaptureLoopState(Arc<Mutex<SharedState>>);
/// Records captured frames during integration tests and can produce
/// individual PNGs or an encoded capture artifact.
pub struct CaptureRecorder {
inner: Arc<Mutex<SharedState>>,
recording_start: Option<Instant>,
}
impl Default for CaptureRecorder {
fn default() -> Self {
Self {
inner: Arc::new(Mutex::new(SharedState::default())),
recording_start: None,
}
}
}
impl CaptureRecorder {
pub fn new() -> Self {
Self::default()
}
pub fn start_recording(&mut self) {
self.recording_start = Some(Instant::now());
if let Ok(mut s) = self.inner.lock() {
s.recorder_state = RecorderState::Recording;
}
}
pub fn stop_recording(&mut self) {
if let Ok(mut s) = self.inner.lock() {
if s.recorder_state == RecorderState::Recording {
s.recorder_state = RecorderState::Idle;
}
}
}
/// Signals the capture loop to exit on its next iteration.
pub fn stop_capture_loop(&self) {
if let Ok(mut s) = self.inner.lock() {
s.recorder_state = RecorderState::Stopping;
}
}
pub fn is_recording(&self) -> bool {
self.inner
.lock()
.map(|s| s.recorder_state == RecorderState::Recording)
.unwrap_or(false)
}
pub fn recording_start(&self) -> Option<Instant> {
self.recording_start
}
pub fn capture_loop_state(&self) -> CaptureLoopState {
CaptureLoopState(self.inner.clone())
}
pub fn raw_frame_count(&self) -> usize {
self.inner.lock().map(|s| s.raw_frames.len()).unwrap_or(0)
}
pub fn is_encoding(&self) -> bool {
self.inner
.lock()
.map(|s| s.encoding_in_progress)
.unwrap_or(false)
}
pub fn frame_count(&self) -> usize {
self.inner
.lock()
.map(|s| s.encoded_frame_count as usize)
.unwrap_or(0)
}
}
// ---------------------------------------------------------------------------
// Feature-gated recording implementation.
//
// When `integration_tests` is enabled we have access to the `openh264` and
// `minimp4` crates and can encode captured frames to H.264 / MP4.
// Otherwise we provide a no-op capture loop and a PNG-based fallback for
// `finalize`.
// ---------------------------------------------------------------------------
cfg_if::cfg_if! {
if #[cfg(feature = "integration_tests")] {
impl CaptureRecorder {
pub fn finalize(&mut self, output_path: &Path) -> anyhow::Result<()> {
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
let (h264_data, total, dims) = {
let mut s = self.inner.lock().map_err(|e| anyhow::anyhow!("{e}"))?;
(
std::mem::take(&mut s.h264_buf),
s.encoded_frame_count,
s.dimensions,
)
};
if h264_data.is_empty() || dims.is_none() {
log::info!("CaptureRecorder: no frames encoded, nothing to finalize");
return Ok(());
}
let (width, height) = dims.expect("dimensions set when h264_data is non-empty");
match mux_h264_to_mp4(output_path, &h264_data, width, height) {
Ok(()) => {
log::info!(
"CaptureRecorder: wrote {total} frames to {}",
output_path.display()
);
}
Err(e) => {
log::warn!("CaptureRecorder: MP4 muxing failed ({e})");
return Err(e);
}
}
Ok(())
}
}
pub async fn run_capture_loop(app: crate::App, state: CaptureLoopState) {
use crate::r#async::Timer;
use std::time::Duration;
const CAPTURE_INTERVAL_MS: u64 = 66;
const FLUSH_THRESHOLD: usize = 60;
const KEEP_RECENT: usize = 15;
let inner = &state.0;
loop {
Timer::after(Duration::from_millis(CAPTURE_INTERVAL_MS)).await;
let (current_state, encoding, backlog) = {
let s = inner.lock().unwrap_or_else(|e| e.into_inner());
(s.recorder_state, s.encoding_in_progress, s.raw_frames.len())
};
let should_stop = current_state == RecorderState::Stopping;
if should_stop && encoding {
Timer::after(Duration::from_millis(50)).await;
continue;
}
let should_flush =
(backlog >= FLUSH_THRESHOLD || (should_stop && backlog > 0)) && !encoding;
if should_flush {
let drain_count = if should_stop {
backlog
} else {
backlog.saturating_sub(KEEP_RECENT)
};
let to_encode: Vec<TimestampedFrame> = {
let mut s = inner.lock().unwrap_or_else(|e| e.into_inner());
s.raw_frames.drain(..drain_count).collect()
};
if !to_encode.is_empty() {
{
let mut s = inner.lock().unwrap_or_else(|e| e.into_inner());
s.encoding_in_progress = true;
}
let inner_clone = inner.clone();
std::thread::Builder::new()
.name("video-encoder".to_string())
.spawn(move || {
encode_frame_batch(&to_encode, &inner_clone);
})
.ok();
}
}
let still_encoding = inner
.lock()
.map(|s| s.encoding_in_progress)
.unwrap_or(false);
if should_stop && !still_encoding {
break;
}
if current_state != RecorderState::Recording {
continue;
}
let window = app.read(|ctx| {
let windowing_state = ctx.windows();
windowing_state
.active_window()
.and_then(|id| windowing_state.platform_window(id))
});
let Some(window) = window else {
continue;
};
let inner_clone = inner.clone();
window
.as_ctx()
.request_frame_capture(Box::new(move |frame| {
let captured_at = Instant::now();
if let Ok(mut s) = inner_clone.lock() {
s.raw_frames.push(TimestampedFrame { frame, captured_at });
}
}));
}
}
fn mux_h264_to_mp4(
output_path: &Path,
h264_data: &[u8],
width: u32,
height: u32,
) -> anyhow::Result<()> {
use minimp4::Mp4Muxer;
use std::io::Cursor;
const TARGET_FPS: u32 = 15;
let mut mp4_buf = Cursor::new(Vec::new());
let mut muxer = Mp4Muxer::new(&mut mp4_buf);
muxer.init_video(
width as i32,
height as i32,
false,
"integration test recording",
);
muxer.write_video_with_fps(h264_data, TARGET_FPS);
muxer.close();
std::fs::write(output_path, mp4_buf.into_inner())?;
Ok(())
}
/// Encodes a batch of raw frames to H.264 on the calling (background)
/// thread. Writes results back into `SharedState` under the lock.
fn encode_frame_batch(
frames: &[TimestampedFrame],
inner: &Arc<Mutex<SharedState>>,
) {
use openh264::encoder::Encoder;
use openh264::formats::{RgbSliceU8, YUVBuffer};
const FRAME_DURATION_MS: u128 = 1000 / 15;
let mut encoder = match Encoder::new() {
Ok(e) => e,
Err(e) => {
log::error!("CaptureRecorder: failed to create encoder on background thread: {e}");
if let Ok(mut s) = inner.lock() {
s.encoding_in_progress = false;
}
return;
}
};
if let Some(first) = frames.first() {
let mut s = inner.lock().unwrap_or_else(|e| e.into_inner());
if s.dimensions.is_none() {
s.dimensions = Some((first.frame.width, first.frame.height));
}
}
let mut prev_captured_at: Option<Instant> = None;
let mut batch_h264 = Vec::new();
let mut batch_encoded = 0u32;
for ts_frame in frames {
let width = ts_frame.frame.width;
let height = ts_frame.frame.height;
let rgb_data = pixel_data_to_rgb(&ts_frame.frame.data, ts_frame.frame.format);
let rgb_source = RgbSliceU8::new(&rgb_data, (width as usize, height as usize));
let yuv = YUVBuffer::from_rgb_source(rgb_source);
let repeat_count = if let Some(prev) = prev_captured_at {
let gap_ms = ts_frame.captured_at.duration_since(prev).as_millis();
(gap_ms / FRAME_DURATION_MS).max(1) as u32
} else {
1
};
prev_captured_at = Some(ts_frame.captured_at);
for _ in 0..repeat_count {
match encoder.encode(&yuv) {
Ok(bitstream) => {
bitstream.write_vec(&mut batch_h264);
batch_encoded += 1;
}
Err(e) => {
log::error!("CaptureRecorder: encode error: {e}");
break;
}
}
}
}
{
let mut s = inner.lock().unwrap_or_else(|e| e.into_inner());
if !batch_h264.is_empty() {
s.h264_buf.extend_from_slice(&batch_h264);
}
s.encoded_frame_count += batch_encoded;
s.encoding_in_progress = false;
}
log::info!(
"CaptureRecorder: background-encoded {batch_encoded} H.264 frames from {} raw frames",
frames.len()
);
}
fn pixel_data_to_rgb(data: &[u8], format: crate::platform::CapturedFrameFormat) -> Vec<u8> {
use crate::platform::CapturedFrameFormat;
let pixel_count = data.len() / 4;
let mut rgb = Vec::with_capacity(pixel_count * 3);
for chunk in data.chunks_exact(4) {
match format {
CapturedFrameFormat::Rgba => {
rgb.push(chunk[0]);
rgb.push(chunk[1]);
rgb.push(chunk[2]);
}
CapturedFrameFormat::Bgra => {
rgb.push(chunk[2]);
rgb.push(chunk[1]);
rgb.push(chunk[0]);
}
}
}
rgb
}
} else {
impl CaptureRecorder {
pub fn finalize(&mut self, output_path: &Path) -> anyhow::Result<()> {
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
let frames: Vec<TimestampedFrame> = self
.inner
.lock()
.map(|mut s| std::mem::take(&mut s.raw_frames))
.unwrap_or_default();
if frames.is_empty() {
log::info!("CaptureRecorder: no frames captured, nothing to finalize");
return Ok(());
}
save_frames_as_pngs(output_path, &frames)?;
Ok(())
}
}
pub async fn run_capture_loop(_app: crate::App, _state: CaptureLoopState) {}
fn save_frames_as_pngs(output_path: &Path, frames: &[TimestampedFrame]) -> anyhow::Result<()> {
let stem = output_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("frame");
let dir = output_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(format!("{stem}_frames"));
std::fs::create_dir_all(&dir)?;
for (i, ts_frame) in frames.iter().enumerate() {
let path = dir.join(format!("{stem}_{i:04}.png"));
save_captured_frame_as_png(&ts_frame.frame, &path)?;
}
log::info!(
"CaptureRecorder: saved {} PNGs to {}",
frames.len(),
dir.display()
);
Ok(())
}
}
}
/// Saves a single `CapturedFrame` to a PNG file at the given path.
pub fn save_captured_frame_as_png(frame: &CapturedFrame, path: &Path) -> anyhow::Result<()> {
let mut frame = frame.clone();
frame.ensure_rgba();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(path)?;
let mut writer = std::io::BufWriter::new(file);
let encoder = image::codecs::png::PngEncoder::new_with_quality(
&mut writer,
image::codecs::png::CompressionType::Fast,
image::codecs::png::FilterType::NoFilter,
);
encoder.write_image(
&frame.data,
frame.width,
frame.height,
image::ColorType::Rgba8.into(),
)?;
Ok(())
}
/// Helper to retrieve a mutable reference to the recorder from a `StepDataMap`.
pub fn get_capture_recorder_mut(
step_data_map: &mut super::step::StepDataMap,
) -> Option<&mut CaptureRecorder> {
step_data_map.get_mut::<_, CaptureRecorder>(CAPTURE_RECORDER_KEY)
}
/// Helper to retrieve a shared reference to the recorder from a `StepDataMap`.
pub fn get_capture_recorder(step_data_map: &super::step::StepDataMap) -> Option<&CaptureRecorder> {
step_data_map.get::<_, CaptureRecorder>(CAPTURE_RECORDER_KEY)
}
@@ -0,0 +1,761 @@
use super::{
action_log::{self, ActionLog, ACTION_LOG_KEY},
artifacts::{self, TestArtifacts, ARTIFACTS_KEY},
overlay::{OverlayLog, OVERLAY_LOG_KEY},
step::{run_step, AssertionOutcome, StepDataMap, TestStep},
video_recorder::{self, VideoRecorder, VIDEO_RECORDER_KEY},
RootDir, TestSetupUtils,
};
const RUNTIME_TAG_FAILED_STEP_GROUP_NAME: &str = "failed_step_group_name";
const RUNTIME_TAG_FAILED_ASSERTION_NAME: &str = "failed_assertion_name";
pub const RUNTIME_TAG_FAILURE_REASON: &str = "failure_reason";
#[cfg(feature = "integration_tests")]
use crate::r#async::Timer;
use crate::{
integration::step::PersistedDataMap, platform::TerminationMode, r#async::FutureExt as _, App,
WindowId,
};
use futures::{Future, FutureExt};
use instant::{Duration, Instant};
#[cfg(not(target_family = "wasm"))]
use std::sync::atomic::Ordering;
use std::{
backtrace::BacktraceStatus,
collections::VecDeque,
panic::AssertUnwindSafe,
path::PathBuf,
pin::Pin,
sync::{atomic::AtomicBool, Arc},
};
pub type SetupFn = Box<dyn FnMut(&mut TestSetupUtils) + 'static>;
pub type OnFinishFn = Box<
dyn FnMut(&mut App, WindowId, &mut PersistedDataMap) -> Pin<Box<dyn Future<Output = ()> + Send>>
+ 'static,
>;
pub struct Builder {
use_real_display: bool,
steps: VecDeque<TestStep>,
should_run_test: Box<dyn FnMut() -> bool>,
setup: Option<SetupFn>,
cleanup: Box<dyn FnMut(&mut TestSetupUtils) + 'static>,
/// The callback to run before the app quits (on success, failure, or cancel).
/// Note that this cannot run if the app panics, so make sure your assertions don't panic if you rely on this.
/// Also, this function relies on the presence of an active window after the test steps have finished.
on_finish: Option<OnFinishFn>,
timeout: Option<Duration>,
root_dir: RootDirKind,
step_group_name_to_apply_to_new_steps: Option<String>,
static_persisted_data: PersistedDataMap,
}
impl Builder {
pub fn new(work_dir: PathBuf) -> Self {
let mut persisted_data = PersistedDataMap::default();
persisted_data.insert("platform".to_string(), std::env::consts::OS.to_string());
persisted_data.insert(
"architecture".to_string(),
std::env::consts::ARCH.to_string(),
);
Self {
use_real_display: false,
steps: Default::default(),
should_run_test: Box::new(|| true),
setup: None,
cleanup: Box::new(|_| {}),
on_finish: None,
timeout: None,
root_dir: RootDirKind::Named { work_dir },
step_group_name_to_apply_to_new_steps: None,
static_persisted_data: persisted_data,
}
}
pub fn set_should_run_test<P>(mut self, predicate: P) -> Self
where
P: FnMut() -> bool + 'static,
{
self.should_run_test = Box::new(predicate);
self
}
pub fn with_real_display(mut self) -> Self {
self.use_real_display = true;
self
}
/// Applies to every TestStep added after this call in the builder, unless
/// TestStep already has Some step_group_name.
pub fn with_step_group_name(mut self, step_group_name: &str) -> Self {
self.step_group_name_to_apply_to_new_steps = Some(step_group_name.to_string());
self
}
pub fn with_step(mut self, mut step: TestStep) -> Self {
if step.step_group_name.is_none() {
step.step_group_name = self.step_group_name_to_apply_to_new_steps.clone();
}
self.steps.push_back(step);
self
}
pub fn with_steps(mut self, mut steps: Vec<TestStep>) -> Self {
for step in &mut steps {
if step.step_group_name.is_none() {
step.step_group_name = self.step_group_name_to_apply_to_new_steps.clone();
}
}
self.steps.extend(steps);
self
}
pub fn with_setup<C>(mut self, callback: C) -> Self
where
C: FnMut(&mut TestSetupUtils) + 'static,
{
assert!(
self.setup.is_none(),
"Can only register a single callback using with_setup!"
);
self.setup = Some(Box::new(callback));
self
}
pub fn with_static_persisted_data(mut self, data: PersistedDataMap) -> Self {
self.static_persisted_data.extend(data);
self
}
pub fn with_cleanup<C>(mut self, callback: C) -> Self
where
C: FnMut(&mut TestSetupUtils) + 'static,
{
self.cleanup = Box::new(callback);
self
}
pub fn with_on_finish<C>(mut self, callback: C) -> Self
where
C: FnMut(
&mut App,
WindowId,
&mut PersistedDataMap,
) -> Pin<Box<dyn Future<Output = ()> + Send>>
+ 'static,
{
self.on_finish = Some(Box::new(callback));
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
/// Configures the test to run with its root directory under the /tmp
/// directory instead of under CARGO_TARGET_TMPDIR.
pub fn use_tmp_filesystem_for_test_root_directory(mut self) -> Self {
self.root_dir = RootDirKind::TemporaryDirectory;
self
}
pub fn build(self, test_name: &str, create_temp_dir_for_test: bool) -> TestDriver {
let test_setup = TestSetupUtils::new(self.root_dir.into_root(test_name));
let mut driver = TestDriver {
steps: self.steps,
test_name: test_name.to_string(),
test_setup,
should_run_test: self.should_run_test,
setup: self.setup.unwrap_or_else(|| Box::new(|_| {})),
cleanup: self.cleanup,
on_finish: self.on_finish,
timeout: self.timeout,
persisted_data: self.static_persisted_data,
};
driver.setup(create_temp_dir_for_test);
driver
}
}
/// Configuration for the test's root directory. The home directory and user data directory
/// are set based on this.
pub enum RootDirKind {
/// Create a directory named after the test, under the temporary working directory.
Named { work_dir: PathBuf },
/// Create a new, anonymous temporary directory for this test.
TemporaryDirectory,
}
impl RootDirKind {
fn into_root(self, test_name: &str) -> RootDir {
match self {
RootDirKind::Named { mut work_dir } => {
work_dir.push(test_name);
RootDir::Path(work_dir)
}
RootDirKind::TemporaryDirectory => RootDir::TempDir(
tempfile::tempdir().expect("should not fail to create temporary directory"),
),
}
}
}
/// The TestDriver records a series of test steps and executes them against the app
/// when run_test is called.
pub struct TestDriver {
steps: VecDeque<TestStep>,
test_name: String,
test_setup: TestSetupUtils,
should_run_test: Box<dyn FnMut() -> bool>,
setup: Box<dyn FnMut(&mut TestSetupUtils) + 'static>,
cleanup: Box<dyn FnMut(&mut TestSetupUtils) + 'static>,
/// The callback to run before the app quits (on success, failure, or cancel).
/// Note that this cannot run if the app panics, so make sure your assertions don't panic if you rely on this.
/// Also, this function relies on the presence of an active window after the test steps have finished.
on_finish: Option<OnFinishFn>,
timeout: Option<Duration>,
persisted_data: PersistedDataMap,
}
pub const RERUN_EXIT_CODE: i32 = 127;
/// The result of running a single integration test step. This does not include results that panic
/// or exit the driver process (failures and cancellations).
enum StepResult {
Success,
PreconditionFailed,
}
impl TestDriver {
/// Executes the test steps, performing assertions against application state,
/// and then cleans up test-only state as necessary.
///
/// In integration tests, this task is automatically spawned on the foreground
/// executor after initializing the application.
pub async fn run_test_and_cleanup(mut self, mut app: App) {
if !(self.should_run_test)() {
log::info!("Skipping test ...");
app.as_mut()
.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
// Safety: We can use `AssertUnwindSafe` here because we aren't accessing any captured data
// and are immediately terminating the app after a panic
let test_result = AssertUnwindSafe(self.run_steps_and_determine_rerun(&mut app))
.catch_unwind()
.await;
if test_result
.as_ref()
.is_ok_and(|attempt_rerun| !attempt_rerun)
{
let window_id = app.read(|ctx| {
let windowing_state = ctx.windows();
windowing_state.active_window()
});
if let Some(window_id) = window_id {
self.run_on_finish_and_export_tags(&mut app, window_id)
.await;
}
}
// Make sure we perform any necessary cleanup steps in case we end up
// calling `std::process::exit()`, which doesn't `Drop` things.
self.cleanup();
match test_result {
Ok(should_rerun) => {
if should_rerun {
std::process::exit(RERUN_EXIT_CODE);
} else {
app.as_mut()
.terminate_app(TerminationMode::ForceTerminate, None);
}
}
Err(panic_data) => {
match get_panic_message(&panic_data) {
Some(message) => eprintln!("\n{message}\n"),
None => eprintln!("\nTest failed (No additional information available)\n"),
}
// Exit with a non-zero status so that the test function knows that we failed
std::process::exit(1);
}
}
}
async fn run_steps_and_determine_rerun(&mut self, app: &mut App) -> bool {
let steps: Vec<TestStep> = self.steps.drain(..).collect();
log::info!("Spawning integration test with {} steps", steps.len());
// Set up Ctrl+C handler to ensure on_finish runs
let sigint_received = Arc::new(AtomicBool::new(false));
#[cfg(not(target_family = "wasm"))]
let sigint_received_clone = sigint_received.clone();
#[cfg(not(target_family = "wasm"))]
ctrlc::set_handler(move || {
log::info!("Received Ctrl+C in test driver");
sigint_received_clone.store(true, Ordering::Relaxed);
})
.expect("Error setting Ctrl-C handler");
// If the test was configured with a timeout, spawn a thread to kill
// the test when the timeout is reached.
//
// We do this with a dedicated, detached thread to ensure that no deadlocks or
// other issues that can tie up a thread prevent this logic from running.
if let Some(timeout) = self.timeout {
let _ = std::thread::Builder::new()
.name("test-timeout-watchdog".to_string())
.spawn(move || {
std::thread::sleep(timeout);
log::warn!(
"Test reached timeout after {}s; terminating...",
timeout.as_secs()
);
std::process::exit(2);
});
}
let mut step_data_map = StepDataMap::default();
self.configure_capture_recording(app, &mut step_data_map);
for mut step in steps {
match self
.run_single_step_with_retries(&mut step, app, &mut step_data_map, &sigint_received)
.await
{
StepResult::Success => {
self.handle_post_step_capture(app, &mut step_data_map).await;
}
StepResult::PreconditionFailed => {
self.finalize_recording(&mut step_data_map).await;
return true;
}
}
}
self.finalize_recording(&mut step_data_map).await;
false
}
fn configure_capture_recording(&self, app: &mut App, step_data_map: &mut StepDataMap) {
#[cfg(not(feature = "integration_tests"))]
let _ = app;
let test_artifacts = TestArtifacts::new(&self.test_name);
log::info!(
"Test artifacts directory: {}",
test_artifacts.dir().display()
);
step_data_map.insert(ARTIFACTS_KEY, test_artifacts);
step_data_map.insert(VIDEO_RECORDER_KEY, VideoRecorder::new());
step_data_map.insert(ACTION_LOG_KEY, ActionLog::new());
step_data_map.insert(OVERLAY_LOG_KEY, OverlayLog::new());
#[cfg(feature = "integration_tests")]
{
let capture_state = video_recorder::get_recorder(step_data_map)
.map(|recorder| recorder.capture_loop_state());
if let Some(state) = capture_state {
let app_clone = app.clone();
app.foreground_executor()
.spawn(video_recorder::run_capture_loop(app_clone, state))
.detach();
}
if let Some(scale) = app.read(|ctx| {
let windows = ctx.windows();
windows
.active_window()
.and_then(|id| windows.platform_window(id))
.map(|window| window.as_ctx().backing_scale_factor())
}) {
if let Some(overlay_log) = super::overlay::get_overlay_log_mut(step_data_map) {
overlay_log.set_scale_factor(scale);
}
}
}
if video_recording_enabled_for_test(&self.test_name) {
if let Some(recorder) = video_recorder::get_recorder_mut(step_data_map) {
recorder.start_recording();
log::info!(
"VideoRecorder: auto-started recording for '{}' via {}",
self.test_name,
video_recorder::VIDEO_ENABLED_ENV_VAR
);
}
if let Some(log) = action_log::get_action_log_mut(step_data_map) {
log.set_recording_start(Instant::now());
log.record("Recording started (auto via env var)");
}
}
}
async fn handle_post_step_capture(&self, app: &mut App, step_data_map: &mut StepDataMap) {
let screenshot_filename: Option<String> = step_data_map
.get::<_, String>(video_recorder::SCREENSHOT_PATH_KEY)
.cloned();
let needs_capture = screenshot_filename
.as_ref()
.is_some_and(|filename| !filename.is_empty());
if !needs_capture {
return;
}
let window = match app.read(|ctx| {
let windowing_state = ctx.windows();
let wid = windowing_state.active_window();
wid.and_then(|id| windowing_state.platform_window(id))
}) {
Some(window) => window,
None => return,
};
let (tx, rx) = futures::channel::oneshot::channel();
window
.as_ctx()
.request_frame_capture(Box::new(move |frame| {
let _ = tx.send(frame);
}));
window.as_ctx().request_redraw();
let frame = match rx.with_timeout(Duration::from_secs(5)).await {
Ok(Ok(frame)) => frame,
_ => {
log::warn!("VideoRecorder: frame capture timed out after step");
return;
}
};
if let Some(filename) = screenshot_filename.filter(|filename| !filename.is_empty()) {
let path = artifacts::get_artifacts(step_data_map)
.map(|artifacts| artifacts.path(&filename))
.unwrap_or_else(|| PathBuf::from(&filename));
if let Err(e) = video_recorder::save_captured_frame_as_png(&frame, &path) {
log::error!(
"VideoRecorder: failed to save screenshot to {}: {e}",
path.display()
);
} else {
log::info!("VideoRecorder: screenshot saved to {}", path.display());
}
step_data_map.insert(video_recorder::SCREENSHOT_PATH_KEY, String::new());
}
}
async fn finalize_recording(&self, step_data_map: &mut StepDataMap) {
#[cfg(feature = "integration_tests")]
{
if let Some(recorder) = video_recorder::get_recorder(step_data_map) {
recorder.stop_capture_loop();
}
Timer::at(Instant::now() + std::time::Duration::from_millis(50)).await;
}
let artifacts_dir =
artifacts::get_artifacts(step_data_map).map(|artifacts| artifacts.dir().to_path_buf());
let overlay_log: Option<OverlayLog> =
step_data_map.remove::<_, OverlayLog>(OVERLAY_LOG_KEY);
if let Some(recorder) = video_recorder::get_recorder_mut(step_data_map) {
recorder.stop_recording();
if recorder.frame_count() > 0 {
if let Some(ref dir) = artifacts_dir {
let output = dir.join("recording.mp4");
if let Err(e) = recorder.finalize(&output, overlay_log.as_ref()) {
log::error!("VideoRecorder: finalization failed: {e}");
}
}
}
}
if let Some(ref dir) = artifacts_dir {
let log_output = dir.join("recording.log");
if let Some(action_log) = action_log::get_action_log(step_data_map) {
if let Err(e) = action_log.write_to_file(&log_output) {
log::error!("ActionLog: finalization failed: {e}");
}
}
}
}
pub(crate) fn setup(&mut self, create_temp_dir_for_test: bool) {
if create_temp_dir_for_test {
self.test_setup.create_temp_dir_for_test();
self.test_setup.set_home_dir_for_test();
}
(self.setup)(&mut self.test_setup);
}
pub(crate) fn cleanup(&mut self) {
self.test_setup.cleanup_env();
self.test_setup.cleanup_dir();
(self.cleanup)(&mut self.test_setup);
}
fn export_runtime_tags(&self) {
if let Ok(output_file) = std::env::var("RUNTIME_TAGS_OUTPUT_FILE") {
match serde_json::to_string_pretty(&self.persisted_data) {
Ok(json_content) => match std::fs::write(&output_file, json_content) {
Ok(_) => log::info!("Runtime tags exported to: {output_file}"),
Err(e) => {
log::error!("Failed to write runtime tags to file {output_file}: {e}")
}
},
Err(e) => log::error!("Failed to serialize runtime tags to JSON: {e}"),
}
} else {
log::debug!("RUNTIME_TAGS_OUTPUT_FILE environment variable not set, skipping runtime tags export");
}
}
/// Runs the on_finish callback and exports runtime tags. This should be called
/// everywhere on_finish is invoked to ensure runtime tags are always exported.
async fn run_on_finish_and_export_tags(&mut self, app: &mut App, window_id: WindowId) {
if let Some(ref mut on_finish) = self.on_finish {
let future = (on_finish)(app, window_id, &mut self.persisted_data);
future.await;
}
self.export_runtime_tags();
}
/// Run a single step of an integration test.
///
/// If the step has retries configured, it will be attempted up to `retries + 1` times:
/// * [`AssertionOutcome::Success`], [`AssertionOutcome::SuccessWithData`] succeed immediately
/// * [`AssertionOutcome::PreconditionFailed`] ends the entire test
/// * [`AssertionOutcome::Failure`] and [`AssertionOutcome::ImmediateFailure`] may be retried
///
/// If the step succeeds or fails preconditions, this returns a [`StepResult`]. If it fails,
/// this panics with a failure message.
///
/// If the test is canceled, this exits the process immediately.
async fn run_single_step_with_retries(
&mut self,
step: &mut TestStep,
app: &mut App,
step_data_map: &mut StepDataMap,
sigint_received: &AtomicBool,
) -> StepResult {
let (window_id, window) = app.read(|ctx| {
let windowing_state = ctx.windows();
let window_id = windowing_state
.active_window()
.expect("should be an active window in integration tests");
let window = windowing_state
.platform_window(window_id)
.expect("should be a platform window");
(window_id, window)
});
// Retry logic for the step
let mut retry_attempt = 0;
let max_attempts = step.retries() + 1; // +1 for the original attempt
'retries: loop {
retry_attempt += 1;
if step.retries() > 0 {
log::info!(
"Running test step '{}' on window id {:?} (attempt {}/{})",
step.name(),
window_id,
retry_attempt,
max_attempts
);
} else {
log::info!(
"Running test step '{}' on window id {:?}",
step.name(),
window_id
);
}
if let Some(log) = action_log::get_action_log_mut(step_data_map) {
log.record(format!("Step started: {}", step.name()));
}
match run_step(
step,
app,
window_id,
window.as_ref(),
step_data_map,
&mut self.test_setup,
sigint_received,
)
.await
{
AssertionOutcome::Success | AssertionOutcome::SuccessWithData(_) => {
if retry_attempt > 1 {
log::info!(
"Test step '{}' succeeded after {} attempts.",
step.name(),
retry_attempt
);
} else {
log::info!("Test step '{}' succeeded.", step.name());
}
if let Some(log) = action_log::get_action_log_mut(step_data_map) {
log.record(format!("Step succeeded: {}", step.name()));
}
return StepResult::Success;
}
AssertionOutcome::Failure {
message,
backtrace,
failed_assertion_name,
} => {
if retry_attempt < max_attempts {
log::warn!(
"Test step '{}' failed (attempt {}/{}): {}. Retrying...",
step.name(),
retry_attempt,
max_attempts,
message
);
continue 'retries; // Retry the step
} else {
// All retries exhausted, fail the test
let backtrace_message = match backtrace.status() {
BacktraceStatus::Captured => format!("{backtrace}"),
_ => "(Backtrace disabled; run with `RUST_BACKTRACE=1` environment variable to display a backtrace)".into(),
};
let step_group_name =
step.step_group_name.as_deref().unwrap_or("Unspecified");
self.persisted_data.insert(
RUNTIME_TAG_FAILED_STEP_GROUP_NAME.to_owned(),
step_group_name.to_owned(),
);
let failed_assertion_name =
failed_assertion_name.unwrap_or("Unspecified".to_owned());
self.persisted_data.insert(
RUNTIME_TAG_FAILED_ASSERTION_NAME.to_owned(),
failed_assertion_name,
);
self.persisted_data
.insert(RUNTIME_TAG_FAILURE_REASON.to_owned(), message.clone());
self.run_on_finish_and_export_tags(app, window_id).await;
panic!(
"Test step '{}' failed after {} attempts: {message}\nFailed in step group: {step_group_name}\n{backtrace_message}",
step.name(),
max_attempts,
);
}
}
AssertionOutcome::ImmediateFailure {
message,
backtrace,
failed_assertion_name,
} => {
if retry_attempt < max_attempts {
log::warn!(
"Test step '{}' failed (attempt {}/{}): {}. Retrying...",
step.name(),
retry_attempt,
max_attempts,
message
);
continue 'retries; // Retry the step
} else {
// All retries exhausted, fail the test
let backtrace_message = match backtrace.status() {
BacktraceStatus::Captured => format!("{backtrace}"),
_ => "(Backtrace disabled; run with `RUST_BACKTRACE=1` environment variable to display a backtrace)".into(),
};
let step_group_name =
step.step_group_name.as_deref().unwrap_or("Unspecified");
self.persisted_data.insert(
RUNTIME_TAG_FAILED_STEP_GROUP_NAME.to_owned(),
step_group_name.to_owned(),
);
let failed_assertion_name =
failed_assertion_name.unwrap_or("Unspecified".to_owned());
self.persisted_data.insert(
RUNTIME_TAG_FAILED_ASSERTION_NAME.to_owned(),
failed_assertion_name,
);
self.persisted_data
.insert(RUNTIME_TAG_FAILURE_REASON.to_owned(), message.clone());
self.run_on_finish_and_export_tags(app, window_id).await;
panic!(
"Test step '{}' failed after {} attempts: {message}\nFailed in step group: {step_group_name}\n{backtrace_message}",
step.name(),
max_attempts,
);
}
}
AssertionOutcome::Canceled => {
// Early exit on cancellation.
log::info!("Test step '{}' canceled, running on_finish...", step.name());
self.run_on_finish_and_export_tags(app, window_id).await;
log::info!("on_finish complete, exiting");
std::process::exit(0);
}
AssertionOutcome::PreconditionFailed(msg) => {
// End the test, but don't fail it.
log::warn!(
"Test step '{}' precondition failed because of '{}' - attempting a re-run.",
step.name(),
msg
);
return StepResult::PreconditionFailed;
}
}
}
}
}
/// Returns whether video recording should be enabled for the given test name.
///
/// Checks the `WARP_INTEGRATION_TEST_VIDEO` environment variable:
/// - Unset or empty → recording disabled.
/// - `"1"` or `"all"` → recording enabled for every test.
/// - Any other value → treated as a comma-separated list of test names;
/// recording is enabled only when `test_name` appears in the list.
///
/// Example:
/// ```sh
/// # Record all tests
/// WARP_INTEGRATION_TEST_VIDEO=1 cargo nextest run ...
///
/// # Record only specific tests
/// WARP_INTEGRATION_TEST_VIDEO=test_foo,test_bar cargo nextest run ...
/// ```
fn video_recording_enabled_for_test(test_name: &str) -> bool {
let Ok(value) = std::env::var(video_recorder::VIDEO_ENABLED_ENV_VAR) else {
return false;
};
let value = value.trim();
if value.is_empty() {
return false;
}
if value == "1" || value == "all" {
return true;
}
value.split(',').any(|name| name.trim() == test_name)
}
/// Given a value retrieved from catching an unwinding panic, returns
/// the panic message, if one is available.
fn get_panic_message(panic: &Box<dyn std::any::Any + Send>) -> Option<&str> {
panic
// If a panic or assert is invoked in a way that includes a format
// string and arguments, the panic data will be an owned string.
.downcast_ref::<String>()
.map(String::as_str)
// Otherwise, it might be a static string reference (if there are no values
// that need to be interpolated at runtime).
.or_else(|| {
panic
.downcast_ref::<&'static str>()
.map(std::ops::Deref::deref)
})
}
+240
View File
@@ -0,0 +1,240 @@
use std::{
collections::HashSet,
env,
ffi::OsStr,
fs,
io::ErrorKind,
path::{Path, PathBuf},
};
mod action_log;
mod artifacts;
pub mod capture_recorder;
mod driver;
pub mod overlay;
mod step;
pub mod video_recorder;
pub use action_log::ActionLog;
pub use artifacts::ARTIFACTS_DIR_ENV_VAR;
pub use driver::{Builder, SetupFn, TestDriver, RERUN_EXIT_CODE, RUNTIME_TAG_FAILURE_REASON};
pub use overlay::OverlayLog;
pub use step::{
AssertionCallback, AssertionOutcome, AssertionWithDataCallback, IntegrationTestEvent,
PersistedDataMap, StepData, StepDataMap, TestStep,
};
pub use video_recorder::{save_captured_frame_as_png, VideoRecorder};
#[macro_export]
macro_rules! async_assert {
($left:expr) => {
match (&$left) {
(left_val) => {
if *left_val {
$crate::integration::AssertionOutcome::Success
} else {
let assertion_message = format!("assertion failed: {}", stringify!($left));
$crate::integration::AssertionOutcome::failure(assertion_message)
}
}
}
};
($left:expr, $($arg:tt)+) => {
match (&$left) {
(left_val) => {
if *left_val {
$crate::integration::AssertionOutcome::Success
} else {
let assertion_message = format!("assertion failed: {}", format_args!($($arg)+));
$crate::integration::AssertionOutcome::failure(assertion_message)
}
}
}
};
}
/// Asserts that the condition is true immediately,
/// but allows for some on_finish behavior in the app before it panics.
#[macro_export]
macro_rules! integration_assert {
($left:expr) => {
match (&$left) {
(left_val) => {
if !*left_val {
let assertion_message = format!("assertion failed: {}", stringify!($left));
return $crate::integration::AssertionOutcome::immediate_failure(assertion_message);
}
}
}
};
($left:expr, $($arg:tt)+) => {
match (&$left) {
(left_val) => {
if !*left_val {
let assertion_message = format!("assertion failed: {}", format_args!($($arg)+));
return $crate::integration::AssertionOutcome::immediate_failure(assertion_message);
}
}
}
};
}
#[macro_export]
macro_rules! async_assert_eq {
($left:expr, $right:expr) => {
match (&$left, &$right) {
(left_val, right_val) => {
if *left_val == *right_val {
$crate::integration::AssertionOutcome::Success
} else {
let assertion_message = format!(
"assertion failed: `(left = right)`
left: `{:?}`,
right: `{:?}`",
left_val, right_val
);
$crate::integration::AssertionOutcome::failure(assertion_message)
}
}
}
};
($left:expr, $right:expr, $($arg:tt)+) => {
match (&$left, &$right) {
(left_val, right_val) => {
if *left_val == *right_val {
$crate::integration::AssertionOutcome::Success
} else {
let assertion_message = format!(
"assertion failed: `{}`
left: `{:?}`,
right: `{:?}`",
format_args!($($arg)+), left_val, right_val
);
$crate::integration::AssertionOutcome::failure(assertion_message)
}
}
}
};
}
pub struct TestSetupUtils {
env_vars: HashSet<String>,
root_dir: RootDir,
}
impl TestSetupUtils {
fn new(root_dir: RootDir) -> Self {
TestSetupUtils {
env_vars: HashSet::new(),
root_dir,
}
}
/// Returns the $HOME dir for the test.
pub fn test_dir(&self) -> PathBuf {
self.root_dir.as_path().to_path_buf()
}
pub fn set_env<K, V>(&mut self, key: K, value: Option<V>)
where
K: Into<String>,
V: AsRef<OsStr>,
{
let key = key.into();
match value {
Some(v) => {
println!(
"Setting env var {} to {} for test",
key,
v.as_ref().to_string_lossy()
);
env::set_var(&key, v);
self.env_vars.insert(key);
}
None => {
println!("Clearing env var {key}");
env::remove_var(key);
}
};
}
pub fn cleanup_env(&mut self) {
for key in &self.env_vars {
println!("Clearing env var {key}");
env::remove_var(key);
}
self.env_vars = HashSet::new();
}
// For each test, we create an empty directory in the temp filesystem. This will be the root
// for that test's specific resources.
fn create_temp_dir_for_test(&self) {
let test_dir = self.root_dir.as_path();
// Remove anything we failed to remove from previous runs of the test.
match fs::remove_dir_all(test_dir) {
Ok(_) => (),
Err(err) => {
// Not found is fine because there's no old data to interfere with this test.
if err.kind() != ErrorKind::NotFound {
eprintln!("failure cleaning up test temp dir at {test_dir:?}");
if let Ok(rd) = test_dir.read_dir() {
eprintln!("contents of test temp dir:");
for entry in rd.flatten() {
eprintln!(" - {:?}", entry.file_name());
}
}
panic!("failed to remove previous run test data: {err}");
}
}
}
let res = fs::create_dir_all(test_dir);
if let Err(err_code) = res {
if err_code.kind() != ErrorKind::AlreadyExists {
panic!("Failed to create directory {test_dir:?}");
}
}
}
/// Configures the home directory path for the test.
fn set_home_dir_for_test(&mut self) {
if cfg!(unix) {
self.set_env("ORIGINAL_HOME", dirs::home_dir());
// Override the home directory path. This helps keep tests more
// hermetic by making them not depend on the contents of the user's
// home directory (which could be very different on a developer's
// machine vs. on cloud CI runners).
//
// We canonicalize the path to resolve symlinks (e.g. /var ->
// /private/var on macOS) so that the shell's resolved $PWD matches
// $HOME exactly, which is required for ~ substitution to work.
let canonical_test_dir = self
.test_dir()
.canonicalize()
.unwrap_or_else(|_| self.test_dir());
self.set_env("HOME", Some(canonical_test_dir));
}
}
pub fn cleanup_dir(&mut self) {
if let Err(err) = fs::remove_dir_all(self.root_dir.as_path()) {
log::error!("Could not cleanup directory {err:?}");
}
}
}
enum RootDir {
/// Uses the provided path as the test's root directory.
Path(PathBuf),
/// Uses the provided TempDir as the test's root directory.
TempDir(tempfile::TempDir),
}
impl RootDir {
fn as_path(&self) -> &Path {
match self {
RootDir::Path(path) => path.as_path(),
RootDir::TempDir(tempdir) => tempdir.path(),
}
}
}
@@ -0,0 +1,899 @@
use instant::Instant;
pub const OVERLAY_LOG_KEY: &str = "overlay_log";
#[cfg(feature = "integration_tests")]
const CLICK_RING_DURATION: std::time::Duration = std::time::Duration::from_millis(900);
#[cfg(feature = "integration_tests")]
const KEY_DISPLAY_DURATION: std::time::Duration = std::time::Duration::from_millis(1500);
#[cfg(feature = "integration_tests")]
const DRAG_TRAIL_FADE_DURATION: std::time::Duration = std::time::Duration::from_millis(600);
#[cfg(feature = "integration_tests")]
const MOUSE_DOWN_RADIUS: f32 = 16.0;
#[cfg(feature = "integration_tests")]
const CLICK_RING_MIN_RADIUS: f32 = 18.0;
#[cfg(feature = "integration_tests")]
const CLICK_RING_MAX_RADIUS: f32 = 36.0;
#[cfg(feature = "integration_tests")]
const CLICK_RING_THICKNESS: f32 = 4.0;
#[cfg(feature = "integration_tests")]
const DRAG_TRAIL_THICKNESS: f32 = 4.0;
#[cfg(feature = "integration_tests")]
const DRAG_ANCHOR_RADIUS: f32 = 10.0;
#[cfg(feature = "integration_tests")]
const MOUSE_DOWN_COLOR: [u8; 4] = [255, 80, 40, 180];
#[cfg(feature = "integration_tests")]
const CLICK_RING_COLOR: [u8; 3] = [255, 80, 40];
#[cfg(feature = "integration_tests")]
const DRAG_TRAIL_COLOR: [u8; 4] = [255, 80, 40, 140];
#[cfg(feature = "integration_tests")]
const DRAG_ANCHOR_COLOR: [u8; 4] = [255, 80, 40, 120];
#[cfg(feature = "integration_tests")]
const KEY_BG_COLOR: [u8; 4] = [30, 30, 30, 200];
#[cfg(feature = "integration_tests")]
const KEY_TEXT_COLOR: [u8; 4] = [255, 255, 255, 255];
#[cfg(feature = "integration_tests")]
const FONT_SOURCE_CHAR_W: u32 = 8;
#[cfg(feature = "integration_tests")]
const FONT_SOURCE_CHAR_H: u32 = 16;
#[cfg(feature = "integration_tests")]
const FONT_CHAR_W: u32 = 24;
#[cfg(feature = "integration_tests")]
const FONT_CHAR_H: u32 = 48;
#[cfg(feature = "integration_tests")]
const FONT_SUPERSAMPLE: u32 = 4;
#[cfg(feature = "integration_tests")]
const KEY_BOX_PADDING_X: u32 = 24;
#[cfg(feature = "integration_tests")]
const KEY_BOX_PADDING_Y: u32 = 12;
#[cfg(feature = "integration_tests")]
const KEY_BOX_MARGIN_BOTTOM: u32 = 48;
#[cfg(feature = "integration_tests")]
const KEY_BOX_SPACING_Y: u32 = 16;
#[cfg(feature = "integration_tests")]
const MAX_VISIBLE_KEY_EVENTS: usize = 4;
pub struct OverlayEvent {
pub recorded_at: Instant,
pub kind: OverlayKind,
}
pub enum OverlayKind {
MouseDown { x: f32, y: f32 },
MouseMove { x: f32, y: f32 },
MouseUp { x: f32, y: f32 },
KeyPress { display_text: String },
}
#[derive(Default)]
pub struct OverlayLog {
events: Vec<OverlayEvent>,
scale_factor: f32,
}
impl OverlayLog {
pub fn new() -> Self {
Self {
events: Vec::new(),
scale_factor: 2.0,
}
}
pub fn set_scale_factor(&mut self, factor: f32) {
self.scale_factor = factor;
}
pub fn record(&mut self, kind: OverlayKind) {
self.events.push(OverlayEvent {
recorded_at: Instant::now(),
kind,
});
}
pub fn events(&self) -> &[OverlayEvent] {
&self.events
}
pub fn scale_factor(&self) -> f32 {
self.scale_factor
}
}
pub fn get_overlay_log_mut(
step_data_map: &mut super::step::StepDataMap,
) -> Option<&mut OverlayLog> {
step_data_map.get_mut::<_, OverlayLog>(OVERLAY_LOG_KEY)
}
pub fn get_overlay_log(step_data_map: &super::step::StepDataMap) -> Option<&OverlayLog> {
step_data_map.get::<_, OverlayLog>(OVERLAY_LOG_KEY)
}
pub fn keystroke_display_text(keystroke: &crate::keymap::Keystroke) -> String {
let mut s = String::new();
if keystroke.ctrl {
s.push('\u{2303}');
}
if keystroke.alt {
s.push('\u{2325}');
}
if keystroke.shift {
s.push('\u{21e7}');
}
if keystroke.cmd {
s.push('\u{2318}');
}
let key = &keystroke.key;
match key.as_str() {
"enter" | "numpadenter" => s.push_str("Enter"),
"tab" => s.push_str("Tab"),
"escape" => s.push_str("Esc"),
"backspace" => s.push_str("Backspace"),
"delete" => s.push_str("Delete"),
" " => s.push_str("Space"),
"up" => s.push('\u{2191}'),
"down" => s.push('\u{2193}'),
"left" => s.push('\u{2190}'),
"right" => s.push('\u{2192}'),
other => {
if other.len() == 1 {
s.push_str(&other.to_uppercase());
} else {
s.push_str(other);
}
}
}
s
}
#[cfg(feature = "integration_tests")]
pub struct OverlayState {
event_cursor: usize,
mouse_held: bool,
held_pos: (f32, f32),
drag_trail: Vec<(f32, f32)>,
drag_ended_at: Option<Instant>,
recent_mouse_ups: Vec<(f32, f32, Instant)>,
recent_keys: Vec<(String, Instant)>,
}
#[cfg(feature = "integration_tests")]
impl Default for OverlayState {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "integration_tests")]
impl OverlayState {
pub fn new() -> Self {
Self {
event_cursor: 0,
mouse_held: false,
held_pos: (0.0, 0.0),
drag_trail: Vec::new(),
drag_ended_at: None,
recent_mouse_ups: Vec::new(),
recent_keys: Vec::new(),
}
}
pub fn advance_to(&mut self, timestamp: Instant, events: &[OverlayEvent]) {
while self.event_cursor < events.len() {
let ev = &events[self.event_cursor];
if ev.recorded_at > timestamp {
break;
}
match &ev.kind {
OverlayKind::MouseDown { x, y } => {
self.mouse_held = true;
self.held_pos = (*x, *y);
self.drag_trail.clear();
self.drag_trail.push((*x, *y));
self.drag_ended_at = None;
}
OverlayKind::MouseMove { x, y } => {
if self.mouse_held {
self.held_pos = (*x, *y);
self.drag_trail.push((*x, *y));
}
}
OverlayKind::MouseUp { x, y } => {
self.mouse_held = false;
self.held_pos = (*x, *y);
if self.drag_trail.is_empty() {
self.drag_trail.push((*x, *y));
}
self.drag_ended_at = Some(ev.recorded_at);
self.recent_mouse_ups.push((*x, *y, ev.recorded_at));
}
OverlayKind::KeyPress { display_text } => {
self.recent_keys
.push((display_text.clone(), ev.recorded_at));
}
}
self.event_cursor += 1;
}
self.recent_mouse_ups
.retain(|&(_, _, t)| timestamp.duration_since(t) < CLICK_RING_DURATION);
self.recent_keys
.retain(|&(_, t)| timestamp.duration_since(t) < KEY_DISPLAY_DURATION);
if let Some(end_t) = self.drag_ended_at {
if timestamp.duration_since(end_t) >= DRAG_TRAIL_FADE_DURATION {
self.drag_trail.clear();
self.drag_ended_at = None;
}
}
}
pub fn render_onto(
&self,
buf: &mut [u8],
width: u32,
height: u32,
timestamp: Instant,
scale: f32,
) {
if self.drag_trail.len() > 1 {
let alpha_mult = if let Some(end_t) = self.drag_ended_at {
let elapsed = timestamp.duration_since(end_t).as_secs_f32();
let total = DRAG_TRAIL_FADE_DURATION.as_secs_f32();
(1.0 - (elapsed / total)).clamp(0.0, 1.0)
} else {
1.0
};
draw_drag_trail(buf, width, height, &self.drag_trail, scale, alpha_mult);
if let Some(&(x, y)) = self.drag_trail.first() {
draw_drag_anchor(buf, width, height, x * scale, y * scale, alpha_mult);
}
}
if self.mouse_held {
let px = self.held_pos.0 * scale;
let py = self.held_pos.1 * scale;
draw_mouse_down_indicator(buf, width, height, px, py);
}
for &(x, y, t) in &self.recent_mouse_ups {
let elapsed = timestamp.duration_since(t).as_secs_f32();
let total = CLICK_RING_DURATION.as_secs_f32();
let progress = (elapsed / total).clamp(0.0, 1.0);
let px = x * scale;
let py = y * scale;
draw_click_ring(buf, width, height, px, py, progress);
}
if !self.recent_keys.is_empty() {
for (stack_index, (text, t)) in self
.recent_keys
.iter()
.rev()
.take(MAX_VISIBLE_KEY_EVENTS)
.enumerate()
{
let elapsed = timestamp.duration_since(*t).as_secs_f32();
let total = KEY_DISPLAY_DURATION.as_secs_f32();
let progress = (elapsed / total).clamp(0.0, 1.0);
draw_key_overlay(buf, width, height, text, progress, stack_index as u32);
}
}
}
}
#[cfg(feature = "integration_tests")]
fn blend_pixel(buf: &mut [u8], offset: usize, r: u8, g: u8, b: u8, a: u8) {
if a == 0 || offset + 3 >= buf.len() {
return;
}
let alpha = a as f32 / 255.0;
let inv = 1.0 - alpha;
buf[offset] = (buf[offset] as f32 * inv + r as f32 * alpha) as u8;
buf[offset + 1] = (buf[offset + 1] as f32 * inv + g as f32 * alpha) as u8;
buf[offset + 2] = (buf[offset + 2] as f32 * inv + b as f32 * alpha) as u8;
buf[offset + 3] = 255;
}
#[cfg(feature = "integration_tests")]
fn draw_filled_circle(
buf: &mut [u8],
width: u32,
height: u32,
center: (f32, f32),
r: f32,
color: [u8; 4],
alpha_mult: f32,
) {
let (cx, cy) = center;
let r2 = r * r;
let x0 = ((cx - r - 1.0).max(0.0)) as u32;
let y0 = ((cy - r - 1.0).max(0.0)) as u32;
let x1 = ((cx + r + 1.0).min(width as f32 - 1.0)) as u32;
let y1 = ((cy + r + 1.0).min(height as f32 - 1.0)) as u32;
for py in y0..=y1 {
for px in x0..=x1 {
let dx = px as f32 + 0.5 - cx;
let dy = py as f32 + 0.5 - cy;
let d2 = dx * dx + dy * dy;
if d2 <= r2 {
let edge = ((r - d2.sqrt()) * 2.0).clamp(0.0, 1.0);
let a = (color[3] as f32 * edge * alpha_mult) as u8;
let off = (py * width + px) as usize * 4;
blend_pixel(buf, off, color[0], color[1], color[2], a);
}
}
}
}
#[cfg(feature = "integration_tests")]
fn draw_mouse_down_indicator(buf: &mut [u8], width: u32, height: u32, cx: f32, cy: f32) {
draw_filled_circle(
buf,
width,
height,
(cx, cy),
MOUSE_DOWN_RADIUS,
MOUSE_DOWN_COLOR,
1.0,
);
}
#[cfg(feature = "integration_tests")]
fn draw_drag_anchor(buf: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, alpha_mult: f32) {
draw_filled_circle(
buf,
width,
height,
(cx, cy),
DRAG_ANCHOR_RADIUS,
DRAG_ANCHOR_COLOR,
alpha_mult,
);
}
#[cfg(feature = "integration_tests")]
fn draw_click_ring(buf: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, progress: f32) {
let radius = CLICK_RING_MIN_RADIUS + (CLICK_RING_MAX_RADIUS - CLICK_RING_MIN_RADIUS) * progress;
let alpha_f = (1.0 - progress) * 255.0;
let half_thick = CLICK_RING_THICKNESS / 2.0;
let outer = radius + half_thick;
let inner = (radius - half_thick).max(0.0);
let x0 = ((cx - outer - 1.0).max(0.0)) as u32;
let y0 = ((cy - outer - 1.0).max(0.0)) as u32;
let x1 = ((cx + outer + 1.0).min(width as f32 - 1.0)) as u32;
let y1 = ((cy + outer + 1.0).min(height as f32 - 1.0)) as u32;
for py in y0..=y1 {
for px in x0..=x1 {
let dx = px as f32 + 0.5 - cx;
let dy = py as f32 + 0.5 - cy;
let dist = (dx * dx + dy * dy).sqrt();
if dist >= inner && dist <= outer {
let edge_outer = ((outer - dist) * 2.0).clamp(0.0, 1.0);
let edge_inner = ((dist - inner) * 2.0).clamp(0.0, 1.0);
let edge = edge_outer.min(edge_inner);
let a = (alpha_f * edge) as u8;
let off = (py * width + px) as usize * 4;
blend_pixel(
buf,
off,
CLICK_RING_COLOR[0],
CLICK_RING_COLOR[1],
CLICK_RING_COLOR[2],
a,
);
}
}
}
}
#[cfg(feature = "integration_tests")]
fn draw_drag_trail(
buf: &mut [u8],
width: u32,
height: u32,
points: &[(f32, f32)],
scale: f32,
alpha_mult: f32,
) {
for window in points.windows(2) {
let start = (window[0].0 * scale, window[0].1 * scale);
let end = (window[1].0 * scale, window[1].1 * scale);
draw_thick_line(buf, width, height, start, end, alpha_mult);
}
}
#[cfg(feature = "integration_tests")]
fn draw_thick_line(
buf: &mut [u8],
width: u32,
height: u32,
start: (f32, f32),
end: (f32, f32),
alpha_mult: f32,
) {
let (x0, y0) = start;
let (x1, y1) = end;
let dx = x1 - x0;
let dy = y1 - y0;
let len = (dx * dx + dy * dy).sqrt();
if len < 0.5 {
return;
}
let steps = (len * 2.0) as u32;
let half_thick = DRAG_TRAIL_THICKNESS / 2.0;
for i in 0..=steps {
let t = i as f32 / steps as f32;
let cx = x0 + dx * t;
let cy = y0 + dy * t;
let px_min = ((cx - half_thick - 1.0).max(0.0)) as u32;
let py_min = ((cy - half_thick - 1.0).max(0.0)) as u32;
let px_max = ((cx + half_thick + 1.0).min(width as f32 - 1.0)) as u32;
let py_max = ((cy + half_thick + 1.0).min(height as f32 - 1.0)) as u32;
for py in py_min..=py_max {
for px in px_min..=px_max {
let ddx = px as f32 + 0.5 - cx;
let ddy = py as f32 + 0.5 - cy;
let dist = (ddx * ddx + ddy * ddy).sqrt();
if dist <= half_thick {
let edge = ((half_thick - dist) * 2.0).clamp(0.0, 1.0);
let a = (DRAG_TRAIL_COLOR[3] as f32 * edge * alpha_mult) as u8;
let off = (py * width + px) as usize * 4;
blend_pixel(
buf,
off,
DRAG_TRAIL_COLOR[0],
DRAG_TRAIL_COLOR[1],
DRAG_TRAIL_COLOR[2],
a,
);
}
}
}
}
}
#[cfg(feature = "integration_tests")]
fn draw_key_overlay(
buf: &mut [u8],
width: u32,
height: u32,
text: &str,
progress: f32,
stack_index: u32,
) {
let alpha_mult = if progress > 0.7 {
((1.0 - progress) / 0.3).clamp(0.0, 1.0)
} else {
1.0
};
if alpha_mult < 0.01 {
return;
}
let char_w = FONT_CHAR_W;
let char_h = FONT_CHAR_H;
let text_w = text.chars().count() as u32 * char_w;
let box_w = text_w + KEY_BOX_PADDING_X * 2;
let box_h = char_h + KEY_BOX_PADDING_Y * 2;
let box_x = (width.saturating_sub(box_w)) / 2;
let stack_offset = stack_index.saturating_mul(box_h + KEY_BOX_SPACING_Y);
let box_y = height.saturating_sub(box_h + KEY_BOX_MARGIN_BOTTOM + stack_offset);
let corner_r = 8u32;
for row in 0..box_h {
for col in 0..box_w {
let px = box_x + col;
let py = box_y + row;
if px >= width || py >= height {
continue;
}
let dx_left = corner_r.saturating_sub(col);
let dx_right = col.saturating_sub(box_w - 1 - corner_r);
let dy_top = corner_r.saturating_sub(row);
let dy_bottom = row.saturating_sub(box_h - 1 - corner_r);
let dx = dx_left.max(dx_right);
let dy = dy_top.max(dy_bottom);
if dx > 0 && dy > 0 {
let dist = ((dx * dx + dy * dy) as f32).sqrt();
if dist > corner_r as f32 + 0.5 {
continue;
}
}
let a = (KEY_BG_COLOR[3] as f32 * alpha_mult) as u8;
let off = (py * width + px) as usize * 4;
blend_pixel(
buf,
off,
KEY_BG_COLOR[0],
KEY_BG_COLOR[1],
KEY_BG_COLOR[2],
a,
);
}
}
let text_x = box_x + KEY_BOX_PADDING_X;
let text_y = box_y + KEY_BOX_PADDING_Y;
draw_text(buf, width, height, text_x, text_y, text, alpha_mult);
}
#[cfg(feature = "integration_tests")]
fn draw_text(
buf: &mut [u8],
width: u32,
height: u32,
start_x: u32,
start_y: u32,
text: &str,
alpha_mult: f32,
) {
let char_w = FONT_CHAR_W;
for (i, ch) in text.chars().enumerate() {
let glyph = rasterize_glyph(ch);
let cx = start_x + i as u32 * char_w;
for row in 0..FONT_CHAR_H {
for col in 0..FONT_CHAR_W {
let glyph_alpha = glyph[(row * FONT_CHAR_W + col) as usize];
if glyph_alpha == 0 {
continue;
}
let px = cx + col;
let py = start_y + row;
if px < width && py < height {
let a = (glyph_alpha as f32 * alpha_mult) as u8;
let off = (py * width + px) as usize * 4;
blend_pixel(
buf,
off,
KEY_TEXT_COLOR[0],
KEY_TEXT_COLOR[1],
KEY_TEXT_COLOR[2],
a,
);
}
}
}
}
}
#[cfg(feature = "integration_tests")]
fn rasterize_glyph(ch: char) -> Vec<u8> {
let source = get_glyph(ch);
let mut bitmap = vec![0; (FONT_CHAR_W * FONT_CHAR_H) as usize];
for row in 0..FONT_CHAR_H {
for col in 0..FONT_CHAR_W {
bitmap[(row * FONT_CHAR_W + col) as usize] = rasterize_glyph_pixel(source, col, row);
}
}
bitmap
}
#[cfg(feature = "integration_tests")]
fn rasterize_glyph_pixel(source: &[u8; 16], x: u32, y: u32) -> u8 {
let mut covered_samples = 0u32;
let total_samples = FONT_SUPERSAMPLE * FONT_SUPERSAMPLE;
for sample_y in 0..FONT_SUPERSAMPLE {
for sample_x in 0..FONT_SUPERSAMPLE {
let src_x = (x as f32 + (sample_x as f32 + 0.5) / FONT_SUPERSAMPLE as f32)
* FONT_SOURCE_CHAR_W as f32
/ FONT_CHAR_W as f32;
let src_y = (y as f32 + (sample_y as f32 + 0.5) / FONT_SUPERSAMPLE as f32)
* FONT_SOURCE_CHAR_H as f32
/ FONT_CHAR_H as f32;
let src_col = src_x.floor().min((FONT_SOURCE_CHAR_W - 1) as f32) as u32;
let src_row = src_y.floor().min((FONT_SOURCE_CHAR_H - 1) as f32) as usize;
if source[src_row] & (0x80 >> src_col) != 0 {
covered_samples += 1;
}
}
}
((covered_samples * 255) / total_samples) as u8
}
#[cfg(feature = "integration_tests")]
fn get_glyph(ch: char) -> &'static [u8; 16] {
let code = ch as u32;
if (0x20..0x7F).contains(&code) {
&FONT_DATA[(code - 0x20) as usize]
} else {
match ch {
'\u{2318}' => &GLYPH_CMD,
'\u{2303}' => &GLYPH_CTRL,
'\u{2325}' => &GLYPH_OPT,
'\u{21e7}' => &GLYPH_SHIFT,
'\u{21a9}' => &GLYPH_RETURN,
'\u{21e5}' => &GLYPH_TAB,
'\u{232b}' => &GLYPH_DELETE,
'\u{2326}' => &GLYPH_FWD_DELETE,
'\u{2191}' => &GLYPH_ARROW_UP,
'\u{2193}' => &GLYPH_ARROW_DOWN,
'\u{2190}' => &GLYPH_ARROW_LEFT,
'\u{2192}' => &GLYPH_ARROW_RIGHT,
_ => &GLYPH_FALLBACK,
}
}
}
// 8x16 bitmap font for printable ASCII 0x20..0x7E (space through tilde).
// Each glyph is 16 bytes, one byte per row, MSB = leftmost pixel.
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static FONT_DATA: [[u8; 16]; 95] = [
// 0x20 ' '
[0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],
// 0x21 '!'
[0x00,0x00,0x18,0x3C,0x3C,0x3C,0x18,0x18,0x18,0x00,0x18,0x18,0x00,0x00,0x00,0x00],
// 0x22 '"'
[0x00,0x66,0x66,0x66,0x24,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],
// 0x23 '#'
[0x00,0x00,0x00,0x6C,0x6C,0xFE,0x6C,0x6C,0x6C,0xFE,0x6C,0x6C,0x00,0x00,0x00,0x00],
// 0x24 '$'
[0x18,0x18,0x7C,0xC6,0xC2,0xC0,0x7C,0x06,0x06,0x86,0xC6,0x7C,0x18,0x18,0x00,0x00],
// 0x25 '%'
[0x00,0x00,0x00,0x00,0xC2,0xC6,0x0C,0x18,0x30,0x60,0xC6,0x86,0x00,0x00,0x00,0x00],
// 0x26 '&'
[0x00,0x00,0x38,0x6C,0x6C,0x38,0x76,0xDC,0xCC,0xCC,0xCC,0x76,0x00,0x00,0x00,0x00],
// 0x27 '''
[0x00,0x30,0x30,0x30,0x60,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],
// 0x28 '('
[0x00,0x00,0x0C,0x18,0x30,0x30,0x30,0x30,0x30,0x30,0x18,0x0C,0x00,0x00,0x00,0x00],
// 0x29 ')'
[0x00,0x00,0x30,0x18,0x0C,0x0C,0x0C,0x0C,0x0C,0x0C,0x18,0x30,0x00,0x00,0x00,0x00],
// 0x2A '*'
[0x00,0x00,0x00,0x00,0x00,0x66,0x3C,0xFF,0x3C,0x66,0x00,0x00,0x00,0x00,0x00,0x00],
// 0x2B '+'
[0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x7E,0x18,0x18,0x00,0x00,0x00,0x00,0x00,0x00],
// 0x2C ','
[0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x18,0x30,0x00,0x00,0x00],
// 0x2D '-'
[0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFE,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],
// 0x2E '.'
[0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x00,0x00,0x00,0x00],
// 0x2F '/'
[0x00,0x00,0x00,0x00,0x02,0x06,0x0C,0x18,0x30,0x60,0xC0,0x80,0x00,0x00,0x00,0x00],
// 0x30 '0'
[0x00,0x00,0x38,0x6C,0xC6,0xC6,0xD6,0xD6,0xC6,0xC6,0x6C,0x38,0x00,0x00,0x00,0x00],
// 0x31 '1'
[0x00,0x00,0x18,0x38,0x78,0x18,0x18,0x18,0x18,0x18,0x18,0x7E,0x00,0x00,0x00,0x00],
// 0x32 '2'
[0x00,0x00,0x7C,0xC6,0x06,0x0C,0x18,0x30,0x60,0xC0,0xC6,0xFE,0x00,0x00,0x00,0x00],
// 0x33 '3'
[0x00,0x00,0x7C,0xC6,0x06,0x06,0x3C,0x06,0x06,0x06,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x34 '4'
[0x00,0x00,0x0C,0x1C,0x3C,0x6C,0xCC,0xFE,0x0C,0x0C,0x0C,0x1E,0x00,0x00,0x00,0x00],
// 0x35 '5'
[0x00,0x00,0xFE,0xC0,0xC0,0xC0,0xFC,0x06,0x06,0x06,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x36 '6'
[0x00,0x00,0x38,0x60,0xC0,0xC0,0xFC,0xC6,0xC6,0xC6,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x37 '7'
[0x00,0x00,0xFE,0xC6,0x06,0x06,0x0C,0x18,0x30,0x30,0x30,0x30,0x00,0x00,0x00,0x00],
// 0x38 '8'
[0x00,0x00,0x7C,0xC6,0xC6,0xC6,0x7C,0xC6,0xC6,0xC6,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x39 '9'
[0x00,0x00,0x7C,0xC6,0xC6,0xC6,0x7E,0x06,0x06,0x06,0x0C,0x78,0x00,0x00,0x00,0x00],
// 0x3A ':'
[0x00,0x00,0x00,0x00,0x18,0x18,0x00,0x00,0x00,0x18,0x18,0x00,0x00,0x00,0x00,0x00],
// 0x3B ';'
[0x00,0x00,0x00,0x00,0x18,0x18,0x00,0x00,0x00,0x18,0x18,0x30,0x00,0x00,0x00,0x00],
// 0x3C '<'
[0x00,0x00,0x00,0x06,0x0C,0x18,0x30,0x60,0x30,0x18,0x0C,0x06,0x00,0x00,0x00,0x00],
// 0x3D '='
[0x00,0x00,0x00,0x00,0x00,0x7E,0x00,0x00,0x7E,0x00,0x00,0x00,0x00,0x00,0x00,0x00],
// 0x3E '>'
[0x00,0x00,0x00,0x60,0x30,0x18,0x0C,0x06,0x0C,0x18,0x30,0x60,0x00,0x00,0x00,0x00],
// 0x3F '?'
[0x00,0x00,0x7C,0xC6,0xC6,0x0C,0x18,0x18,0x18,0x00,0x18,0x18,0x00,0x00,0x00,0x00],
// 0x40 '@'
[0x00,0x00,0x00,0x7C,0xC6,0xC6,0xDE,0xDE,0xDE,0xDC,0xC0,0x7C,0x00,0x00,0x00,0x00],
// 0x41 'A'
[0x00,0x00,0x10,0x38,0x6C,0xC6,0xC6,0xFE,0xC6,0xC6,0xC6,0xC6,0x00,0x00,0x00,0x00],
// 0x42 'B'
[0x00,0x00,0xFC,0x66,0x66,0x66,0x7C,0x66,0x66,0x66,0x66,0xFC,0x00,0x00,0x00,0x00],
// 0x43 'C'
[0x00,0x00,0x3C,0x66,0xC2,0xC0,0xC0,0xC0,0xC0,0xC2,0x66,0x3C,0x00,0x00,0x00,0x00],
// 0x44 'D'
[0x00,0x00,0xF8,0x6C,0x66,0x66,0x66,0x66,0x66,0x66,0x6C,0xF8,0x00,0x00,0x00,0x00],
// 0x45 'E'
[0x00,0x00,0xFE,0x66,0x62,0x68,0x78,0x68,0x60,0x62,0x66,0xFE,0x00,0x00,0x00,0x00],
// 0x46 'F'
[0x00,0x00,0xFE,0x66,0x62,0x68,0x78,0x68,0x60,0x60,0x60,0xF0,0x00,0x00,0x00,0x00],
// 0x47 'G'
[0x00,0x00,0x3C,0x66,0xC2,0xC0,0xC0,0xDE,0xC6,0xC6,0x66,0x3A,0x00,0x00,0x00,0x00],
// 0x48 'H'
[0x00,0x00,0xC6,0xC6,0xC6,0xC6,0xFE,0xC6,0xC6,0xC6,0xC6,0xC6,0x00,0x00,0x00,0x00],
// 0x49 'I'
[0x00,0x00,0x3C,0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x3C,0x00,0x00,0x00,0x00],
// 0x4A 'J'
[0x00,0x00,0x1E,0x0C,0x0C,0x0C,0x0C,0x0C,0xCC,0xCC,0xCC,0x78,0x00,0x00,0x00,0x00],
// 0x4B 'K'
[0x00,0x00,0xE6,0x66,0x66,0x6C,0x78,0x78,0x6C,0x66,0x66,0xE6,0x00,0x00,0x00,0x00],
// 0x4C 'L'
[0x00,0x00,0xF0,0x60,0x60,0x60,0x60,0x60,0x60,0x62,0x66,0xFE,0x00,0x00,0x00,0x00],
// 0x4D 'M'
[0x00,0x00,0xC6,0xEE,0xFE,0xFE,0xD6,0xC6,0xC6,0xC6,0xC6,0xC6,0x00,0x00,0x00,0x00],
// 0x4E 'N'
[0x00,0x00,0xC6,0xE6,0xF6,0xFE,0xDE,0xCE,0xC6,0xC6,0xC6,0xC6,0x00,0x00,0x00,0x00],
// 0x4F 'O'
[0x00,0x00,0x7C,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x50 'P'
[0x00,0x00,0xFC,0x66,0x66,0x66,0x7C,0x60,0x60,0x60,0x60,0xF0,0x00,0x00,0x00,0x00],
// 0x51 'Q'
[0x00,0x00,0x7C,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0xD6,0xDE,0x7C,0x0C,0x0E,0x00,0x00],
// 0x52 'R'
[0x00,0x00,0xFC,0x66,0x66,0x66,0x7C,0x6C,0x66,0x66,0x66,0xE6,0x00,0x00,0x00,0x00],
// 0x53 'S'
[0x00,0x00,0x7C,0xC6,0xC6,0x60,0x38,0x0C,0x06,0xC6,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x54 'T'
[0x00,0x00,0xFF,0xDB,0x99,0x18,0x18,0x18,0x18,0x18,0x18,0x3C,0x00,0x00,0x00,0x00],
// 0x55 'U'
[0x00,0x00,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x56 'V'
[0x00,0x00,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0x6C,0x38,0x10,0x00,0x00,0x00,0x00],
// 0x57 'W'
[0x00,0x00,0xC6,0xC6,0xC6,0xC6,0xD6,0xD6,0xD6,0xFE,0xEE,0x6C,0x00,0x00,0x00,0x00],
// 0x58 'X'
[0x00,0x00,0xC6,0xC6,0x6C,0x7C,0x38,0x38,0x7C,0x6C,0xC6,0xC6,0x00,0x00,0x00,0x00],
// 0x59 'Y'
[0x00,0x00,0xC6,0xC6,0xC6,0x6C,0x38,0x18,0x18,0x18,0x18,0x3C,0x00,0x00,0x00,0x00],
// 0x5A 'Z'
[0x00,0x00,0xFE,0xC6,0x86,0x0C,0x18,0x30,0x60,0xC2,0xC6,0xFE,0x00,0x00,0x00,0x00],
// 0x5B '['
[0x00,0x00,0x3C,0x30,0x30,0x30,0x30,0x30,0x30,0x30,0x30,0x3C,0x00,0x00,0x00,0x00],
// 0x5C '\'
[0x00,0x00,0x00,0x80,0xC0,0xE0,0x70,0x38,0x1C,0x0E,0x06,0x02,0x00,0x00,0x00,0x00],
// 0x5D ']'
[0x00,0x00,0x3C,0x0C,0x0C,0x0C,0x0C,0x0C,0x0C,0x0C,0x0C,0x3C,0x00,0x00,0x00,0x00],
// 0x5E '^'
[0x10,0x38,0x6C,0xC6,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],
// 0x5F '_'
[0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF,0x00,0x00],
// 0x60 '`'
[0x30,0x30,0x18,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],
// 0x61 'a'
[0x00,0x00,0x00,0x00,0x00,0x78,0x0C,0x7C,0xCC,0xCC,0xCC,0x76,0x00,0x00,0x00,0x00],
// 0x62 'b'
[0x00,0x00,0xE0,0x60,0x60,0x78,0x6C,0x66,0x66,0x66,0x66,0x7C,0x00,0x00,0x00,0x00],
// 0x63 'c'
[0x00,0x00,0x00,0x00,0x00,0x7C,0xC6,0xC0,0xC0,0xC0,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x64 'd'
[0x00,0x00,0x1C,0x0C,0x0C,0x3C,0x6C,0xCC,0xCC,0xCC,0xCC,0x76,0x00,0x00,0x00,0x00],
// 0x65 'e'
[0x00,0x00,0x00,0x00,0x00,0x7C,0xC6,0xFE,0xC0,0xC0,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x66 'f'
[0x00,0x00,0x1C,0x36,0x32,0x30,0x78,0x30,0x30,0x30,0x30,0x78,0x00,0x00,0x00,0x00],
// 0x67 'g'
[0x00,0x00,0x00,0x00,0x00,0x76,0xCC,0xCC,0xCC,0xCC,0xCC,0x7C,0x0C,0xCC,0x78,0x00],
// 0x68 'h'
[0x00,0x00,0xE0,0x60,0x60,0x6C,0x76,0x66,0x66,0x66,0x66,0xE6,0x00,0x00,0x00,0x00],
// 0x69 'i'
[0x00,0x00,0x18,0x18,0x00,0x38,0x18,0x18,0x18,0x18,0x18,0x3C,0x00,0x00,0x00,0x00],
// 0x6A 'j'
[0x00,0x00,0x06,0x06,0x00,0x0E,0x06,0x06,0x06,0x06,0x06,0x06,0x66,0x66,0x3C,0x00],
// 0x6B 'k'
[0x00,0x00,0xE0,0x60,0x60,0x66,0x6C,0x78,0x78,0x6C,0x66,0xE6,0x00,0x00,0x00,0x00],
// 0x6C 'l'
[0x00,0x00,0x38,0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x3C,0x00,0x00,0x00,0x00],
// 0x6D 'm'
[0x00,0x00,0x00,0x00,0x00,0xEC,0xFE,0xD6,0xD6,0xD6,0xD6,0xC6,0x00,0x00,0x00,0x00],
// 0x6E 'n'
[0x00,0x00,0x00,0x00,0x00,0xDC,0x66,0x66,0x66,0x66,0x66,0x66,0x00,0x00,0x00,0x00],
// 0x6F 'o'
[0x00,0x00,0x00,0x00,0x00,0x7C,0xC6,0xC6,0xC6,0xC6,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x70 'p'
[0x00,0x00,0x00,0x00,0x00,0xDC,0x66,0x66,0x66,0x66,0x66,0x7C,0x60,0x60,0xF0,0x00],
// 0x71 'q'
[0x00,0x00,0x00,0x00,0x00,0x76,0xCC,0xCC,0xCC,0xCC,0xCC,0x7C,0x0C,0x0C,0x1E,0x00],
// 0x72 'r'
[0x00,0x00,0x00,0x00,0x00,0xDC,0x76,0x66,0x60,0x60,0x60,0xF0,0x00,0x00,0x00,0x00],
// 0x73 's'
[0x00,0x00,0x00,0x00,0x00,0x7C,0xC6,0x60,0x38,0x0C,0xC6,0x7C,0x00,0x00,0x00,0x00],
// 0x74 't'
[0x00,0x00,0x10,0x30,0x30,0xFC,0x30,0x30,0x30,0x30,0x36,0x1C,0x00,0x00,0x00,0x00],
// 0x75 'u'
[0x00,0x00,0x00,0x00,0x00,0xCC,0xCC,0xCC,0xCC,0xCC,0xCC,0x76,0x00,0x00,0x00,0x00],
// 0x76 'v'
[0x00,0x00,0x00,0x00,0x00,0xC6,0xC6,0xC6,0xC6,0xC6,0x6C,0x38,0x00,0x00,0x00,0x00],
// 0x77 'w'
[0x00,0x00,0x00,0x00,0x00,0xC6,0xC6,0xD6,0xD6,0xD6,0xFE,0x6C,0x00,0x00,0x00,0x00],
// 0x78 'x'
[0x00,0x00,0x00,0x00,0x00,0xC6,0x6C,0x38,0x38,0x38,0x6C,0xC6,0x00,0x00,0x00,0x00],
// 0x79 'y'
[0x00,0x00,0x00,0x00,0x00,0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0x7E,0x06,0x0C,0xF8,0x00],
// 0x7A 'z'
[0x00,0x00,0x00,0x00,0x00,0xFE,0xCC,0x18,0x30,0x60,0xC6,0xFE,0x00,0x00,0x00,0x00],
// 0x7B '{'
[0x00,0x00,0x0E,0x18,0x18,0x18,0x70,0x18,0x18,0x18,0x18,0x0E,0x00,0x00,0x00,0x00],
// 0x7C '|'
[0x00,0x00,0x18,0x18,0x18,0x18,0x00,0x18,0x18,0x18,0x18,0x18,0x00,0x00,0x00,0x00],
// 0x7D '}'
[0x00,0x00,0x70,0x18,0x18,0x18,0x0E,0x18,0x18,0x18,0x18,0x70,0x00,0x00,0x00,0x00],
// 0x7E '~'
[0x00,0x00,0x76,0xDC,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],
];
// Special Unicode symbol glyphs (8x16 bitmaps)
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_CMD: [u8; 16] = [
0x00,0x00,0x6C,0x92,0x92,0x7C,0x28,0x7C,0x92,0x92,0x6C,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_CTRL: [u8; 16] = [
0x00,0x00,0x00,0x00,0x10,0x38,0x6C,0xC6,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_OPT: [u8; 16] = [
0x00,0x00,0x00,0xC0,0x60,0x30,0x18,0x0C,0x06,0xFF,0x00,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_SHIFT: [u8; 16] = [
0x00,0x00,0x10,0x38,0x6C,0xC6,0xC6,0xFE,0x00,0xFE,0x00,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_RETURN: [u8; 16] = [
0x00,0x00,0x00,0x00,0x04,0x04,0x04,0x44,0x64,0x7E,0x20,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_TAB: [u8; 16] = [
0x00,0x00,0x00,0x00,0x20,0x60,0xFE,0x60,0x20,0x00,0xFE,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_DELETE: [u8; 16] = [
0x00,0x00,0x00,0x7C,0xCC,0x0C,0x0C,0x3C,0x0C,0xCC,0x7C,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_FWD_DELETE: [u8; 16] = [
0x00,0x00,0x00,0x7C,0xC6,0xC0,0xC0,0xFC,0xC0,0xC6,0x7C,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_ARROW_UP: [u8; 16] = [
0x00,0x00,0x18,0x3C,0x7E,0x18,0x18,0x18,0x18,0x18,0x00,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_ARROW_DOWN: [u8; 16] = [
0x00,0x00,0x18,0x18,0x18,0x18,0x18,0x7E,0x3C,0x18,0x00,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_ARROW_LEFT: [u8; 16] = [
0x00,0x00,0x00,0x00,0x10,0x30,0x7E,0x30,0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_ARROW_RIGHT: [u8; 16] = [
0x00,0x00,0x00,0x00,0x08,0x0C,0x7E,0x0C,0x08,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
];
#[cfg(feature = "integration_tests")]
#[rustfmt::skip]
static GLYPH_FALLBACK: [u8; 16] = [
0x00,0x00,0xFE,0x82,0x82,0x82,0x82,0x82,0x82,0x82,0xFE,0x00,0x00,0x00,0x00,0x00,
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
use crate::platform::CapturedFrame;
use image::ImageEncoder;
#[cfg(feature = "integration_tests")]
use instant::Instant;
use std::{
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
};
/// Well-known key used to store the `VideoRecorder` inside `StepDataMap`.
pub const VIDEO_RECORDER_KEY: &str = "video_recorder";
/// Well-known key prefix for screenshot path requests stored in `StepDataMap`.
pub const SCREENSHOT_PATH_KEY: &str = "pending_screenshot_path";
/// Environment variable that enables automatic video recording for all
/// integration test steps. When set, the driver starts recording at the
/// beginning of the test and writes the video on completion.
pub const VIDEO_ENABLED_ENV_VAR: &str = "WARP_INTEGRATION_TEST_VIDEO";
/// Environment variable that sets the output directory for video recordings
/// and screenshots. Defaults to `$TMPDIR/warp_integration_video_captures` when
/// unset.
pub const VIDEO_DIR_ENV_VAR: &str = "WARP_INTEGRATION_TEST_VIDEO_DIR";
/// A captured frame paired with the wall-clock time it was taken.
pub(super) struct TimestampedFrame {
pub(super) frame: CapturedFrame,
#[cfg(feature = "integration_tests")]
pub(super) captured_at: Instant,
}
/// Shared state passed to the capture loop task so it can push frames and
/// check whether recording/stopping is requested. All fields use
/// atomics/mutex so they are `Send`.
#[cfg(feature = "integration_tests")]
pub struct CaptureLoopState {
pub(super) recording: Arc<AtomicBool>,
pub(super) stopped: Arc<AtomicBool>,
pub(super) frames: Arc<Mutex<Vec<TimestampedFrame>>>,
}
/// Records captured frames during integration tests and can produce
/// individual PNGs or an encoded video file.
pub struct VideoRecorder {
/// Whether frames should currently be pushed (shared with the capture loop).
recording: Arc<AtomicBool>,
/// Set to `true` to tell the capture loop to exit.
stopped: Arc<AtomicBool>,
/// Accumulated frames (shared with the capture loop callback).
frames: Arc<Mutex<Vec<TimestampedFrame>>>,
/// Wall-clock time when `start_recording` was called.
#[cfg(feature = "integration_tests")]
recording_start: Option<Instant>,
}
impl Default for VideoRecorder {
fn default() -> Self {
Self {
recording: Arc::new(AtomicBool::new(false)),
stopped: Arc::new(AtomicBool::new(false)),
frames: Arc::new(Mutex::new(Vec::new())),
#[cfg(feature = "integration_tests")]
recording_start: None,
}
}
}
impl VideoRecorder {
pub fn new() -> Self {
Self::default()
}
pub fn start_recording(&mut self) {
#[cfg(feature = "integration_tests")]
{
self.recording_start = Some(Instant::now());
}
self.recording.store(true, Ordering::Relaxed);
}
pub fn stop_recording(&mut self) {
self.recording.store(false, Ordering::Relaxed);
}
/// Signals the capture loop to exit on its next iteration.
pub fn stop_capture_loop(&self) {
self.stopped.store(true, Ordering::Relaxed);
}
pub fn is_recording(&self) -> bool {
self.recording.load(Ordering::Relaxed)
}
/// Returns the instant recording started, if recording has been started.
#[cfg(feature = "integration_tests")]
pub fn recording_start(&self) -> Option<Instant> {
self.recording_start
}
/// Returns a `CaptureLoopState` that the capture loop task can use to
/// push frames and read the recording/stopped flags.
#[cfg(feature = "integration_tests")]
pub fn capture_loop_state(&self) -> CaptureLoopState {
CaptureLoopState {
recording: self.recording.clone(),
stopped: self.stopped.clone(),
frames: self.frames.clone(),
}
}
/// Returns the number of frames captured so far.
pub fn frame_count(&self) -> usize {
self.frames.lock().map(|g| g.len()).unwrap_or(0)
}
/// Encodes all captured frames into a video at `output_path`.
/// Falls back to saving individual PNGs if encoding fails.
pub fn finalize(
&mut self,
output_path: &Path,
overlay_log: Option<&super::overlay::OverlayLog>,
) -> anyhow::Result<()> {
let frames: Vec<TimestampedFrame> = self
.frames
.lock()
.map(|mut g| std::mem::take(&mut *g))
.unwrap_or_default();
if frames.is_empty() {
log::info!("VideoRecorder: no frames captured, nothing to finalize");
return Ok(());
}
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(feature = "integration_tests")]
match encode_to_mp4(output_path, &frames, overlay_log) {
Ok(()) => {
log::info!(
"VideoRecorder: wrote {} frames to {}",
frames.len(),
output_path.display()
);
}
Err(e) => {
log::warn!("VideoRecorder: MP4 encoding failed ({e}), falling back to PNGs");
save_frames_as_pngs(output_path, &frames)?;
}
}
#[cfg(not(feature = "integration_tests"))]
{
let _ = overlay_log;
save_frames_as_pngs(output_path, &frames)?;
}
Ok(())
}
}
/// Encodes a slice of timestamped frames into an H.264/MP4 file.
/// Runs entirely on the calling thread at test finalization time.
#[cfg(feature = "integration_tests")]
fn encode_to_mp4(
output_path: &Path,
frames: &[TimestampedFrame],
overlay_log: Option<&super::overlay::OverlayLog>,
) -> anyhow::Result<()> {
use super::overlay::OverlayState;
use minimp4::Mp4Muxer;
use openh264::encoder::Encoder;
use openh264::formats::{RgbSliceU8, YUVBuffer};
use std::io::Cursor;
const TARGET_FPS: u32 = 60;
const FRAME_DURATION_MS: u128 = 1000 / TARGET_FPS as u128;
let width = frames[0].frame.width;
let height = frames[0].frame.height;
let mut encoder = Encoder::new().map_err(|e| anyhow::anyhow!("openh264 init: {e}"))?;
let mut overlay_state = OverlayState::new();
let overlay_events = overlay_log.map(|ol| ol.events()).unwrap_or(&[]);
let overlay_scale = overlay_log.map(|ol| ol.scale_factor()).unwrap_or(2.0);
let has_overlays = !overlay_events.is_empty();
let mut h264_buf = Vec::new();
let mut total_encoded_frames = 0u32;
for i in 0..frames.len() {
let ts_frame = &frames[i];
let rgb_data = if has_overlays {
overlay_state.advance_to(ts_frame.captured_at, overlay_events);
let mut composited = ts_frame.frame.data.clone();
overlay_state.render_onto(
&mut composited,
width,
height,
ts_frame.captured_at,
overlay_scale,
);
rgba_to_rgb(&composited)
} else {
rgba_to_rgb(&ts_frame.frame.data)
};
let rgb_source = RgbSliceU8::new(&rgb_data, (width as usize, height as usize));
let yuv = YUVBuffer::from_rgb_source(rgb_source);
let repeat_count = if i + 1 < frames.len() {
let gap_ms = frames[i + 1]
.captured_at
.duration_since(ts_frame.captured_at)
.as_millis();
(gap_ms / FRAME_DURATION_MS).max(1) as u32
} else {
1
};
for _ in 0..repeat_count {
let bitstream = encoder
.encode(&yuv)
.map_err(|e| anyhow::anyhow!("openh264 encode: {e}"))?;
bitstream.write_vec(&mut h264_buf);
total_encoded_frames += 1;
}
}
log::info!(
"VideoRecorder: encoded {total_encoded_frames} video frames from {} captured frames",
frames.len()
);
let mut mp4_buf = Cursor::new(Vec::new());
let mut muxer = Mp4Muxer::new(&mut mp4_buf);
muxer.init_video(
width as i32,
height as i32,
false,
"integration test recording",
);
muxer.write_video_with_fps(&h264_buf, TARGET_FPS);
muxer.close();
std::fs::write(output_path, mp4_buf.into_inner())?;
Ok(())
}
/// Saves each frame as a PNG into a subdirectory next to `output_path`.
/// Heavy PNG encoding is offloaded to a Tokio blocking thread.
fn save_frames_as_pngs(output_path: &Path, frames: &[TimestampedFrame]) -> anyhow::Result<()> {
let stem = output_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("frame");
let dir = output_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(format!("{stem}_frames"));
std::fs::create_dir_all(&dir)?;
for (i, ts_frame) in frames.iter().enumerate() {
let path = dir.join(format!("{stem}_{i:04}.png"));
save_captured_frame_as_png(&ts_frame.frame, &path)?;
}
log::info!(
"VideoRecorder: saved {} PNGs to {}",
frames.len(),
dir.display()
);
Ok(())
}
/// Background task that drives continuous frame capture at ~60 FPS.
///
/// This future is `!Send` (it holds a clone of `App` which is `Rc<RefCell<...>>`) and
/// must be spawned on the foreground (main-thread) executor. It interleaves with the
/// step execution loop at every `Timer` yield point.
///
/// When `state.recording` is `false` the loop sleeps without requesting any captures,
/// so there is zero rendering overhead when recording is not active. When
/// `state.stopped` is set the loop exits cleanly.
#[cfg(feature = "integration_tests")]
pub async fn run_capture_loop(app: crate::App, state: CaptureLoopState) {
use crate::r#async::Timer;
use std::time::Duration;
loop {
Timer::after(Duration::from_millis(16)).await;
if state.stopped.load(Ordering::Relaxed) {
break;
}
if !state.recording.load(Ordering::Relaxed) {
continue;
}
let window = app.read(|ctx| {
let windowing_state = ctx.windows();
windowing_state
.active_window()
.and_then(|id| windowing_state.platform_window(id))
});
let Some(window) = window else {
continue;
};
let frames = state.frames.clone();
window
.as_ctx()
.request_frame_capture(Box::new(move |frame| {
let captured_at = Instant::now();
if let Ok(mut guard) = frames.lock() {
guard.push(TimestampedFrame { frame, captured_at });
}
}));
window.as_ctx().request_redraw();
}
}
#[cfg(feature = "integration_tests")]
fn rgba_to_rgb(rgba: &[u8]) -> Vec<u8> {
let pixel_count = rgba.len() / 4;
let mut rgb = Vec::with_capacity(pixel_count * 3);
for chunk in rgba.chunks_exact(4) {
rgb.push(chunk[0]);
rgb.push(chunk[1]);
rgb.push(chunk[2]);
}
rgb
}
/// Saves a single `CapturedFrame` to a PNG file at the given path.
pub fn save_captured_frame_as_png(frame: &CapturedFrame, path: &Path) -> anyhow::Result<()> {
let mut frame = frame.clone();
frame.ensure_rgba();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(path)?;
let mut writer = std::io::BufWriter::new(file);
let encoder = image::codecs::png::PngEncoder::new_with_quality(
&mut writer,
image::codecs::png::CompressionType::Fast,
image::codecs::png::FilterType::NoFilter,
);
encoder.write_image(
&frame.data,
frame.width,
frame.height,
image::ColorType::Rgba8.into(),
)?;
Ok(())
}
/// Returns the directory where video recordings and screenshots should be
/// written, creating it if necessary.
pub fn output_dir() -> PathBuf {
let dir = std::env::var(VIDEO_DIR_ENV_VAR)
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("warp_integration_video_captures"));
std::fs::create_dir_all(&dir).ok();
dir
}
/// Helper to retrieve a mutable reference to the recorder from a `StepDataMap`.
pub fn get_recorder_mut(
step_data_map: &mut super::step::StepDataMap,
) -> Option<&mut VideoRecorder> {
step_data_map.get_mut::<_, VideoRecorder>(VIDEO_RECORDER_KEY)
}
/// Helper to retrieve a shared reference to the recorder from a `StepDataMap`.
pub fn get_recorder(step_data_map: &super::step::StepDataMap) -> Option<&VideoRecorder> {
step_data_map.get::<_, VideoRecorder>(VIDEO_RECORDER_KEY)
}