1mod basic_chrome;
5mod basic_context_menu;
6mod chrome;
7mod context_menu;
8mod renderer;
9
10pub use basic_chrome::BasicChrome;
11pub use basic_context_menu::{BasicContextMenu, MenuItem};
12pub use chrome::{Chrome, ChromeAction, ChromeEventResult};
13pub use context_menu::{ClickContext, ContextMenu, MenuEventResult};
14pub use renderer::BrowserRenderer;
15
16use std::collections::HashMap;
17
18use url::Url;
19use winit::event::{ElementState, Ime, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
20
21use crate::browser::Tab;
22use crate::browser::core::resource_loader::{BrowserNetworkError, BrowserResponse};
23use crate::browser::core::tab::{FetchKind, TabTask};
24use crate::engine::layouter::types::ColorScheme;
25use crate::engine::renderer_model::{DrawCommand, Rect};
26use crate::engine::ui::PointerEvent;
27use crate::engine::ui::input_text_types::{InputTextEvent, InputTextKey};
28use crate::platform::renderer::gpu::GpuRenderer;
29
30use super::BrowserCommand;
31
32#[derive(Debug, Clone)]
34pub struct RenderState {
35 pub draw_commands: Vec<DrawCommand>,
37 pub window_size: (u32, u32),
39 pub scale_factor: f64,
41 pub window_title: String,
43}
44
45impl Default for RenderState {
46 fn default() -> Self {
47 Self {
48 draw_commands: Vec::new(),
49 window_size: (1280, 800),
50 scale_factor: 1.0,
51 window_title: String::new(),
52 }
53 }
54}
55
56impl RenderState {
57 pub fn new(window_size: (u32, u32), scale_factor: f64, window_title: String) -> Self {
59 Self {
60 draw_commands: Vec::new(),
61 window_size,
62 scale_factor,
63 window_title,
64 }
65 }
66
67 pub fn viewport(&self) -> (f32, f32) {
69 let sf = self.scale_factor as f32;
70 (
71 self.window_size.0 as f32 / sf,
72 self.window_size.1 as f32 / sf,
73 )
74 }
75}
76
77#[derive(Default)]
79struct InputState {
80 mouse_position: (f64, f64),
82 modifiers: winit::keyboard::ModifiersState,
84}
85
86pub(crate) struct TabFetchRequest {
88 pub(crate) tab_id: TabId,
89 pub(crate) request: FetchRequest,
90}
91
92pub struct FetchRequest {
93 pub url: Url,
94 pub kind: FetchKind,
95 pub origin: crate::engine::origin::Origin,
97}
98
99pub(crate) struct BrowserUiTick {
101 pub(crate) fetches: Vec<TabFetchRequest>,
102 pub(crate) needs_redraw: bool,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107pub struct TabId(pub usize);
108
109impl std::fmt::Display for TabId {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 write!(f, "{:?}", self)
112 }
113}
114
115pub struct BrowserUi {
123 tabs: HashMap<TabId, Tab>,
124 active_tab_id: TabId,
125 next_tab_id: TabId,
126 renderer: BrowserRenderer,
127 input: InputState,
128
129 system_color_scheme: ColorScheme,
131}
132
133impl Default for BrowserUi {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139impl BrowserUi {
140 pub fn new() -> Self {
143 Self::with_chrome(Box::new(BasicChrome::new()))
144 }
145
146 pub fn with_tab(tab: Tab) -> Self {
148 Self::with_tab_and_chrome(tab, Box::new(BasicChrome::new()))
149 }
150
151 pub fn with_chrome(chrome: Box<dyn Chrome>) -> Self {
154 Self::with_chrome_and_menu(chrome, Box::new(BasicContextMenu::new()))
155 }
156
157 pub fn with_tab_and_chrome(tab: Tab, chrome: Box<dyn Chrome>) -> Self {
160 Self::with_tab_and_menu(tab, chrome, Box::new(BasicContextMenu::new()))
161 }
162
163 pub fn with_chrome_and_menu(chrome: Box<dyn Chrome>, menu: Box<dyn ContextMenu>) -> Self {
165 Self {
166 tabs: HashMap::new(),
167 active_tab_id: TabId(0),
168 next_tab_id: TabId(1),
169 renderer: BrowserRenderer::with_chrome_and_menu(chrome, menu),
170 input: InputState::default(),
171 system_color_scheme: dark_light::detect().map(Into::into).unwrap_or_else(|e| {
172 log::error!("Failed to detect system color scheme, using default: {e}");
173 Default::default()
174 }),
175 }
176 }
177
178 pub fn with_tab_and_menu(
180 mut tab: Tab,
181 chrome: Box<dyn Chrome>,
182 menu: Box<dyn ContextMenu>,
183 ) -> Self {
184 let system_color_scheme = dark_light::detect().map(Into::into).unwrap_or_else(|e| {
185 log::error!("Failed to detect system color scheme, using default: {e}");
186 Default::default()
187 });
188
189 tab.set_system_color_scheme(system_color_scheme);
190
191 let tab_id = TabId(1);
192
193 let mut tabs = HashMap::new();
194 tabs.insert(tab_id, tab);
195
196 Self {
197 tabs,
198 active_tab_id: tab_id,
199 next_tab_id: TabId(tab_id.0 + 1),
200 renderer: BrowserRenderer::with_chrome_and_menu(chrome, menu),
201 input: InputState::default(),
202 system_color_scheme,
203 }
204 }
205
206 pub fn active_tab_id(&self) -> Option<TabId> {
208 if self.active_tab_id.0 == 0 {
209 None
210 } else {
211 Some(self.active_tab_id)
212 }
213 }
214
215 pub fn active_tab(&self) -> Option<&Tab> {
216 self.active_tab_id().and_then(|id| self.tabs.get(&id))
217 }
218
219 pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
220 self.active_tab_id().and_then(|id| self.tabs.get_mut(&id))
221 }
222
223 pub fn tab(&self, id: &TabId) -> Option<&Tab> {
225 self.tabs.get(id)
226 }
227
228 pub fn tab_mut(&mut self, id: &TabId) -> Option<&mut Tab> {
229 self.tabs.get_mut(id)
230 }
231
232 pub fn add_tab(&mut self, mut tab: Tab) {
233 tab.set_system_color_scheme(self.system_color_scheme);
234 self.next_tab_id.0 += 1;
235 }
236
237 pub fn set_window(&mut self, window_size: (u32, u32), scale_factor: f64, window_title: String) {
239 self.renderer
240 .set_window(window_size, scale_factor, window_title);
241 }
242
243 pub fn window_size(&self) -> (u32, u32) {
245 self.renderer.render_state.window_size
246 }
247
248 pub fn window_title(&self) -> String {
250 self.renderer.render_state.window_title.clone()
251 }
252
253 pub fn redraw(&mut self, gpu: &mut GpuRenderer) {
255 let id = self.active_tab_id();
256 self.renderer.redraw(&mut self.tabs, id, gpu);
257 }
258
259 pub fn apply_draw_commands(&self, gpu: &mut GpuRenderer) {
261 self.renderer.apply_draw_commands(gpu);
262 }
263
264 pub(crate) fn tick(&mut self) -> BrowserUiTick {
266 let mut fetches = Vec::new();
267 let mut needs_redraw = false;
268 let mut chrome_actions = Vec::new();
269
270 let tab_ids: Vec<_> = self.tabs.keys().copied().collect();
271
272 for tab_id in &tab_ids {
273 let Some(tab) = self.tab_mut(tab_id) else {
274 continue;
275 };
276 for task in tab.tick() {
277 match task {
278 TabTask::Fetch { url, kind, origin } => {
279 log::info!("Fetch requested in BrowserUi: url={}", url);
280 fetches.push(TabFetchRequest {
281 tab_id: *tab_id,
282 request: FetchRequest { url, kind, origin },
283 });
284 }
285 TabTask::NeedsRedraw => {
286 needs_redraw = true;
287 }
288 TabTask::DevToolsRequest { id, .. } => {
289 let response = serde_json::json!({
293 "ok": false,
294 "error": "no inspected page",
295 })
296 .to_string();
297
298 if let Some(requester) = self.tab_mut(tab_id) {
299 requester.on_devtools_response(id, response);
300 }
301 }
302 }
303 }
304 }
305
306 fetches.extend(
307 self.renderer
308 .chrome
309 .tick(&mut chrome_actions)
310 .0
311 .into_iter()
312 .map(|request| TabFetchRequest {
313 tab_id: TabId(0),
314 request,
315 }),
316 );
317
318 for action in chrome_actions {
319 match action {
320 ChromeAction::DevToolsRequest { id, method, params } => {
321 self.handle_devtools_request(id, method, params);
322 needs_redraw = true;
323 }
324 ChromeAction::Repaint => needs_redraw = true,
325 action => log::warn!("Ignoring unsupported action from chrome tick: {action:?}"),
326 }
327 }
328
329 BrowserUiTick {
330 fetches,
331 needs_redraw,
332 }
333 }
334
335 pub(crate) fn deliver_fetch(
337 &mut self,
338 tab_id: &TabId,
339 kind: FetchKind,
340 url: Url,
341 response: Result<BrowserResponse, BrowserNetworkError>,
342 ) {
343 if tab_id == &TabId(0) {
344 self.renderer.chrome.deliver_fetch(kind, url, response);
345 return;
346 }
347
348 let Some(tab) = self.tab_mut(tab_id) else {
349 log::warn!("There is no Tab called id={}", tab_id);
350 return;
351 };
352
353 log::info!("Delivering fetch result in BrowserUi for tab_id={}", tab_id);
354 tab.deliver_fetch(kind, url, response);
355 }
356
357 pub fn handle_window_event(
359 &mut self,
360 event: WindowEvent,
361 gpu: &mut GpuRenderer,
362 ) -> BrowserCommand {
363 match event {
364 WindowEvent::CloseRequested => BrowserCommand::Exit,
365
366 WindowEvent::RedrawRequested => {
367 self.redraw(gpu);
368 BrowserCommand::RenameWindowTitle
369 }
370
371 WindowEvent::Resized(size) => {
372 self.renderer.render_state.window_size = (size.width, size.height);
373 gpu.resize(size);
374 self.redraw(gpu);
375 BrowserCommand::RequestRedraw
376 }
377
378 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
379 gpu.set_scale_factor(scale_factor);
380 self.renderer.render_state.scale_factor = scale_factor;
381 self.redraw(gpu);
382 BrowserCommand::RequestRedraw
383 }
384
385 WindowEvent::MouseWheel { delta, .. } => {
386 self.handle_scroll(delta);
387 BrowserCommand::RequestRedraw
388 }
389
390 WindowEvent::CursorMoved { position, .. } => {
391 self.input.mouse_position = (position.x, position.y);
392 if self.handle_pointer_move(position.x, position.y) {
393 BrowserCommand::RequestRedraw
394 } else {
395 BrowserCommand::None
396 }
397 }
398
399 WindowEvent::MouseInput { button, state, .. } => self.handle_mouse_input(button, state),
400
401 WindowEvent::ModifiersChanged(modifiers) => {
402 self.input.modifiers = modifiers.state();
403 BrowserCommand::None
404 }
405
406 WindowEvent::KeyboardInput { event, .. } => self.handle_keyboard_input(event),
407
408 WindowEvent::Ime(event) => self.handle_ime_input(event),
409
410 _ => BrowserCommand::None,
411 }
412 }
413
414 fn handle_keyboard_input(&mut self, event: KeyEvent) -> BrowserCommand {
416 const KEY_NEW_WINDOW: &str = "n";
418
419 if event.state != ElementState::Pressed {
420 return BrowserCommand::None;
421 }
422
423 let ctrl = self.input.modifiers.control_key();
424
425 if ctrl
426 && let winit::keyboard::Key::Character(ch) = &event.logical_key
427 && ch.as_str().eq_ignore_ascii_case(KEY_NEW_WINDOW)
428 {
429 return BrowserCommand::OpenNewWindow;
430 }
431
432 let Some(tab_id) = &self.active_tab_id() else {
433 return BrowserCommand::None;
434 };
435
436 if self.renderer.chrome.accepts_text_input() {
439 let action = self.renderer.chrome.key_event(&event, ctrl);
440 return self.apply_chrome_action(action);
441 }
442
443 let Some(tab) = self.tab(tab_id) else {
444 return BrowserCommand::None;
445 };
446
447 let special = logical_key_to_special_event(&event.logical_key, ctrl);
448 let key = logical_key_to_text_key(&event.logical_key);
449 let handled = if let Some(special) = special {
450 tab.dispatch_text_input(special)
451 } else if let Some(key) = key {
452 tab.dispatch_text_input(InputTextEvent::Key(key))
453 } else if !ctrl && !tab.is_text_input_composing() {
454 event.text.as_ref().is_some_and(|text| {
455 tab.dispatch_text_input(InputTextEvent::Insert(text.to_string()))
456 })
457 } else {
458 false
459 };
460
461 if handled {
462 BrowserCommand::RequestRedraw
463 } else {
464 BrowserCommand::None
465 }
466 }
467
468 fn handle_ime_input(&mut self, event: Ime) -> BrowserCommand {
470 if self.renderer.chrome.accepts_text_input() {
473 let action = self.renderer.chrome.ime_event(&event);
474 return match action {
475 ChromeAction::Repaint => BrowserCommand::RequestRedraw,
476 _ => BrowserCommand::None,
477 };
478 }
479
480 let event = match event {
481 Ime::Preedit(text, _) => InputTextEvent::Preedit(text),
482 Ime::Commit(text) => InputTextEvent::Commit(text),
483 Ime::Disabled => InputTextEvent::CancelComposition,
484 Ime::Enabled => return BrowserCommand::None,
485 };
486
487 let Some(tab) = self.active_tab() else {
488 return BrowserCommand::None;
489 };
490
491 if tab.dispatch_text_input(event) {
492 BrowserCommand::RequestRedraw
493 } else {
494 BrowserCommand::None
495 }
496 }
497
498 fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState) -> BrowserCommand {
503 let (x, y, sf) = (
504 self.input.mouse_position.0,
505 self.input.mouse_position.1,
506 self.renderer.render_state.scale_factor,
507 );
508 let (px, py) = ((x / sf) as f32, (y / sf) as f32);
509
510 let width = self.renderer.render_state.viewport().0;
511 let height = self.renderer.render_state.viewport().1;
512
513 if self.renderer.menu.is_open() {
516 let window_event = match state {
517 ElementState::Pressed => PointerEvent::Down { x: px, y: py },
518 ElementState::Released => PointerEvent::Up { x: px, y: py },
519 };
520 let result = self
521 .renderer
522 .menu
523 .pointer_event(width, height, window_event);
524 if result.consumed {
525 return self.dispatch_action(result.action, ActionSource::ContextMenu, x, y);
526 }
527 } else if button == MouseButton::Right {
528 if state == ElementState::Pressed {
530 return self.open_context_menu(px, py);
531 }
532 return BrowserCommand::None;
533 }
534
535 if button != MouseButton::Left {
536 return BrowserCommand::None;
537 }
538
539 let window_event = match state {
541 ElementState::Pressed => PointerEvent::Down { x: px, y: py },
542 ElementState::Released => PointerEvent::Up { x: px, y: py },
543 };
544 let result = self
545 .renderer
546 .chrome
547 .pointer_event(width, height, window_event, state);
548 if result.consumed {
549 return self.dispatch_action(result.action, ActionSource::Chrome, x, y);
550 }
551
552 let Rect { x: dx, y: dy, .. } = self.renderer.chrome.content_rect(width, height);
554
555 let (px, py) = (px - dx, py - dy);
556
557 let (tab_redraw, input_focused) = if let Some(tab) = self.active_tab_mut() {
558 tab.handle_mouse_input(px, py, state)
559 } else {
560 (false, false)
561 };
562
563 self.renderer.chrome.blur();
565
566 if tab_redraw {
567 BrowserCommand::RequestRedraw
568 } else {
569 BrowserCommand::SetImeAllowed {
570 allowed: input_focused,
571 position: (x, y),
572 }
573 }
574 }
575
576 fn apply_chrome_action(&mut self, action: ChromeAction) -> BrowserCommand {
583 match action {
584 ChromeAction::Repaint => BrowserCommand::RequestRedraw,
585 ChromeAction::None => BrowserCommand::None,
586 action => {
587 let (x, y) = self.input.mouse_position;
588 self.dispatch_action(action, ActionSource::Chrome, x, y)
589 }
590 }
591 }
592
593 fn dispatch_action(
599 &mut self,
600 action: ChromeAction,
601 source: ActionSource,
602 x: f64,
603 y: f64,
604 ) -> BrowserCommand {
605 match action {
606 ChromeAction::EnableIme => {
610 if let Some(tab) = self.active_tab() {
611 tab.defocus_text_input();
612 }
613 return BrowserCommand::SetImeAllowed {
614 allowed: true,
615 position: (x, y),
616 };
617 }
618 ChromeAction::Back => {
619 if let Some(tab) = self.active_tab_mut() {
620 tab.go_back();
621 }
622 }
623 ChromeAction::Reload => {
624 if let Some(tab) = self.active_tab_mut() {
625 tab.reload();
626 }
627 }
628 ChromeAction::Navigate(url) => {
629 if let Some(tab) = self.active_tab_mut() {
630 tab.navigate(url);
631 }
632 }
633 ChromeAction::SetJsPolicy(policy) => {
634 if let Some(tab) = self.active_tab_mut() {
635 tab.set_js_policy(policy);
636 tab.reload();
637 }
638 }
639 ChromeAction::DevToolsRequest { id, method, params } => {
640 self.handle_devtools_request(id, method, params)
641 }
642 ChromeAction::Repaint | ChromeAction::None => {}
643 }
644
645 let needs_repaint = match source {
646 ActionSource::Chrome => self.renderer.chrome.needs_repaint(),
647 ActionSource::ContextMenu => self.renderer.menu.needs_repaint(),
648 };
649 if needs_repaint {
650 BrowserCommand::RequestRedraw
651 } else {
652 BrowserCommand::SetImeAllowed {
653 allowed: false,
654 position: (x, y),
655 }
656 }
657 }
658
659 fn open_context_menu(&mut self, px: f32, py: f32) -> BrowserCommand {
665 let width = self.renderer.render_state.viewport().0;
666 let height = self.renderer.render_state.viewport().1;
667
668 let Rect {
669 x: dx,
670 y: dy,
671 width: content_width,
672 height: content_height,
673 } = self.renderer.chrome.content_rect(width, height);
674 if px < dx || py < dy || px > dx + content_width || py > dy + content_height {
675 return BrowserCommand::None;
676 }
677
678 let page_pos = (px - dx, py - dy);
679
680 let Some(tab) = self.active_tab() else {
681 return BrowserCommand::None;
682 };
683 let document_url = tab.document_url().map(|url| url.to_string());
684 let link_url = tab.link_at(page_pos.0, page_pos.1);
685
686 let ctx = ClickContext {
687 window_pos: (px, py),
688 page_pos,
689 link_url,
690 document_url,
691 };
692
693 if self.renderer.menu.open(&ctx) {
694 BrowserCommand::RequestRedraw
695 } else {
696 BrowserCommand::None
697 }
698 }
699
700 fn handle_pointer_move(&mut self, x: f64, y: f64) -> bool {
705 let sf = self.renderer.render_state.scale_factor;
706 let (px, py) = ((x / sf) as f32, (y / sf) as f32);
707 let v_width = self.renderer.render_state.viewport().0;
708 let v_height = self.renderer.render_state.viewport().1;
709
710 if self.renderer.menu.is_open()
712 && self
713 .renderer
714 .menu
715 .pointer_event(v_width, v_height, PointerEvent::Move { x: px, y: py })
716 .consumed
717 {
718 return self.renderer.menu.needs_repaint();
719 }
720
721 let result = self.renderer.chrome.pointer_event(
723 v_width,
724 v_height,
725 PointerEvent::Move { x: px, y: py },
726 ElementState::Released,
727 );
728
729 if result.consumed {
730 return self.renderer.chrome.needs_repaint();
731 }
732
733 let Rect { x: dx, y: dy, .. } = self.renderer.chrome.content_rect(v_width, v_height);
735 let (px, py) = (px - dx, py - dy);
736 let tab_repaint = self
737 .active_tab_mut()
738 .is_some_and(|tab| tab.handle_pointer_move(px, py).0);
739
740 tab_repaint || self.renderer.chrome.needs_repaint()
741 }
742
743 fn handle_scroll(&mut self, delta: MouseScrollDelta) {
746 let (scroll_x, scroll_y) = match delta {
747 MouseScrollDelta::LineDelta(x, y) => (-x * 60.0, -y * 60.0),
748 MouseScrollDelta::PixelDelta(pos) => (-pos.x as f32, -pos.y as f32),
749 };
750
751 let (w_width, w_height) = self.renderer.render_state.viewport();
752 let Rect {
753 x: sx,
754 y: sy,
755 width,
756 height,
757 } = self.renderer.chrome.content_rect(w_width, w_height);
758
759 let sf = self.renderer.render_state.scale_factor;
760 let (mouse_x, mouse_y) = (
761 (self.input.mouse_position.0 / sf) as f32 - sx,
762 (self.input.mouse_position.1 / sf) as f32 - sy,
763 );
764
765 let outside_content = mouse_x < 0.0 || mouse_y < 0.0 || mouse_x > width || mouse_y > height;
767
768 if outside_content {
769 self.renderer
770 .chrome
771 .handle_scroll(w_width, w_height, mouse_x, mouse_y, scroll_x, scroll_y);
772 return;
773 }
774
775 let Some(tab) = self.active_tab_mut() else {
776 return;
777 };
778
779 tab.scroll_at(mouse_x, mouse_y, scroll_x, scroll_y, (width, height));
781 }
782
783 fn handle_devtools_request(&mut self, id: u64, method: String, params: String) {
784 let response = match self.active_tab_mut() {
785 Some(target_tab) => match target_tab.inspect(&method, ¶ms) {
786 Ok(data) => serde_json::json!({ "ok": true, "data": data }).to_string(),
787 Err(error) => serde_json::json!({ "ok": false, "error": error }).to_string(),
788 },
789 None => serde_json::json!({
790 "ok": false,
791 "error": "no inspected page",
792 })
793 .to_string(),
794 };
795 self.renderer.chrome.on_devtools_response(id, response);
796 }
797}
798
799#[derive(Debug, Clone, Copy, PartialEq)]
802enum ActionSource {
803 Chrome,
805 ContextMenu,
807}
808
809fn logical_key_to_text_key(key: &winit::keyboard::Key) -> Option<InputTextKey> {
811 match key {
812 winit::keyboard::Key::Named(winit::keyboard::NamedKey::Backspace) => {
813 Some(InputTextKey::Backspace)
814 }
815 winit::keyboard::Key::Named(winit::keyboard::NamedKey::Delete) => {
816 Some(InputTextKey::Delete)
817 }
818 winit::keyboard::Key::Named(winit::keyboard::NamedKey::ArrowLeft) => {
819 Some(InputTextKey::Left)
820 }
821 winit::keyboard::Key::Named(winit::keyboard::NamedKey::ArrowRight) => {
822 Some(InputTextKey::Right)
823 }
824 winit::keyboard::Key::Named(winit::keyboard::NamedKey::Home) => Some(InputTextKey::Home),
825 winit::keyboard::Key::Named(winit::keyboard::NamedKey::End) => Some(InputTextKey::End),
826 _ => None,
827 }
828}
829
830fn logical_key_to_special_event(key: &winit::keyboard::Key, ctrl: bool) -> Option<InputTextEvent> {
832 if ctrl && let winit::keyboard::Key::Character(ch) = key {
833 match ch.as_str() {
834 "z" | "Z" => Some(InputTextEvent::Undo),
835 "y" | "Y" => Some(InputTextEvent::Redo),
836 _ => None,
837 }
838 } else if let winit::keyboard::Key::Named(winit::keyboard::NamedKey::Enter) = key {
839 Some(InputTextEvent::Enter)
840 } else {
841 None
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848 use crate::browser::core::webview::JsPolicy;
849 use crate::engine::layouter::types::ColorScheme;
850
851 fn ui_with_one_tab() -> BrowserUi {
852 let tab = Tab::new(ColorScheme::default(), JsPolicy::default());
853 BrowserUi::with_tab(tab)
854 }
855
856 #[test]
857 fn get_document_serializes_children_under_the_document_root() {
858 let mut ui = ui_with_one_tab();
859 ui.active_tab_mut()
860 .unwrap()
861 .navigate("https://example.test/index.html".parse().expect("url"));
862 ui.active_tab_mut()
863 .unwrap()
864 .on_fetch_succeeded_html("<html><body><p id=\"a\">hello</p></body></html>".to_string());
865
866 let doc = ui
867 .active_tab_mut()
868 .unwrap()
869 .inspect("getDocument", "{}")
870 .expect("document payload");
871 assert_eq!(doc["type"], "document");
872
873 let children = doc["children"].as_array().expect("children array");
876 assert!(
877 children
878 .iter()
879 .any(|child| child["type"] == "element" && child["tag"] == "html"),
880 "document root must expose the <html> element: {doc}"
881 );
882 }
883}