504 lines
19 KiB
Rust
504 lines
19 KiB
Rust
use std::error::Error;
|
|
|
|
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
|
use gpui::{Bounds, point, px, size};
|
|
|
|
use super::WebSurfaceStore;
|
|
use crate::shell::web_surface_state::WebSurfaceInputOutcome;
|
|
|
|
fn assert_applied(outcome: WebSurfaceInputOutcome) {
|
|
assert_eq!(outcome, WebSurfaceInputOutcome::Applied);
|
|
}
|
|
|
|
#[test]
|
|
fn typed_text_enters_pending_input_after_clicked_viewport() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab = web_tab("https://example.com/form")?;
|
|
|
|
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
|
|
assert_applied(store.record_click_point(
|
|
tab.id(),
|
|
tab.url().as_str(),
|
|
point(px(160.0), px(120.0)),
|
|
1.0,
|
|
));
|
|
assert_applied(store.record_typed_text(tab.id(), tab.url().as_str(), "e"));
|
|
assert_applied(store.record_typed_text(tab.id(), tab.url().as_str(), "l"));
|
|
|
|
let input = store.take_pending_input(tab.id(), tab.url().as_str());
|
|
|
|
assert_eq!(input.click_point.map(|point| (point.x(), point.y())), Some((160, 120)));
|
|
assert_eq!(input.typed_text.as_deref(), Some("el"));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn scroll_delta_enters_pending_input_after_wheel() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab = web_tab("https://example.com/list")?;
|
|
|
|
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
|
|
assert_applied(store.record_scroll_delta(
|
|
tab.id(),
|
|
tab.url().as_str(),
|
|
point(px(0.0), px(140.0)),
|
|
point(px(320.0), px(240.0)),
|
|
1.0,
|
|
));
|
|
assert_applied(store.record_scroll_delta(
|
|
tab.id(),
|
|
tab.url().as_str(),
|
|
point(px(0.0), px(60.0)),
|
|
point(px(300.0), px(220.0)),
|
|
1.0,
|
|
));
|
|
|
|
let input = store.take_pending_input(tab.id(), tab.url().as_str());
|
|
|
|
assert_eq!(input.scroll_offset.y(), 200);
|
|
assert_eq!(input.scroll_delta.map(|delta| (delta.x(), delta.y())), Some((0, 200)));
|
|
assert_eq!(input.scroll_point.map(|point| (point.x(), point.y())), Some((300, 220)));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn viewport_size_changes_after_stable_second_measurement() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab = web_tab("https://example.com/resize")?;
|
|
|
|
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
|
|
assert_eq!(
|
|
store.record_viewport_size(tab.id(), resized_once_bounds(), 1.0),
|
|
WebSurfaceInputOutcome::Buffered,
|
|
"first sighting of a new size must wait for a confirming second measurement",
|
|
);
|
|
assert_applied(store.record_viewport_size(tab.id(), resized_once_bounds(), 1.0));
|
|
Ok(())
|
|
}
|
|
|
|
/// Regression — a wheel scroll between focusing a field and typing
|
|
/// must not erase keyboard focus or buffered keystrokes.
|
|
///
|
|
/// The `record_scroll_delta` path drops `click_point` on purpose
|
|
/// (its viewport coordinates are stale once the page scrolls) but
|
|
/// must keep `keyboard_focus` and `typed_text`, so a focused input
|
|
/// keeps receiving the user's keystrokes after they wheel-scroll.
|
|
/// Locks the fix from commit fcac326-era input-loss work.
|
|
#[test]
|
|
fn scroll_after_click_keeps_keyboard_focus_and_typed_text() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab = web_tab("https://example.com/form")?;
|
|
let url = tab.url().as_str();
|
|
|
|
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
|
|
assert_applied(store.record_click_point(tab.id(), url, point(px(160.0), px(120.0)), 1.0));
|
|
assert_applied(store.record_typed_text(tab.id(), url, "h"));
|
|
|
|
assert_applied(store.record_scroll_delta(
|
|
tab.id(),
|
|
url,
|
|
point(px(0.0), px(140.0)),
|
|
point(px(160.0), px(120.0)),
|
|
1.0,
|
|
));
|
|
|
|
assert_eq!(
|
|
store.record_typed_text(tab.id(), url, "i"),
|
|
WebSurfaceInputOutcome::Applied,
|
|
"scroll must not erase keyboard focus — typing after a scroll should still buffer",
|
|
);
|
|
|
|
let input = store.take_pending_input(tab.id(), url);
|
|
|
|
assert_eq!(
|
|
input.scroll_delta.map(|delta| (delta.x(), delta.y())),
|
|
Some((0, 140)),
|
|
"scroll delta should reach the sidecar",
|
|
);
|
|
assert_eq!(input.scroll_offset.y(), 140);
|
|
assert!(
|
|
input.click_point.is_none(),
|
|
"post-scroll click coordinates would land on the wrong DOM node — they must be dropped",
|
|
);
|
|
assert_eq!(
|
|
input.typed_text.as_deref(),
|
|
Some("hi"),
|
|
"buffered keystrokes from before AND after the scroll must reach the sidecar",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Locks the Retina (2x) scale path: GPUI delivers logical pixels but
|
|
/// Servo expects device pixels, so every coordinate that crosses the
|
|
/// boundary must be multiplied by `window.scale_factor()`. Without
|
|
/// this regression a future refactor could silently revert to the
|
|
/// 1.0-only behavior that left every Retina click landing in the
|
|
/// upper-left quadrant of the page.
|
|
#[test]
|
|
fn retina_scale_factor_doubles_every_input_coordinate() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab = web_tab("https://example.com/form")?;
|
|
let url = tab.url().as_str();
|
|
|
|
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 2.0));
|
|
assert_applied(store.record_click_point(tab.id(), url, point(px(160.0), px(120.0)), 2.0));
|
|
assert_applied(store.record_typed_text(tab.id(), url, "h"));
|
|
assert_applied(store.record_scroll_delta(
|
|
tab.id(),
|
|
url,
|
|
point(px(0.0), px(140.0)),
|
|
point(px(160.0), px(120.0)),
|
|
2.0,
|
|
));
|
|
|
|
let input = store.take_pending_input(tab.id(), url);
|
|
|
|
assert_eq!(
|
|
input.scroll_delta.map(|delta| (delta.x(), delta.y())),
|
|
Some((0, 280)),
|
|
"wheel delta of 140 logical px must be 280 device px on Retina",
|
|
);
|
|
assert_eq!(input.scroll_offset.y(), 280, "scroll offset accumulates in device px");
|
|
assert_eq!(
|
|
input.click_point, None,
|
|
"scroll drops the buffered click — its viewport coords are stale",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Locks the precondition that `record_typed_text` requires a prior
|
|
/// click to have established keyboard focus. Without this guard, a
|
|
/// future refactor could silently start buffering stray keystrokes
|
|
/// against an unfocused tab — and the user would see characters land
|
|
/// on whichever DOM node Servo last focused, with no visible cause.
|
|
#[test]
|
|
fn typing_without_a_prior_click_is_rejected() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab = web_tab("https://example.com/form")?;
|
|
let url = tab.url().as_str();
|
|
|
|
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
|
|
assert_eq!(
|
|
store.record_typed_text(tab.id(), url, "x"),
|
|
WebSurfaceInputOutcome::DroppedNoKeyboardFocus,
|
|
"typing must fail until a click establishes keyboard focus on this tab and url",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Negative-path coverage for `DroppedNoViewportBounds`: a click that
|
|
/// arrives before the viewport has reported its bounds (race during
|
|
/// first paint) must surface as a typed outcome, not a silent `false`.
|
|
#[test]
|
|
fn click_before_viewport_measured_reports_no_viewport_bounds() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab = web_tab("https://example.com/form")?;
|
|
|
|
assert_eq!(
|
|
store.record_click_point(tab.id(), tab.url().as_str(), point(px(160.0), px(120.0)), 1.0,),
|
|
WebSurfaceInputOutcome::DroppedNoViewportBounds,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Negative-path coverage for `DroppedZeroDelta`: a wheel event whose
|
|
/// device-pixel delta rounds to zero must surface as the explicit
|
|
/// outcome so the renderer skips an unnecessary repaint.
|
|
#[test]
|
|
fn zero_wheel_delta_reports_zero_delta() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab = web_tab("https://example.com/list")?;
|
|
|
|
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
|
|
assert_eq!(
|
|
store.record_scroll_delta(
|
|
tab.id(),
|
|
tab.url().as_str(),
|
|
point(px(0.0), px(0.0)),
|
|
point(px(160.0), px(120.0)),
|
|
1.0,
|
|
),
|
|
WebSurfaceInputOutcome::DroppedZeroDelta,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Pinning the per-tab isolation invariant. A click recorded against
|
|
/// tab A must not be drained by, dropped by, or overwritten by any
|
|
/// state mutation routed to tab B. The store keys every click on its
|
|
/// owning `TabId` via `PerTabSurface`, but the singleton
|
|
/// `keyboard_focus` cross-cuts tabs — so a regression that
|
|
/// accidentally entangled them (e.g. dropping A's `click_point` when
|
|
/// B took focus) would surface here.
|
|
#[test]
|
|
fn click_on_tab_a_survives_click_on_tab_b() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab_a = web_tab("https://example.com/a")?;
|
|
let tab_b = web_tab("https://example.com/b")?;
|
|
|
|
assert_applied(store.record_viewport_size(tab_a.id(), web_bounds(), 1.0));
|
|
assert_applied(store.record_viewport_size(tab_b.id(), web_bounds(), 1.0));
|
|
|
|
assert_applied(store.record_click_point(
|
|
tab_a.id(),
|
|
tab_a.url().as_str(),
|
|
point(px(40.0), px(40.0)),
|
|
1.0,
|
|
));
|
|
assert_applied(store.record_click_point(
|
|
tab_b.id(),
|
|
tab_b.url().as_str(),
|
|
point(px(200.0), px(200.0)),
|
|
1.0,
|
|
));
|
|
|
|
let input_a = store.take_pending_input(tab_a.id(), tab_a.url().as_str());
|
|
assert_eq!(
|
|
input_a.click_point.map(|p| (p.x(), p.y())),
|
|
Some((40, 40)),
|
|
"tab A's click must survive a subsequent click on tab B — \
|
|
per-tab surfaces are independent owners of `click_point`",
|
|
);
|
|
|
|
let input_b = store.take_pending_input(tab_b.id(), tab_b.url().as_str());
|
|
assert_eq!(
|
|
input_b.click_point.map(|p| (p.x(), p.y())),
|
|
Some((200, 200)),
|
|
"tab B's click must drain into B's pending input, not A's",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Pinning the zero-delta short-circuit's non-effect on a buffered
|
|
/// click. `record_scroll_delta` wipes `click_point` (post-scroll
|
|
/// coords would target the wrong DOM node), but the early `None`
|
|
/// return for `DroppedZeroDelta` must short-circuit *before* the wipe.
|
|
/// A future refactor that moved the wipe above the delta check would
|
|
/// silently eat clicks whenever a precision-mouse wheel reported a
|
|
/// sub-device-pixel delta.
|
|
#[test]
|
|
fn zero_wheel_delta_must_not_erase_buffered_click() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab = web_tab("https://example.com/form")?;
|
|
let url = tab.url().as_str();
|
|
|
|
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
|
|
assert_applied(store.record_click_point(tab.id(), url, point(px(160.0), px(120.0)), 1.0));
|
|
|
|
assert_eq!(
|
|
store.record_scroll_delta(
|
|
tab.id(),
|
|
url,
|
|
point(px(0.0), px(0.0)),
|
|
point(px(160.0), px(120.0)),
|
|
1.0,
|
|
),
|
|
WebSurfaceInputOutcome::DroppedZeroDelta,
|
|
);
|
|
|
|
let input = store.take_pending_input(tab.id(), url);
|
|
assert_eq!(
|
|
input.click_point.map(|p| (p.x(), p.y())),
|
|
Some((160, 120)),
|
|
"a zero-delta wheel event reports DroppedZeroDelta and must not \
|
|
take the `click_point` wipe path — the early return guards it",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Pinning the bounds-vs-drain decoupling. A viewport resize between
|
|
/// click and drain leaves `viewport_bounds` mutated but does not
|
|
/// touch `click_point`, `scroll_offset`, or `requested_url` — the
|
|
/// three keys the drain filter checks. The click's stored device-px
|
|
/// coordinates remain valid against the new bounds because GPUI
|
|
/// re-renders before any new click can arrive.
|
|
///
|
|
/// If a future refactor stored raw window-relative coords on
|
|
/// `click_point` and converted them at drain time, a resize between
|
|
/// record and drain would shift the result; this test would still
|
|
/// drain "something" but the coordinates would change, surfacing the
|
|
/// drift.
|
|
#[test]
|
|
fn click_survives_viewport_bounds_change_before_drain() -> Result<(), Box<dyn Error>> {
|
|
let mut store = WebSurfaceStore::new();
|
|
let tab = web_tab("https://example.com/resize")?;
|
|
let url = tab.url().as_str();
|
|
|
|
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
|
|
assert_applied(store.record_click_point(tab.id(), url, point(px(160.0), px(120.0)), 1.0));
|
|
|
|
// Resize: first measurement is buffered (requires confirmation).
|
|
assert_eq!(
|
|
store.record_viewport_size(tab.id(), resized_once_bounds(), 1.0),
|
|
WebSurfaceInputOutcome::Buffered,
|
|
);
|
|
|
|
let input = store.take_pending_input(tab.id(), url);
|
|
assert_eq!(
|
|
input.click_point.map(|p| (p.x(), p.y())),
|
|
Some((160, 120)),
|
|
"a resize-in-progress must not steal the buffered click — \
|
|
the drain filter checks url+scroll_offset, not bounds",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Servo's `read_pixels(gl::RGBA, gl::UNSIGNED_BYTE)` writes R-G-B-A
|
|
/// in memory order. GPUI's `RenderImage` is documented as "in BGRA
|
|
/// format" and the macOS Metal atlas uploads bytes as
|
|
/// `MTLPixelFormat::BGRA8Unorm` (B-G-R-A in memory). If the bytes
|
|
/// crossed the boundary unchanged, the Metal sampler would read the R
|
|
/// byte as B and vice versa — every coloured pixel would render with
|
|
/// R and B swapped. `WebSurfaceFrame::from_live_frame` is responsible
|
|
/// for swapping the two channels before handing the buffer to GPUI.
|
|
///
|
|
/// This test fed `(R=255, G=0, B=0, A=255)` — one red RGBA pixel —
|
|
/// through the live-frame conversion and asserts the resulting
|
|
/// `RenderImage` carries `(B=0, G=0, R=255, A=255)` byte-for-byte.
|
|
#[test]
|
|
fn live_frame_swaps_red_and_blue_bytes_for_gpui_bgra() -> Result<(), Box<dyn Error>> {
|
|
use crate::services::servo_live::ServoLiveFrame;
|
|
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
|
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
|
|
|
let rgba_red_pixel = vec![255u8, 0, 0, 255];
|
|
let live = ServoLiveFrame::for_test(1, 1, rgba_red_pixel);
|
|
let frame = WebSurfaceFrame::from_live_frame(
|
|
"https://example.com/".to_string(),
|
|
WebSurfaceScrollOffset::default(),
|
|
100,
|
|
live,
|
|
)?;
|
|
|
|
let image = frame.image.as_ref().ok_or("software frame must produce a RenderImage")?;
|
|
let bytes = image.as_bytes(0).ok_or("RenderImage must expose its first frame's bytes")?;
|
|
assert_eq!(
|
|
bytes,
|
|
&[0u8, 0, 255, 255],
|
|
"Servo's RGBA red pixel must be swapped to BGRA (B=0, G=0, R=255, A=255) \
|
|
before GPUI uploads it as BGRA8Unorm. If this assert says \
|
|
`[255, 0, 0, 255]`, the swap is missing and every coloured \
|
|
pixel on the page renders with R and B exchanged.",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn empty_live_frame_payload_is_rejected() -> Result<(), String> {
|
|
use crate::services::servo_live::ServoLiveFrame;
|
|
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
|
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
|
|
|
let result = WebSurfaceFrame::from_live_frame(
|
|
"https://example.com/".to_string(),
|
|
WebSurfaceScrollOffset::default(),
|
|
100,
|
|
ServoLiveFrame::for_test(1, 1, Vec::new()),
|
|
);
|
|
|
|
let error = match result {
|
|
Ok(_) => return Err("empty Servo frame payload reached Ready state".to_string()),
|
|
Err(error) => error,
|
|
};
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"servo live frame did not include a software image or hardware IOSurface",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[test]
|
|
fn hardware_live_frame_with_pixel_buffer_skips_software_image() -> Result<(), String> {
|
|
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
|
|
|
use crate::services::servo_live::ServoLiveFrame;
|
|
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
|
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
|
|
|
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 1, 1, None)
|
|
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
|
let live = ServoLiveFrame::for_test_with_pixel_buffer(1, 1, pixel_buffer);
|
|
let frame = WebSurfaceFrame::from_live_frame(
|
|
"https://example.com/".to_string(),
|
|
WebSurfaceScrollOffset::default(),
|
|
100,
|
|
live,
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
|
|
assert!(frame.image.is_none(), "hardware frame should use the CVPixelBuffer surface path");
|
|
assert!(frame.pixel_buffer.is_some(), "hardware frame should carry the imported CVPixelBuffer");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[test]
|
|
fn hardware_live_frame_rejects_mismatched_surface_size() -> Result<(), String> {
|
|
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
|
|
|
use crate::services::servo_live::ServoLiveFrame;
|
|
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
|
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
|
|
|
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 2, 1, None)
|
|
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
|
let live = ServoLiveFrame::for_test_with_pixel_buffer(1, 1, pixel_buffer);
|
|
let result = WebSurfaceFrame::from_live_frame(
|
|
"https://example.com/".to_string(),
|
|
WebSurfaceScrollOffset::default(),
|
|
100,
|
|
live,
|
|
);
|
|
|
|
let error = match result {
|
|
Ok(_) => return Err("mismatched hardware surface reached Ready state".to_string()),
|
|
Err(error) => error,
|
|
};
|
|
assert_eq!(error.to_string(), "servo hardware surface size 2x1 did not match frame report 1x1",);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[test]
|
|
fn hardware_live_frame_rejects_unsupported_surface_format() -> Result<(), String> {
|
|
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange};
|
|
|
|
use crate::services::servo_live::ServoLiveFrame;
|
|
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
|
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
|
|
|
let pixel_buffer =
|
|
CVPixelBuffer::new(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, 2, 2, None)
|
|
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
|
let live = ServoLiveFrame::for_test_with_pixel_buffer(2, 2, pixel_buffer);
|
|
let result = WebSurfaceFrame::from_live_frame(
|
|
"https://example.com/".to_string(),
|
|
WebSurfaceScrollOffset::default(),
|
|
100,
|
|
live,
|
|
);
|
|
|
|
let error = match result {
|
|
Ok(_) => return Err("unsupported hardware surface format reached Ready state".to_string()),
|
|
Err(error) => error,
|
|
};
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"servo hardware surface pixel format 0x34323066 is unsupported; expected 32BGRA",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn web_bounds() -> Bounds<gpui::Pixels> {
|
|
Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)))
|
|
}
|
|
|
|
fn resized_once_bounds() -> Bounds<gpui::Pixels> {
|
|
Bounds::new(point(px(0.0), px(0.0)), size(px(934.0), px(657.0)))
|
|
}
|
|
|
|
fn web_tab(url: &str) -> Result<BrowserTab, Box<dyn Error>> {
|
|
Ok(BrowserTab::new(TabId::new(), SpaceId::new(), ProfileId::new(), "Web", UrlText::parse(url)?))
|
|
}
|