openpencil/crates/op-host-native/tests/common/mod.rs
Kayshen-X 47e9e3cac6 refactor(host): gate op-host-native GL stack behind opt-in gl-host feature
Phase 0 of the op-web-daemon extraction. op-host-native now defaults to a
raster-only library: with the new NON-default gl-host feature off, it pulls no
winit/glutin/jian-host-desktop/accesskit and skia-safe WITHOUT gl (no GL link
directives). GUI consumers (op-host-desktop) opt in via features=[gl-host], so
the future headless op-web-daemon can depend on op-host-native
(default-features=false) for the raster export path without the desktop GUI stack.

- Move NativeFrameBackend widget_host/ -> backend/ (raster; no longer drags the
  interactive host into the raster build).
- Gate widget_host/preview/boolean_ops/canvas_view_stub + GlutinProvider /
  SharedSkiaContext behind gl-host. skia gl arrives only via skia-safe/gl;
  accesskit made optional. Mobile-safe: default features = no gl-host.

Verified: raster + desktop + app build green; 385 lib tests pass under gl-host;
cargo tree confirms the raster build is winit/glutin/skia-gl free. Codex-approved.
2026-06-19 17:47:34 +08:00

245 lines
9.4 KiB
Rust

#![cfg(feature = "gl-host")]
//! Shared test helpers for `op-host-native`.
//!
//! Spec v19 §9 / plan v7 Task 2 Step 16a-16f: this module supplies
//! - `setup_headless_context()` for tests that pin lifecycle
//! idempotence on a fully torn-down context (teardown, tracing);
//! - `RasterCycle` for tests that need real Skia resources (RSS
//! sanity loop) without spinning up a GL stack — this is the
//! macOS / Windows / no-GPU-Linux memory-loop path;
//! - `egl_pbuffer::EglPbufferProvider` (Linux only) for the GPU-smoke
//! tests, providing an off-screen GL context via Mesa softpipe.
#![allow(dead_code)]
use op_host_native::SharedSkiaContext;
/// Returns a `SharedSkiaContext` whose every `Option<>` field is
/// `None`. The tests that exercise idempotence + tracing don't care
/// what's behind the handles — they care that the public API behaves
/// the same on a torn-down context. Avoids the per-test cost of
/// spinning up an EGL pbuffer / winit window.
///
/// **Do not use for resource-accounting tests** — see
/// `RasterCycle` below. (Codex Phase A Gate round 1 BLOCK 3.)
pub fn setup_headless_context() -> SharedSkiaContext {
SharedSkiaContext::inert_for_lifecycle_test()
}
/// Real-resource cycle helper for the memory-loop test (Codex Phase A
/// Gate round 1 BLOCK 3 fix).
///
/// Runs `iterations` of `{ build raster Skia surface → paint via
/// `NativeBackend::fill_rect` → drop the surface }` against a real
/// `skia_safe::Surface`. This exercises the same Skia allocation
/// paths (`raster_n32_premul`, `Canvas::draw_rect`) that the GPU
/// path goes through after `wrap_backend_render_target`, so the RSS
/// budget assertion measures real driver-side accounting rather than
/// a phantom no-op.
///
/// We intentionally do **not** route through `SharedSkiaContext`
/// here: that struct's full lifecycle requires a `GlContextProvider`,
/// and on hosted-CI macOS / Windows runners the only realistic
/// no-display GL stack is a winit window (which can't run on a
/// worker thread, see `gpu_smoke.rs`). Raster surfaces are the
/// largest non-GL allocation we can hammer in a `cargo test`
/// process; they're enough to flush a leak in the Skia bindings or
/// our `to_jian_*` translation layer.
pub fn raster_memory_cycle(iterations: usize, side: i32) {
use op_editor_ui::{Color, Point2D, Rect};
use op_host_native::NativeBackend;
let mut backend = NativeBackend::with_dpi(1.0);
for _ in 0..iterations {
let mut surface = skia_safe::surfaces::raster_n32_premul((side, side))
.expect("raster_n32_premul allocated");
// Fill a real rectangle to prevent the optimiser from eliding
// the surface and to force Skia to actually touch the GPU
// (raster) backing memory.
backend.fill_rect(
surface.canvas(),
Rect {
origin: Point2D::new(10.0, 10.0),
size: Point2D::new((side - 20) as f32, (side - 20) as f32),
},
Color::RED,
);
// Surface drops here — Skia must release its raster backing.
drop(surface);
}
}
#[cfg(target_os = "linux")]
pub mod egl_pbuffer {
//! `EglPbufferProvider` — a `GlContextProvider` backed by a Mesa
//! softpipe EGL pbuffer. No display server / X11 / Wayland needed,
//! so the test runs on hosted Linux runners (GitHub Actions
//! `ubuntu-latest` ships Mesa). Spec §9.2 + plan v7 Task 2 Step
//! 16f Linux path.
//!
//! The provider is tiny on purpose — it only does what the GPU
//! smoke + chrome-stub composition tests need, and it intentionally
//! does not try to unify with the desktop `GlutinProvider` API
//! beyond the `GlContextProvider` trait.
use std::ffi::c_void;
use std::sync::Arc;
use khronos_egl as egl;
use op_host_native::{GlContextProvider, ProviderError, ProviderResult};
type Egl = egl::DynamicInstance<egl::EGL1_4>;
pub struct EglPbufferProvider {
egl: Arc<Egl>,
display: egl::Display,
context: egl::Context,
surface: egl::Surface,
glow: Arc<glow::Context>,
released: bool,
}
impl EglPbufferProvider {
pub fn new(size: (u32, u32)) -> ProviderResult<Self> {
let egl: Arc<Egl> = unsafe {
Arc::new(
egl::DynamicInstance::<egl::EGL1_4>::load_required()
.map_err(|e| ProviderError::from_msg(format!("libEGL load: {e}")))?,
)
};
// SAFETY: khronos-egl 6.x marks `get_display` unsafe (it dereferences
// a raw display pointer). DEFAULT_DISPLAY is a well-known sentinel
// (NULL on most Linux platforms) handled correctly by libEGL.
let display = unsafe { egl.get_display(egl::DEFAULT_DISPLAY) }
.ok_or_else(|| ProviderError::from_msg("no default EGL display"))?;
egl.initialize(display)
.map_err(|e| ProviderError::from_msg(format!("EGL init: {e}")))?;
egl.bind_api(egl::OPENGL_API)
.map_err(|e| ProviderError::from_msg(format!("EGL bind_api: {e}")))?;
let attribs = [
egl::SURFACE_TYPE,
egl::PBUFFER_BIT,
egl::RENDERABLE_TYPE,
egl::OPENGL_BIT,
egl::RED_SIZE,
8,
egl::GREEN_SIZE,
8,
egl::BLUE_SIZE,
8,
egl::ALPHA_SIZE,
8,
egl::DEPTH_SIZE,
0,
egl::STENCIL_SIZE,
8,
egl::NONE,
];
let config = egl
.choose_first_config(display, &attribs)
.map_err(|e| ProviderError::from_msg(format!("choose_config: {e}")))?
.ok_or_else(|| ProviderError::from_msg("no matching EGL config"))?;
let context_attribs = [egl::NONE];
let context = egl
.create_context(display, config, None, &context_attribs)
.map_err(|e| ProviderError::from_msg(format!("create_context: {e}")))?;
let pbuffer_attribs = [
egl::WIDTH,
size.0 as i32,
egl::HEIGHT,
size.1 as i32,
egl::NONE,
];
let surface = egl
.create_pbuffer_surface(display, config, &pbuffer_attribs)
.map_err(|e| ProviderError::from_msg(format!("create_pbuffer: {e}")))?;
egl.make_current(display, Some(surface), Some(surface), Some(context))
.map_err(|e| ProviderError::from_msg(format!("make_current: {e}")))?;
let egl_for_loader = Arc::clone(&egl);
let glow_ctx = unsafe {
glow::Context::from_loader_function(move |sym| {
egl_for_loader
.get_proc_address(sym)
.map(|p| p as *const c_void)
.unwrap_or(std::ptr::null())
})
};
Ok(Self {
egl,
display,
context,
surface,
glow: Arc::new(glow_ctx),
released: false,
})
}
}
impl GlContextProvider for EglPbufferProvider {
fn make_current(&mut self) -> ProviderResult<()> {
self.egl
.make_current(
self.display,
Some(self.surface),
Some(self.surface),
Some(self.context),
)
.map_err(|e| ProviderError::from_msg(format!("make_current: {e}")))
}
fn swap_buffers(&mut self) -> ProviderResult<()> {
// Pbuffers don't have a real "swap" — the EGL spec defines
// it as a no-op. Treat the call as success so the present
// path still runs to completion.
self.egl
.swap_buffers(self.display, self.surface)
.map_err(|e| ProviderError::from_msg(format!("swap_buffers: {e}")))
}
fn glow(&self) -> Arc<glow::Context> {
self.glow.clone()
}
/// EGL pbuffer default FBO = 0 (matches `GlutinProvider`).
/// Phase A Gate round 2 CONCERN 1 fix — explicit override
/// required, no trait default body.
fn default_framebuffer_id(&self) -> u32 {
0
}
fn resize(&mut self, _w: u32, _h: u32) -> ProviderResult<()> {
// Pbuffer can't be resized once created — tests build the
// surface at the size they need.
Ok(())
}
fn release(&mut self) -> ProviderResult<()> {
if self.released {
return Ok(());
}
// Make-not-current first; some Mesa builds segfault if the
// pbuffer surface drops while still bound.
let _ = self.egl.make_current(self.display, None, None, None);
let _ = self.egl.destroy_surface(self.display, self.surface);
let _ = self.egl.destroy_context(self.display, self.context);
// Keep the EGL display open for other tests in the same
// process; eglTerminate is process-global on Mesa and a
// shared display is normal.
self.released = true;
Ok(())
}
}
impl Drop for EglPbufferProvider {
fn drop(&mut self) {
let _ = self.release();
}
}
}