Skip to main content

tessera_ui/
headless.rs

1//! Offscreen rendering, input injection, and resource observations.
2
3use std::{
4    io::{BufRead, BufReader, Write},
5    num::NonZero,
6    path::Path,
7    time::Duration,
8};
9
10use serde::Deserialize;
11use serde_json::{Value, json};
12use tracing::info;
13
14use crate::{
15    NodeId, Px, PxPosition, PxSize,
16    build_tree::build_component_tree,
17    component_tree::{ComponentNodeMetaDatas, ComponentNodeTree, ComputeMode, ComputeParams},
18    context::reset_context_read_dependencies,
19    cursor::{
20        CursorEventContent, CursorState, GestureState, MOUSE_POINTER_ID, PointerChange, PointerId,
21        PressKeyEventType, ScrollDeltaUnit, ScrollEventContent, ScrollEventSource,
22    },
23    dp::set_scale_factor,
24    focus::flush_pending_focus_callbacks,
25    ime_state::ImeState,
26    keyboard_state::KeyboardState,
27    pipeline_context::PipelineContext,
28    render_module::RenderModule,
29    renderer::{
30        MAX_FRAME_SETTLING_RETRIES, RenderCore, TesseraConfig, core::OffscreenReadbackError,
31        frame_settling_can_progress,
32    },
33    runtime::{
34        TesseraRuntime, begin_frame_build_invalidations, begin_frame_clock,
35        clear_persistent_focus_handles, clear_redraw_waker, has_pending_build_invalidations,
36        has_pending_layout_invalidations, has_ready_build_invalidations,
37        prune_persistent_focus_handles, reset_build_invalidations, reset_component_replay_tracking,
38        reset_focus_read_dependencies, reset_frame_clock, reset_layout_dirty_tracking,
39        reset_render_slot_read_dependencies, reset_state_read_dependencies,
40        take_layout_dirty_nodes, tick_frame_nanos_receivers,
41    },
42    time::Instant,
43};
44
45#[cfg(feature = "profiling")]
46use crate::profiler::{
47    FrameMeta, begin_frame as profiler_begin_frame, begin_frame_summary,
48    end_frame as profiler_end_frame, submit_frame_meta, submit_frame_summary,
49};
50
51/// Environment variable that switches a Tessera application into headless mode.
52pub const HEADLESS_ENV: &str = "TESSERA_HEADLESS";
53/// Environment variable overriding the initial headless surface width.
54pub const HEADLESS_WIDTH_ENV: &str = "TESSERA_HEADLESS_WIDTH";
55/// Environment variable overriding the initial headless surface height.
56pub const HEADLESS_HEIGHT_ENV: &str = "TESSERA_HEADLESS_HEIGHT";
57/// Environment variable overriding physical pixels per density-independent
58/// pixel.
59pub const HEADLESS_SCALE_FACTOR_ENV: &str = "TESSERA_HEADLESS_SCALE_FACTOR";
60/// Environment variable overriding the virtual frame step in milliseconds.
61pub const HEADLESS_FRAME_TIME_MS_ENV: &str = "TESSERA_HEADLESS_FRAME_TIME_MS";
62
63/// Returns whether the current process should run in headless mode.
64pub fn headless_mode_requested() -> bool {
65    match std::env::var(HEADLESS_ENV) {
66        Ok(value) => {
67            let value = value.trim();
68            !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
69        }
70        Err(_) => false,
71    }
72}
73
74/// Errors produced by headless session setup and the JSONL worker loop.
75#[derive(Debug)]
76pub enum HeadlessError {
77    /// An I/O error while reading commands or writing responses.
78    Io(std::io::Error),
79    /// A malformed JSONL command.
80    Json(serde_json::Error),
81    /// The GPU render core could not be initialized.
82    GpuInit(String),
83    /// Reading pixels back from the offscreen target failed.
84    Readback(OffscreenReadbackError),
85}
86
87impl std::fmt::Display for HeadlessError {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Self::Io(err) => write!(f, "headless io error: {err}"),
91            Self::Json(err) => write!(f, "headless protocol error: {err}"),
92            Self::GpuInit(err) => write!(f, "headless gpu init failed: {err}"),
93            Self::Readback(err) => write!(f, "headless readback failed: {err}"),
94        }
95    }
96}
97
98impl std::error::Error for HeadlessError {}
99
100impl From<std::io::Error> for HeadlessError {
101    fn from(err: std::io::Error) -> Self {
102        Self::Io(err)
103    }
104}
105
106impl From<serde_json::Error> for HeadlessError {
107    fn from(err: serde_json::Error) -> Self {
108        Self::Json(err)
109    }
110}
111
112impl From<OffscreenReadbackError> for HeadlessError {
113    fn from(err: OffscreenReadbackError) -> Self {
114        Self::Readback(err)
115    }
116}
117
118/// Configuration for a headless renderer session.
119#[derive(Clone, Debug)]
120pub struct HeadlessConfig {
121    /// Offscreen target width in physical pixels.
122    pub width: u32,
123    /// Offscreen target height in physical pixels.
124    pub height: u32,
125    /// Physical pixels per density-independent pixel; invalid values use 1.0.
126    pub scale_factor: f64,
127    /// MSAA sample count for render pipelines.
128    pub sample_count: u32,
129    /// Positive D3D12 non-sampler binding capacity; defaults to 65,536.
130    pub max_non_sampler_bindings: u32,
131    /// Output texture format; RGBA8 is required for sRGB-encoded readback and
132    /// PNG output.
133    pub format: wgpu::TextureFormat,
134    /// Virtual time advanced per rendered frame.
135    pub frame_time: Duration,
136}
137
138impl Default for HeadlessConfig {
139    fn default() -> Self {
140        Self {
141            width: 800,
142            height: 600,
143            scale_factor: 1.0,
144            sample_count: 1,
145            max_non_sampler_bindings: crate::renderer::DEFAULT_MAX_NON_SAMPLER_BINDINGS,
146            format: wgpu::TextureFormat::Rgba8UnormSrgb,
147            frame_time: Duration::from_nanos(16_666_667),
148        }
149    }
150}
151
152/// A deterministic, non-interactive Tessera renderer session.
153///
154/// Frames advance on a virtual clock, so a fixed input script always produces
155/// the same output regardless of wall-clock timing.
156pub struct HeadlessRenderer {
157    session: crate::RuntimeSession,
158    core: RenderCore,
159    entry: Box<dyn Fn()>,
160    cursor: CursorState,
161    keyboard: KeyboardState,
162    ime: ImeState,
163    size: PxSize,
164    physical_size: winit::dpi::PhysicalSize<u32>,
165    frame_index: u64,
166    frame_origin: Instant,
167    current_frame_nanos: u64,
168    frame_time: Duration,
169    diagnostics: crate::component_tree::LayoutFrameDiagnostics,
170    work: crate::FrameWork,
171    work_totals: crate::WorkTotals,
172    frame_clock_diagnostics: crate::FrameClockDiagnostics,
173    frame_graphics_work: crate::GraphicsWorkDiagnostics,
174
175    #[cfg(feature = "profiling")]
176    profile_summary: bool,
177}
178
179impl HeadlessRenderer {
180    /// Returns the GPU instance for resource observations across renderer lifetimes.
181    pub fn gpu_instance(&self) -> &wgpu::Instance {
182        &self.core.instance
183    }
184
185    /// Waits for GPU completion and reports held resource handles and allocator usage.
186    ///
187    /// Allocator reservations include unused capacity and are not resident VRAM.
188    /// Unsupported byte counters and allocator reports are null.
189    pub fn resource_snapshot(&self) -> Result<Value, wgpu::PollError> {
190        let device = self.core.device();
191        device.poll(wgpu::PollType::wait_indefinitely())?;
192        let adapter = device.adapter_info();
193        let byte_counters = matches!(adapter.backend, wgpu::Backend::Vulkan | wgpu::Backend::Dx12);
194        let counters = device.get_internal_counters().hal;
195        let resources = self.core.instance.generate_report();
196        let allocator = device.generate_allocator_report().map(|report| {
197            json!({
198                "allocated_bytes": report.total_allocated_bytes,
199                "reserved_bytes": report.total_reserved_bytes,
200                "allocations": report.allocations.iter().map(|allocation| json!({
201                    "name": allocation.name,
202                    "bytes": allocation.size,
203                })).collect::<Vec<_>>(),
204            })
205        });
206        Ok(json!({
207            "adapter": adapter.name,
208            "backend": format!("{:?}", adapter.backend),
209            "max_non_sampler_bindings": device.limits().max_non_sampler_bindings,
210            "buffers": resources.as_ref().map(|r| r.hub.buffers.num_kept_from_user),
211            "textures": resources.as_ref().map(|r| r.hub.textures.num_kept_from_user),
212            "texture_views": resources.as_ref().map(|r| r.hub.texture_views.num_kept_from_user),
213            "bind_groups": resources.as_ref().map(|r| r.hub.bind_groups.num_kept_from_user),
214            "render_pipelines": resources.as_ref().map(|r| r.hub.render_pipelines.num_kept_from_user),
215            "compute_pipelines": resources.as_ref().map(|r| r.hub.compute_pipelines.num_kept_from_user),
216            "buffer_bytes": byte_counters.then(|| counters.buffer_memory.read()),
217            "texture_bytes": byte_counters.then(|| counters.texture_memory.read()),
218            "allocator": allocator,
219            "graphics_work": self.graphics_work(),
220        }))
221    }
222
223    /// Captures cumulative resource creation and uploads performed by pipelines.
224    pub fn graphics_work(&self) -> crate::GraphicsWorkDiagnostics {
225        self.core.resources().graphics.diagnostics()
226    }
227
228    /// Returns actual resource operations performed in the last completed frame.
229    pub fn frame_graphics_work(&self) -> &crate::GraphicsWorkDiagnostics {
230        &self.frame_graphics_work
231    }
232    /// Returns the runtime identity shared by this session's snapshots and diagnostics.
233    pub fn session_id(&self) -> crate::RuntimeSessionId {
234        self.session.identity()
235    }
236
237    /// Runs state or controller operations in this renderer's session.
238    pub fn run<R>(&self, work: impl FnOnce() -> R) -> R {
239        self.session.run(work)
240    }
241
242    /// Creates a headless renderer for the given entry point and render
243    /// modules.
244    pub fn new<Output: 'static>(
245        entry: Box<dyn Fn() -> Output>,
246        modules: Vec<Box<dyn RenderModule>>,
247        config: HeadlessConfig,
248    ) -> Result<Self, HeadlessError> {
249        let session = crate::RuntimeSession::for_root();
250        session.activate();
251        let width = config.width.max(1);
252        let height = config.height.max(1);
253        let physical_size = winit::dpi::PhysicalSize::new(width, height);
254
255        let mut core = pollster::block_on(RenderCore::new_offscreen(
256            physical_size,
257            config.format,
258            config.sample_count,
259            config.max_non_sampler_bindings,
260        ));
261
262        let scale_factor = normalize_scale_factor(config.scale_factor);
263        set_scale_factor(scale_factor);
264
265        {
266            let mut context = PipelineContext::new(&mut core);
267            for module in &modules {
268                module.register_pipelines(&mut context);
269            }
270        }
271
272        reset_headless_runtime(physical_size);
273
274        info!(
275            "Headless renderer initialized at {}x{} ({} samples, {:?})",
276            width, height, config.sample_count, config.format
277        );
278
279        Ok(Self {
280            session,
281            core,
282            entry: Box::new(move || {
283                entry();
284            }),
285            cursor: CursorState::default(),
286            keyboard: KeyboardState::default(),
287            ime: ImeState::default(),
288            size: PxSize::new(Px(width as i32), Px(height as i32)),
289            physical_size,
290            frame_index: 0,
291            frame_origin: Instant::now(),
292            current_frame_nanos: 0,
293            frame_time: config.frame_time.max(Duration::from_nanos(1)),
294            diagnostics: Default::default(),
295            work: Default::default(),
296            work_totals: Default::default(),
297            frame_clock_diagnostics: Default::default(),
298            frame_graphics_work: Default::default(),
299
300            #[cfg(feature = "profiling")]
301            profile_summary: false,
302        })
303    }
304
305    /// Returns the current offscreen size in physical pixels.
306    pub fn physical_size(&self) -> winit::dpi::PhysicalSize<u32> {
307        let _session = self.session.enter();
308        self.physical_size
309    }
310
311    /// Returns the number of frames rendered so far.
312    pub fn frame_index(&self) -> u64 {
313        let _session = self.session.enter();
314        self.frame_index
315    }
316
317    /// Returns the work performed by the most recently rendered frame.
318    pub fn diagnostics(&self) -> crate::component_tree::LayoutFrameDiagnostics {
319        let _session = self.session.enter();
320        self.diagnostics
321    }
322
323    /// Queues a synthetic pointer change.
324    ///
325    /// Movement changes also update the tracked cursor position so hit testing
326    /// observes the new location on the next frame.
327    pub fn inject_pointer(&mut self, change: PointerChange) {
328        let _session = self.session.enter();
329        if let Some(position) = change.position_snapshot() {
330            self.cursor
331                .update_pointer_position(change.pointer_id, position);
332        }
333        self.cursor.push_event(change);
334    }
335
336    /// Moves the given pointer to `position`.
337    pub fn pointer_move(&mut self, pointer_id: PointerId, position: PxPosition) {
338        let _session = self.session.enter();
339        self.cursor.update_pointer_position(pointer_id, position);
340        self.cursor.push_event(PointerChange {
341            timestamp: Instant::now(),
342            pointer_id,
343            content: CursorEventContent::Moved(position),
344            gesture_state: GestureState::TapCandidate,
345            sampled_position: Default::default(),
346            consumed: false,
347            tap_claimed: false,
348            scroll_discrete: None,
349        });
350    }
351
352    /// Presses a pointer button at `position`.
353    pub fn pointer_press(
354        &mut self,
355        pointer_id: PointerId,
356        position: PxPosition,
357        button: PressKeyEventType,
358    ) {
359        let _session = self.session.enter();
360        self.cursor.update_pointer_position(pointer_id, position);
361        self.cursor.push_event(PointerChange {
362            timestamp: Instant::now(),
363            pointer_id,
364            content: CursorEventContent::Pressed(button),
365            gesture_state: GestureState::TapCandidate,
366            sampled_position: Default::default(),
367            consumed: false,
368            tap_claimed: false,
369            scroll_discrete: None,
370        });
371    }
372
373    /// Releases a pointer button at `position`.
374    pub fn pointer_release(
375        &mut self,
376        pointer_id: PointerId,
377        position: PxPosition,
378        button: PressKeyEventType,
379    ) {
380        let _session = self.session.enter();
381        self.cursor.update_pointer_position(pointer_id, position);
382        self.cursor.push_event(PointerChange {
383            timestamp: Instant::now(),
384            pointer_id,
385            content: CursorEventContent::Released(button),
386            gesture_state: GestureState::TapCandidate,
387            sampled_position: Default::default(),
388            consumed: false,
389            tap_claimed: false,
390            scroll_discrete: None,
391        });
392    }
393
394    /// Queues a mouse-wheel scroll at `position`.
395    pub fn pointer_scroll(
396        &mut self,
397        pointer_id: PointerId,
398        position: PxPosition,
399        delta_x: f32,
400        delta_y: f32,
401    ) {
402        let _session = self.session.enter();
403        self.cursor.update_pointer_position(pointer_id, position);
404        self.cursor.push_event(PointerChange {
405            timestamp: Instant::now(),
406            pointer_id,
407            content: CursorEventContent::Scroll(ScrollEventContent {
408                delta_x,
409                delta_y,
410                unit: ScrollDeltaUnit::Pixel,
411                source: ScrollEventSource::Wheel,
412            }),
413            gesture_state: GestureState::TapCandidate,
414            sampled_position: Default::default(),
415            consumed: false,
416            tap_claimed: false,
417            scroll_discrete: None,
418        });
419    }
420
421    /// Starts a touch contact at the given window position.
422    pub fn touch_start(&mut self, touch_id: u64, position: PxPosition) {
423        let _session = self.session.enter();
424        self.cursor.handle_touch_start(touch_id, position);
425    }
426
427    /// Moves a touch contact, generating scrolling after touch slop is crossed.
428    pub fn touch_move(&mut self, touch_id: u64, position: PxPosition) {
429        let _session = self.session.enter();
430        if let Some(scroll) = self.cursor.handle_touch_move(touch_id, position) {
431            self.cursor.push_event(scroll);
432        }
433    }
434
435    /// Releases a touch contact and completes its gesture.
436    pub fn touch_end(&mut self, touch_id: u64) {
437        let _session = self.session.enter();
438        self.cursor.handle_touch_end(touch_id);
439    }
440
441    /// Cancels a touch contact without producing a tap or fling.
442    pub fn touch_cancel(&mut self, touch_id: u64) {
443        let _session = self.session.enter();
444        self.cursor.handle_touch_cancel(touch_id);
445    }
446
447    /// Queues a native or owned keyboard sample for the next frame.
448    pub fn inject_key_event(&mut self, event: impl Into<crate::KeyboardEvent>) {
449        let _session = self.session.enter();
450        let event = event.into();
451        TesseraRuntime::with_mut(|runtime| {
452            runtime
453                .component_tree
454                .focus_owner_mut()
455                .record_keyboard_input(event.state, Instant::now())
456        });
457        self.keyboard.push_event(event);
458    }
459
460    /// Queues a non-repeating press of a named key for the next frame.
461    pub fn press_key(&mut self, key: winit::keyboard::NamedKey) {
462        self.inject_key_event(crate::KeyboardEvent::named(
463            key,
464            winit::event::ElementState::Pressed,
465        ));
466    }
467
468    /// Sets the modifier state captured by subsequent keyboard events.
469    pub fn set_key_modifiers(&mut self, modifiers: winit::keyboard::ModifiersState) {
470        let _session = self.session.enter();
471        self.keyboard.update_modifiers(modifiers);
472    }
473
474    /// Queues an IME event, used for synthetic text entry.
475    pub fn inject_ime_event(&mut self, event: winit::event::Ime) {
476        let _session = self.session.enter();
477        self.ime.push_event(event);
478    }
479
480    /// Commits `text` as IME input for the next frame.
481    pub fn inject_text(&mut self, text: &str) {
482        let _session = self.session.enter();
483        self.ime
484            .push_event(winit::event::Ime::Commit(text.to_string()));
485    }
486
487    /// Advances the virtual clock by one frame and renders it.
488    pub fn step_frame(&mut self) {
489        let _session = self.session.enter();
490        #[cfg(feature = "profiling")]
491        let wait_timer = Instant::now();
492        // Offscreen rendering has no swapchain to bound frames in flight.
493        if self.frame_index.is_multiple_of(2) {
494            self.core
495                .device()
496                .poll(wgpu::PollType::wait_indefinitely())
497                .expect("headless GPU frame completion failed");
498        }
499        #[cfg(feature = "profiling")]
500        let wait_duration = wait_timer.elapsed();
501        TesseraRuntime::with_mut(|runtime| runtime.component_tree.begin_frame_diagnostics());
502        let graphics_before = self.graphics_work();
503        crate::work::begin();
504        self.current_frame_nanos = self
505            .current_frame_nanos
506            .saturating_add(self.frame_time.as_nanos().min(u64::MAX as u128) as u64);
507        let frame_time = self.frame_origin + Duration::from_nanos(self.current_frame_nanos);
508        #[cfg(feature = "profiling")]
509        if self.profile_summary {
510            begin_frame_summary(self.frame_index);
511        } else {
512            profiler_begin_frame(self.frame_index);
513        }
514        #[cfg(feature = "profiling")]
515        let frame_timer = Instant::now();
516        begin_frame_clock(frame_time);
517        begin_frame_build_invalidations();
518        crate::asset::settle();
519        // Tick frame-nanos receivers before the build so their state writes are
520        // consumed by the current recomposition pass.
521        tick_frame_nanos_receivers();
522
523        let mut _build_tree_result = build_component_tree(&self.entry);
524
525        #[cfg(feature = "profiling")]
526        let initial_build_duration = _build_tree_result.duration();
527        #[cfg(feature = "profiling")]
528        let draw_timer = Instant::now();
529        let cursor_position = self.cursor.position();
530        let mut pointer_changes = Some(self.cursor.take_events());
531        let mut keyboard_events = Some(self.keyboard.take_events());
532        let mut ime_events = Some(self.ime.take_events());
533        let modifiers = self.keyboard.modifiers();
534        let screen_size = self.size;
535        let mut retry_focus_move = None;
536        let mut retry_focus_reveal = false;
537        let mut retries = 0;
538        let mut _record_cost = Duration::ZERO;
539        let mut _diagnostics = crate::component_tree::LayoutFrameDiagnostics::default();
540        let graph = loop {
541            let layout_dirty_nodes = take_layout_dirty_nodes();
542            let (graph, _requests, diagnostics, record_cost, next_move, next_reveal, next_keyboard) =
543                TesseraRuntime::with_mut(|runtime| {
544                    runtime.component_tree.compute(
545                        ComputeParams {
546                            screen_size,
547                            cursor_position,
548                            pointer_changes: pointer_changes.take().unwrap_or_default(),
549                            keyboard_events: keyboard_events.take().unwrap_or_default(),
550                            ime_events: ime_events.take().unwrap_or_default(),
551                            retry_focus_move,
552                            retry_focus_reveal,
553                            modifiers,
554                            layout_dirty_nodes,
555                        },
556                        ComputeMode::Full,
557                    )
558                });
559            _record_cost += record_cost;
560            _diagnostics.accumulate_pass(diagnostics);
561            flush_pending_focus_callbacks();
562            crate::component_tree::flush_pending_globally_positioned_callbacks();
563            let next_keyboard = next_keyboard
564                || TesseraRuntime::with(|runtime| {
565                    runtime.component_tree.has_pending_focus_reveal()
566                });
567            let focus_retry = next_move.is_some() || next_reveal || next_keyboard;
568            if !focus_retry && !frame_settling_can_progress(false) {
569                break graph;
570            }
571            if retries >= MAX_FRAME_SETTLING_RETRIES
572                || (focus_retry && !frame_settling_can_progress(next_keyboard))
573            {
574                if next_reveal
575                    && (has_pending_build_invalidations() || has_pending_layout_invalidations())
576                {
577                    TesseraRuntime::with_mut(|runtime| {
578                        runtime
579                            .component_tree
580                            .focus_owner_mut()
581                            .retain_reveal_retry();
582                    });
583                }
584                break graph;
585            }
586            retries += 1;
587            retry_focus_move = next_move;
588            retry_focus_reveal = next_reveal;
589            if focus_retry || has_ready_build_invalidations() {
590                _build_tree_result.absorb_retry(build_component_tree(&self.entry));
591            }
592        };
593        self.diagnostics = _diagnostics;
594        prune_persistent_focus_handles();
595        #[cfg(feature = "profiling")]
596        let draw_duration = draw_timer.elapsed().saturating_sub(
597            _build_tree_result
598                .duration()
599                .saturating_sub(initial_build_duration),
600        );
601
602        #[cfg(feature = "profiling")]
603        let render_timer = Instant::now();
604        #[cfg(feature = "debug-dirty-overlay")]
605        self.core.render(graph, &[]);
606        #[cfg(not(feature = "debug-dirty-overlay"))]
607        self.core.render(graph);
608        #[cfg(feature = "profiling")]
609        let render_duration = render_timer.elapsed();
610        self.cursor.frame_cleanup();
611        #[cfg(feature = "profiling")]
612        {
613            let breakdown = self.core.last_render_breakdown();
614            let metadata_timer = tracing::enabled!(tracing::Level::DEBUG).then(Instant::now);
615            let nodes = if self.profile_summary {
616                Vec::new()
617            } else {
618                TesseraRuntime::with_mut(|rt| rt.component_tree.profiler_nodes())
619            };
620            if let Some(metadata_timer) = metadata_timer {
621                tracing::debug!(
622                    frame_index = self.frame_index,
623                    elapsed_us = metadata_timer.elapsed().as_micros(),
624                    "headless_profiler_metadata_cpu"
625                );
626            }
627            let frame_duration = frame_timer.elapsed();
628            #[cfg(not(target_arch = "wasm32"))]
629            let render_gpu_ns = self.core.read_gpu_time().map(|time| time.as_nanos());
630            #[cfg(target_arch = "wasm32")]
631            let render_gpu_ns = None;
632            let metadata = FrameMeta {
633                frame_idx: self.frame_index,
634                build_mode: _build_tree_result.profiler_build_mode(),
635                redraw_reasons: Vec::new(),
636                inter_frame_wait_ns: Some(wait_duration.as_nanos()),
637                partial_replay_nodes: _build_tree_result.partial_replay_nodes(),
638                total_nodes_before_build: _build_tree_result.total_nodes_before_build(),
639                render_time_ns: Some(render_duration.as_nanos()),
640                render_prepare_ns: None,
641                render_acquire_ns: breakdown.map(|timing| timing.acquire.as_nanos()),
642                render_build_passes_ns: breakdown.map(|timing| timing.build_passes.as_nanos()),
643                render_encode_ns: breakdown.map(|timing| timing.encode.as_nanos()),
644                render_submit_ns: breakdown.map(|timing| timing.submit.as_nanos()),
645                render_present_ns: breakdown.map(|timing| timing.present.as_nanos()),
646                render_gpu_ns,
647                build_tree_time_ns: Some(_build_tree_result.duration().as_nanos()),
648                draw_time_ns: Some(draw_duration.as_nanos()),
649                record_time_ns: Some(_record_cost.as_nanos()),
650                frame_total_ns: Some(frame_duration.as_nanos()),
651                layout_diagnostics: Some(_diagnostics),
652                nodes,
653            };
654            if self.profile_summary {
655                submit_frame_summary(metadata);
656            } else {
657                submit_frame_meta(metadata);
658            }
659            profiler_end_frame();
660        }
661
662        self.frame_clock_diagnostics = crate::runtime::frame_clock_diagnostics();
663
664        self.work = crate::work::finish();
665        self.work_totals.record(&self.work);
666        self.frame_graphics_work = self.graphics_work().since(&graphics_before);
667        self.frame_index = self.frame_index.wrapping_add(1);
668    }
669
670    /// Renders exactly `frames` frames (at least one).
671    pub fn render_frames(&mut self, frames: u32) {
672        let _session = self.session.enter();
673        for _ in 0..frames.max(1) {
674            self.step_frame();
675        }
676    }
677
678    /// Renders frames until at least `duration` of virtual time has elapsed.
679    pub fn render_for_duration(&mut self, duration: Duration) {
680        let _session = self.session.enter();
681        let mut elapsed = Duration::ZERO;
682        loop {
683            self.step_frame();
684            elapsed += self.frame_time;
685            if elapsed >= duration {
686                break;
687            }
688        }
689    }
690
691    /// Reads the last rendered frame back as tightly packed RGBA8 pixels.
692    pub fn read_pixels(&self) -> Result<Vec<u8>, HeadlessError> {
693        let _session = self.session.enter();
694        Ok(self.core.read_offscreen_rgba()?)
695    }
696
697    /// Resizes the offscreen target and updates the runtime window size.
698    pub fn resize(&mut self, width: u32, height: u32) {
699        let _session = self.session.enter();
700        let width = width.max(1);
701        let height = height.max(1);
702        let physical_size = winit::dpi::PhysicalSize::new(width, height);
703        self.physical_size = physical_size;
704        self.size = PxSize::new(Px(width as i32), Px(height as i32));
705        self.core.resize(physical_size);
706        self.core.resize_surface();
707        TesseraRuntime::with_mut(|runtime| runtime.window_size = [width, height]);
708    }
709
710    /// Returns actual component, layout, and paint work in the last frame.
711    pub fn work(&self) -> &crate::FrameWork {
712        &self.work
713    }
714
715    /// Returns cumulative work for this fixture's lifetime.
716    pub fn work_totals(&self) -> &crate::WorkTotals {
717        &self.work_totals
718    }
719
720    /// Returns frame-clock work and remaining subscriptions for the last frame.
721    pub fn frame_clock_diagnostics(&self) -> &crate::FrameClockDiagnostics {
722        &self.frame_clock_diagnostics
723    }
724
725    /// Captures typed component bounds, labels, and test tags for inspection.
726    #[cfg(any(test, feature = "testing"))]
727    pub fn inspect_components(&self) -> crate::testing::ComponentSnapshot {
728        let _session = self.session.enter();
729        crate::testing::ComponentSnapshot::capture()
730    }
731
732    /// Captures the complete published accessibility tree with typed queries.
733    #[cfg(any(test, feature = "testing"))]
734    pub fn inspect_accessibility(&self) -> Option<crate::testing::AccessibilitySnapshot> {
735        self.accessibility_snapshot()
736            .map(|tree| crate::testing::AccessibilitySnapshot::new(tree, self.session.identity()))
737    }
738
739    /// Captures a semantic snapshot of the component tree as JSON.
740    ///
741    /// Each node reports its component function name, role, absolute bounds and
742    /// accessibility metadata (role, label, value, key and state flags), which
743    /// is what an automated agent needs to locate and target widgets.
744    pub fn snapshot(&self) -> Value {
745        let _session = self.session.enter();
746        TesseraRuntime::with(|runtime| {
747            let tree = runtime.component_tree.tree();
748            let metadatas = runtime.component_tree.metadatas();
749            let root = tree.get_node_id_at(NonZero::new(1).expect("root index is non-zero"));
750            let mut counter = 0u64;
751            let root_value = match root {
752                Some(root) => snapshot_node(tree, metadatas, root, &mut counter),
753                None => Value::Null,
754            };
755            json!({
756                "width": self.physical_size.width,
757                "height": self.physical_size.height,
758                "scale_factor": crate::dp::scale_factor(),
759                "frame_index": self.frame_index,
760                "node_count": counter,
761                "root": root_value,
762            })
763        })
764    }
765
766    /// Returns the accessibility tree published to assistive technologies.
767    ///
768    /// Use this after rendering a frame to verify semantic roles, reading
769    /// order, visibility, actions, and focus independently of layout
770    /// snapshots.
771    pub fn accessibility_snapshot(&self) -> Option<accesskit::TreeUpdate> {
772        let _session = self.session.enter();
773        TesseraRuntime::with_mut(|runtime| runtime.component_tree.accessibility_update(None, true))
774    }
775
776    /// Dispatches an assistive-technology action to a published node.
777    ///
778    /// Returns whether a focus target or action handler received the request.
779    /// Render another frame to publish resulting state and focus changes.
780    pub fn dispatch_accessibility_action(&mut self, request: accesskit::ActionRequest) -> bool {
781        let _session = self.session.enter();
782        let handled = TesseraRuntime::with_mut(|runtime| {
783            let (tree, metadatas, focus_owner) =
784                runtime.component_tree.accessibility_dispatch_context();
785            crate::accessibility::dispatch_action(tree, metadatas, focus_owner, request)
786        });
787        flush_pending_focus_callbacks();
788        handled
789    }
790}
791
792fn snapshot_coordinate(value: f32) -> Value {
793    if value.fract() == 0.0 {
794        json!(value as i64)
795    } else {
796        json!(value)
797    }
798}
799
800fn snapshot_node(
801    tree: &ComponentNodeTree,
802    metadatas: &ComponentNodeMetaDatas,
803    node_id: NodeId,
804    counter: &mut u64,
805) -> Value {
806    *counter += 1;
807    let node = tree.get(node_id);
808    let fn_name = node.map_or("<unknown>", |node| node.get().fn_name);
809    let role = node
810        .map(|node| format!("{:?}", node.get().role))
811        .unwrap_or_else(|| "<unknown>".to_string());
812
813    let metadata = metadatas.get(&node_id);
814    let bounds = metadata
815        .and_then(|metadata| {
816            let position = metadata.abs_position?;
817            let size = metadata.computed_data?;
818            let transform = metadata.local_to_window.unwrap_or_else(|| {
819                crate::LocalToWindow::new(1.0, position).expect("Placed origin must be finite")
820            });
821            let bounds = transform.map_rect(crate::FloatRect::from_position_size(
822                PxPosition::ZERO,
823                PxSize::new(size.width, size.height),
824            ));
825            Some(json!({
826                "x": snapshot_coordinate(bounds.x),
827                "y": snapshot_coordinate(bounds.y),
828                "width": snapshot_coordinate(bounds.width),
829                "height": snapshot_coordinate(bounds.height),
830            }))
831        })
832        .unwrap_or(Value::Null);
833
834    let accessibility = metadata
835        .and_then(|metadata| metadata.accessibility.as_ref())
836        .map(|accessibility| {
837            let accessibility = accessibility.snapshot();
838            json!({
839                "role": accessibility.role.map(|role| format!("{role:?}")),
840                "label": accessibility.label,
841                "value": accessibility.value,
842                "key": accessibility.key,
843                "focusable": accessibility.focusable,
844                "focused": accessibility.focused,
845                "disabled": accessibility.disabled,
846                "expanded": accessibility.expanded,
847                "has_popup": accessibility.has_popup.map(|popup| format!("{popup:?}")),
848                "hidden": accessibility.hidden,
849            })
850        })
851        .unwrap_or(Value::Null);
852
853    let children: Vec<Value> = node_id
854        .children(tree)
855        .map(|child| snapshot_node(tree, metadatas, child, counter))
856        .collect();
857
858    json!({
859        "fn_name": fn_name,
860        "role": role,
861        "bounds": bounds,
862        "accessibility": accessibility,
863        "children": children,
864    })
865}
866
867fn reset_headless_runtime(size: winit::dpi::PhysicalSize<u32>) {
868    TesseraRuntime::with_mut(|runtime| {
869        runtime.component_tree.reset();
870        runtime.cursor_icon_request = None;
871        runtime.window_minimized = false;
872        runtime.window_size = [size.width, size.height];
873    });
874    reset_layout_dirty_tracking();
875    reset_component_replay_tracking();
876    reset_focus_read_dependencies();
877    reset_render_slot_read_dependencies();
878    reset_state_read_dependencies();
879    reset_context_read_dependencies();
880    reset_build_invalidations();
881    reset_frame_clock();
882    clear_redraw_waker();
883    clear_persistent_focus_handles();
884    crate::runtime::reset_component_slots();
885}
886
887/// Runs the worker side of the `cargo tessera headless` protocol.
888///
889/// Reads one JSON command per line from stdin and writes one JSON response per
890/// line to stdout. Diagnostics are written to stderr so stdout stays a clean
891/// JSONL stream.
892pub fn run_headless(
893    entry: Box<dyn Fn()>,
894    modules: Vec<Box<dyn RenderModule>>,
895    config: TesseraConfig,
896) -> Result<(), HeadlessError> {
897    init_headless_tracing();
898
899    let headless_config = HeadlessConfig {
900        width: env_u32(HEADLESS_WIDTH_ENV).unwrap_or(800),
901        height: env_u32(HEADLESS_HEIGHT_ENV).unwrap_or(600),
902        scale_factor: std::env::var(HEADLESS_SCALE_FACTOR_ENV)
903            .ok()
904            .and_then(|value| value.trim().parse().ok())
905            .map(normalize_scale_factor)
906            .unwrap_or(1.0),
907        sample_count: config.sample_count.max(1),
908        max_non_sampler_bindings: config.max_non_sampler_bindings,
909        ..HeadlessConfig::default()
910    };
911    let mut frame_time = headless_config.frame_time;
912    if let Some(ms) = env_u32(HEADLESS_FRAME_TIME_MS_ENV) {
913        frame_time = Duration::from_millis(ms.max(1) as u64);
914    }
915
916    let mut renderer = HeadlessRenderer::new(entry, modules, headless_config)?;
917    renderer.frame_time = frame_time;
918    #[cfg(feature = "profiling")]
919    {
920        crate::profiler::set_output_path(crate::renderer::resolve_profiler_output_path(&config));
921        renderer.profile_summary = std::env::var("TESSERA_HEADLESS_PROFILE")
922            .is_ok_and(|value| value.eq_ignore_ascii_case("summary"));
923    }
924
925    let stdout = std::io::stdout();
926    let mut out = stdout.lock();
927    write_line(
928        &mut out,
929        json!({
930            "event": "ready",
931            "width": renderer.physical_size.width,
932            "height": renderer.physical_size.height,
933            "scale_factor": crate::dp::scale_factor(),
934        }),
935    )?;
936
937    let stdin = std::io::stdin();
938    let reader = BufReader::new(stdin.lock());
939    for line in reader.lines() {
940        let line = line?;
941        let line = line.trim();
942        if line.is_empty() {
943            continue;
944        }
945
946        let request = match serde_json::from_str::<WorkerRequest>(line) {
947            Ok(request) => request,
948            Err(err) => {
949                write_line(
950                    &mut out,
951                    json!({"ok": false, "error": format!("invalid command: {err}")}),
952                )?;
953                continue;
954            }
955        };
956
957        let is_shutdown = matches!(&request, WorkerRequest::Shutdown { .. });
958        let (id, mut response) = handle_request(&mut renderer, request)?;
959        if let Some(map) = response.as_object_mut() {
960            if let Some(id) = id {
961                map.insert("id".to_string(), json!(id));
962            }
963        }
964        write_line(&mut out, response)?;
965
966        if is_shutdown {
967            break;
968        }
969    }
970
971    Ok(())
972}
973
974fn handle_request(
975    renderer: &mut HeadlessRenderer,
976    request: WorkerRequest,
977) -> Result<(Option<u64>, Value), HeadlessError> {
978    match request {
979        WorkerRequest::Input {
980            id,
981            kind,
982            pointer_id,
983            x,
984            y,
985            delta_x,
986            delta_y,
987            button,
988        } => {
989            let pointer_id = pointer_id.unwrap_or(MOUSE_POINTER_ID);
990            let position = PxPosition::from_f32_xy(x.unwrap_or(0.0), y.unwrap_or(0.0));
991            let button = button.unwrap_or_default().to_press_key();
992            match kind {
993                InputKind::Press => renderer.pointer_press(pointer_id, position, button),
994                InputKind::Release => renderer.pointer_release(pointer_id, position, button),
995                InputKind::Move => renderer.pointer_move(pointer_id, position),
996                InputKind::Scroll => renderer.pointer_scroll(
997                    pointer_id,
998                    position,
999                    delta_x.unwrap_or(0.0),
1000                    delta_y.unwrap_or(0.0),
1001                ),
1002            }
1003            Ok((id, json!({"ok": true, "kind": kind.as_str()})))
1004        }
1005        WorkerRequest::Text { id, text } => {
1006            renderer.inject_text(&text);
1007            Ok((id, json!({"ok": true, "chars": text.chars().count()})))
1008        }
1009        WorkerRequest::Render {
1010            id,
1011            frames,
1012            duration_ms,
1013            out,
1014        } => {
1015            let rendered = if let Some(duration_ms) = duration_ms {
1016                renderer.render_for_duration(Duration::from_millis(duration_ms.max(1)));
1017                None
1018            } else {
1019                let frames = frames.unwrap_or(1).max(1);
1020                renderer.render_frames(frames);
1021                Some(frames)
1022            };
1023
1024            let size = renderer.physical_size();
1025            let mut response = json!({
1026                "ok": true,
1027                "width": size.width,
1028                "height": size.height,
1029                "frame_index": renderer.frame_index(),
1030            });
1031            if let Some(frames) = rendered {
1032                response["frames"] = json!(frames);
1033            }
1034
1035            if let Some(out_path) = out {
1036                let pixels = renderer.read_pixels()?;
1037                let png = encode_png_rgba(size.width, size.height, &pixels);
1038                std::fs::write(&out_path, &png)?;
1039                response["path"] = json!(out_path);
1040                response["bytes"] = json!(png.len());
1041            }
1042            Ok((id, response))
1043        }
1044        WorkerRequest::Snapshot { id } => {
1045            let snapshot = renderer.snapshot();
1046            Ok((id, json!({"ok": true, "snapshot": snapshot})))
1047        }
1048        WorkerRequest::Resources { id } => {
1049            let response = match renderer.resource_snapshot() {
1050                Ok(resources) => json!({"ok": true, "resources": resources}),
1051                Err(error) => json!({"ok": false, "error": error.to_string()}),
1052            };
1053            Ok((id, response))
1054        }
1055        WorkerRequest::Resize { id, width, height } => {
1056            renderer.resize(width, height);
1057            let size = renderer.physical_size();
1058            Ok((
1059                id,
1060                json!({"ok": true, "width": size.width, "height": size.height}),
1061            ))
1062        }
1063        WorkerRequest::Shutdown { id } => {
1064            let _ = id;
1065            Ok((id, json!({"ok": true, "event": "shutdown"})))
1066        }
1067    }
1068}
1069
1070fn write_line(out: &mut impl Write, value: Value) -> Result<(), HeadlessError> {
1071    let encoded = serde_json::to_string(&value)?;
1072    out.write_all(encoded.as_bytes())?;
1073    out.write_all(b"\n")?;
1074    out.flush()?;
1075    Ok(())
1076}
1077
1078fn env_u32(key: &str) -> Option<u32> {
1079    std::env::var(key)
1080        .ok()
1081        .and_then(|value| value.trim().parse::<u32>().ok())
1082}
1083
1084fn normalize_scale_factor(value: f64) -> f64 {
1085    if value.is_finite() && value > 0.0 {
1086        value
1087    } else {
1088        1.0
1089    }
1090}
1091
1092fn init_headless_tracing() {
1093    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
1094        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("error,tessera_ui=info"));
1095    // Keep stdout reserved for the JSONL protocol.
1096    let _ = tracing_subscriber::fmt()
1097        .with_env_filter(filter)
1098        .with_writer(std::io::stderr)
1099        .try_init();
1100}
1101
1102#[derive(Clone, Copy, Debug, Default, Deserialize)]
1103#[serde(rename_all = "snake_case")]
1104enum InputKind {
1105    #[default]
1106    Press,
1107    Release,
1108    Move,
1109    Scroll,
1110}
1111
1112impl InputKind {
1113    fn as_str(self) -> &'static str {
1114        match self {
1115            Self::Press => "press",
1116            Self::Release => "release",
1117            Self::Move => "move",
1118            Self::Scroll => "scroll",
1119        }
1120    }
1121}
1122
1123#[derive(Clone, Copy, Debug, Default, Deserialize)]
1124#[serde(rename_all = "snake_case")]
1125enum ButtonKind {
1126    #[default]
1127    Left,
1128    Right,
1129    Middle,
1130}
1131
1132impl ButtonKind {
1133    fn to_press_key(self) -> PressKeyEventType {
1134        match self {
1135            Self::Left => PressKeyEventType::Left,
1136            Self::Right => PressKeyEventType::Right,
1137            Self::Middle => PressKeyEventType::Middle,
1138        }
1139    }
1140}
1141
1142#[derive(Debug, Deserialize)]
1143#[serde(tag = "cmd", rename_all = "snake_case")]
1144enum WorkerRequest {
1145    Input {
1146        #[serde(default)]
1147        id: Option<u64>,
1148        kind: InputKind,
1149        #[serde(default)]
1150        pointer_id: Option<u64>,
1151        #[serde(default)]
1152        x: Option<f32>,
1153        #[serde(default)]
1154        y: Option<f32>,
1155        #[serde(default)]
1156        delta_x: Option<f32>,
1157        #[serde(default)]
1158        delta_y: Option<f32>,
1159        #[serde(default)]
1160        button: Option<ButtonKind>,
1161    },
1162    Text {
1163        #[serde(default)]
1164        id: Option<u64>,
1165        text: String,
1166    },
1167    Render {
1168        #[serde(default)]
1169        id: Option<u64>,
1170        #[serde(default)]
1171        frames: Option<u32>,
1172        #[serde(default)]
1173        duration_ms: Option<u64>,
1174        #[serde(default)]
1175        out: Option<String>,
1176    },
1177    Snapshot {
1178        #[serde(default)]
1179        id: Option<u64>,
1180    },
1181    Resources {
1182        #[serde(default)]
1183        id: Option<u64>,
1184    },
1185    Resize {
1186        #[serde(default)]
1187        id: Option<u64>,
1188        width: u32,
1189        height: u32,
1190    },
1191    Shutdown {
1192        #[serde(default)]
1193        id: Option<u64>,
1194    },
1195}
1196
1197/// Encodes tightly packed RGBA8 pixels as a PNG image.
1198///
1199/// Uses stored (uncompressed) DEFLATE blocks so no compression dependency is
1200/// required; the resulting file is larger than a compressed PNG but fully
1201/// valid and readable by any PNG consumer.
1202pub fn encode_png_rgba(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> {
1203    let row_bytes = width as usize * 4;
1204    let mut raw = Vec::with_capacity((height as usize) * (1 + row_bytes));
1205    for row in 0..height as usize {
1206        raw.push(0);
1207        let start = row * row_bytes;
1208        raw.extend_from_slice(&pixels[start..start + row_bytes]);
1209    }
1210
1211    let mut out = Vec::with_capacity(raw.len() + 1024);
1212    out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
1213
1214    let mut ihdr = Vec::with_capacity(13);
1215    ihdr.extend_from_slice(&width.to_be_bytes());
1216    ihdr.extend_from_slice(&height.to_be_bytes());
1217    ihdr.push(8); // bit depth
1218    ihdr.push(6); // color type: RGBA
1219    ihdr.push(0); // compression method
1220    ihdr.push(0); // filter method
1221    ihdr.push(0); // interlace method
1222    write_png_chunk(&mut out, b"IHDR", &ihdr);
1223    write_png_chunk(&mut out, b"IDAT", &zlib_store(&raw));
1224    write_png_chunk(&mut out, b"IEND", &[]);
1225    out
1226}
1227
1228fn write_png_chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
1229    out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1230    out.extend_from_slice(kind);
1231    out.extend_from_slice(data);
1232
1233    let mut crc = Crc32::new();
1234    crc.update(kind);
1235    crc.update(data);
1236    out.extend_from_slice(&crc.finish().to_be_bytes());
1237}
1238
1239fn zlib_store(data: &[u8]) -> Vec<u8> {
1240    let mut out = Vec::with_capacity(data.len() + data.len() / 65_535 * 5 + 16);
1241    // zlib header: deflate, 32K window, default compression level.
1242    out.push(0x78);
1243    out.push(0x01);
1244
1245    if data.is_empty() {
1246        out.extend_from_slice(&[0x01, 0x00, 0x00, 0xFF, 0xFF]);
1247    }
1248
1249    let mut offset = 0;
1250    while offset < data.len() {
1251        let remaining = data.len() - offset;
1252        let block_len = remaining.min(65_535);
1253        let is_final = offset + block_len >= data.len();
1254        out.push(if is_final { 0x01 } else { 0x00 });
1255        let len = block_len as u16;
1256        out.extend_from_slice(&len.to_le_bytes());
1257        out.extend_from_slice(&(!len).to_le_bytes());
1258        out.extend_from_slice(&data[offset..offset + block_len]);
1259        offset += block_len;
1260    }
1261
1262    out.extend_from_slice(&adler32(data).to_be_bytes());
1263    out
1264}
1265
1266fn adler32(data: &[u8]) -> u32 {
1267    const MOD: u32 = 65_521;
1268    let mut a: u32 = 1;
1269    let mut b: u32 = 0;
1270    for &byte in data {
1271        a = (a + byte as u32) % MOD;
1272        b = (b + a) % MOD;
1273    }
1274    (b << 16) | a
1275}
1276
1277struct Crc32 {
1278    table: [u32; 256],
1279    state: u32,
1280}
1281
1282impl Crc32 {
1283    fn new() -> Self {
1284        let mut table = [0u32; 256];
1285        for (index, entry) in table.iter_mut().enumerate() {
1286            let mut value = index as u32;
1287            for _ in 0..8 {
1288                value = if value & 1 != 0 {
1289                    0xEDB8_8320 ^ (value >> 1)
1290                } else {
1291                    value >> 1
1292                };
1293            }
1294            *entry = value;
1295        }
1296        Self {
1297            table,
1298            state: 0xFFFF_FFFF,
1299        }
1300    }
1301
1302    fn update(&mut self, data: &[u8]) {
1303        for &byte in data {
1304            let index = ((self.state ^ byte as u32) & 0xFF) as usize;
1305            self.state = self.table[index] ^ (self.state >> 8);
1306        }
1307    }
1308
1309    fn finish(self) -> u32 {
1310        self.state ^ 0xFFFF_FFFF
1311    }
1312}
1313
1314/// Writes a PNG file containing `pixels` and returns the byte length written.
1315pub fn write_png_file(
1316    path: impl AsRef<Path>,
1317    width: u32,
1318    height: u32,
1319    pixels: &[u8],
1320) -> Result<usize, HeadlessError> {
1321    let png = encode_png_rgba(width, height, pixels);
1322    std::fs::write(path, &png)?;
1323    Ok(png.len())
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329
1330    #[test]
1331    fn adler32_matches_reference_values() {
1332        let _runtime_session = crate::RuntimeSession::new();
1333        let _runtime_scope = _runtime_session.enter();
1334        assert_eq!(adler32(b""), 1);
1335        assert_eq!(adler32(b"Wikipedia"), 0x11E6_0398);
1336    }
1337
1338    #[test]
1339    fn crc32_matches_reference_value() {
1340        let _runtime_session = crate::RuntimeSession::new();
1341        let _runtime_scope = _runtime_session.enter();
1342        let mut crc = Crc32::new();
1343        crc.update(b"123456789");
1344        assert_eq!(crc.finish(), 0xCBF4_3926);
1345    }
1346
1347    #[test]
1348    fn png_encoder_emits_valid_structure() {
1349        let _runtime_session = crate::RuntimeSession::new();
1350        let _runtime_scope = _runtime_session.enter();
1351        let width = 2;
1352        let height = 1;
1353        let pixels = vec![255, 0, 0, 255, 0, 255, 0, 255];
1354        let png = encode_png_rgba(width, height, &pixels);
1355
1356        assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
1357        assert_eq!(&png[12..16], b"IHDR");
1358        assert_eq!(&png[16..20], &2u32.to_be_bytes());
1359        assert_eq!(&png[20..24], &1u32.to_be_bytes());
1360        assert_eq!(png[png.len() - 8..png.len() - 4], *b"IEND");
1361        assert!(png.windows(4).any(|window| window == b"IDAT"));
1362    }
1363}
1364
1365#[cfg(test)]
1366#[path = "headless_protocol_tests.rs"]
1367mod protocol_tests;