Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user