1use std::sync::Arc;
4
5use crate::{
6 browser::core::{
7 resource_loader::{BrowserNetworkError, BrowserResponse},
8 webview::JsPolicy,
9 },
10 engine::{
11 html::HtmlNodeType,
12 input::HitItem,
13 js::JsFetchResponse,
14 layouter::{
15 self,
16 types::{ColorScheme, ContainerRole, InfoNode, NodeKind},
17 },
18 origin::Origin,
19 renderer_model::{self, DrawCommand},
20 tree::TreeNode,
21 ui::{CustomNode, PointerEvent, input_text_types::InputTextEvent},
22 },
23};
24use ui_layout::LayoutNode;
25use url::Url;
26use winit::event::ElementState;
27
28pub use super::webview::{CssApplicationStrategy, FetchKind, WebView, WebViewTask};
29
30pub enum TabTask {
31 Fetch {
32 url: Url,
33 kind: FetchKind,
34 origin: Origin,
36 },
37 NeedsRedraw,
38 DevToolsRequest {
40 id: u64,
41 method: String,
42 params: String,
43 },
44}
45
46#[derive(Debug)]
47enum TabError {
48 NetworkError(BrowserNetworkError),
49}
50
51#[derive(Debug)]
52enum TabState {
53 Loading,
54 Loaded,
55 Error(TabError, Option<Url>), }
57
58pub struct Tab {
67 title: Option<String>,
68 base_url: Option<Url>,
69 document_url: Option<Url>,
70 webview: Option<WebView>,
71
72 system_color_scheme: ColorScheme,
73
74 js_policy: JsPolicy,
77
78 state: TabState,
79 history: Vec<Url>,
81
82 hovered: Option<Arc<dyn CustomNode>>,
84 pressed_dom_id: Option<u32>,
89}
90
91impl std::fmt::Debug for Tab {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.debug_struct("Tab")
94 .field("title", &self.title)
95 .field("base_url", &self.base_url)
96 .field("document_url", &self.document_url)
97 .field("system_color_scheme", &self.system_color_scheme)
98 .field("js_policy", &self.js_policy)
99 .field("state", &self.state)
100 .field("history", &self.history)
101 .finish()
102 }
103}
104
105impl Default for Tab {
106 fn default() -> Self {
107 Self::new(ColorScheme::default(), JsPolicy::default())
108 }
109}
110
111impl Tab {
112 pub fn new(system_color_scheme: ColorScheme, js_policy: JsPolicy) -> Self {
113 Self {
114 title: None,
115 base_url: None,
116 document_url: None,
117 webview: None,
118
119 system_color_scheme,
120
121 js_policy,
122
123 state: TabState::Loading,
124 history: Vec::new(),
125
126 hovered: None,
127 pressed_dom_id: None,
128 }
129 }
130
131 fn page_origin(&self) -> Origin {
136 self.document_url
137 .as_ref()
138 .map(Origin::from_url)
139 .unwrap_or_else(Origin::opaque)
140 }
141
142 fn may_read_fetch_response(
151 &self,
152 initiator: &Origin,
153 url: &Url,
154 headers: &[(String, String)],
155 ) -> bool {
156 if !initiator.is_network() {
157 return true;
158 }
159 match url.scheme() {
160 "http" | "https" => {
161 let response_origin = Origin::from_url_string(url.as_str());
162 initiator.same_origin(&response_origin) || headers_allow_cors(headers, initiator)
163 }
164 _ => true,
165 }
166 }
167
168 pub fn tick(&mut self) -> Vec<TabTask> {
173 let mut tasks = Vec::new();
174 let page_origin = self.page_origin();
175 let Some(wv) = self.webview.as_mut() else {
176 return tasks;
177 };
178
179 for task in wv.tick() {
180 match task {
181 WebViewTask::Fetch { url, kind } => {
182 log::info!("Fetch requested in Tab: url={}", url);
183 tasks.push(TabTask::Fetch {
184 url,
185 kind,
186 origin: page_origin.clone(),
187 });
188 }
189 WebViewTask::AskTabHtml => {
190 tasks.push(TabTask::Fetch {
191 url: self.document_url.as_ref().unwrap().clone(),
192 kind: FetchKind::Html,
193 origin: page_origin.clone(),
194 });
195 }
196 WebViewTask::DevToolsRequest { id, method, params } => {
197 tasks.push(TabTask::DevToolsRequest { id, method, params });
198 }
199 }
200 }
201
202 if wv.needs_redraw() {
203 tasks.push(TabTask::NeedsRedraw);
204 }
205
206 tasks
207 }
208
209 pub(crate) fn deliver_fetch(
215 &mut self,
216 kind: FetchKind,
217 url: Url,
218 response: Result<BrowserResponse, BrowserNetworkError>,
219 ) {
220 match response {
221 Ok(resp) => match kind {
222 FetchKind::Html => {
223 let html = String::from_utf8_lossy(&resp.body).to_string();
224 self.on_fetch_succeeded_html(html);
225 }
226 FetchKind::Css => {
227 let css = String::from_utf8_lossy(&resp.body).to_string();
228 self.on_fetch_succeeded_css_from(css, &url);
229 }
230 FetchKind::Script { index } => {
231 let source = String::from_utf8_lossy(&resp.body).to_string();
232 self.on_fetch_succeeded_script(index, source);
233 }
234 FetchKind::DynamicScript { node_id } => {
235 let source = String::from_utf8_lossy(&resp.body).to_string();
236 self.on_fetch_succeeded_dynamic_script(node_id, source);
237 }
238 FetchKind::DynamicCss { node_id } => {
239 let source = String::from_utf8_lossy(&resp.body).to_string();
240 self.on_fetch_succeeded_dynamic_style(node_id, source);
241 }
242 FetchKind::Image { source } => {
243 self.on_fetch_succeeded_image(source, &resp.body);
244 }
245 FetchKind::Audio { source } => {
246 self.on_fetch_succeeded_audio(source, &resp.body);
247 }
248 FetchKind::Iframe { dom_id } => {
249 let html = String::from_utf8_lossy(&resp.body).to_string();
250 self.on_fetch_succeeded_iframe(dom_id, html);
251 }
252 FetchKind::JavaScript { request_id, .. } => {
253 let initiator = self.page_origin();
254 if self.may_read_fetch_response(&initiator, &url, &resp.headers) {
255 let redirected = resp.url != url.as_str();
256 self.on_fetch_succeeded_js(request_id, resp, redirected);
257 } else {
258 log::warn!(
259 "Blocked CORS read of {url} from {}",
260 initiator.ascii_serialization()
261 );
262 self.on_fetch_failed_js(
263 request_id,
264 "Cross-origin response blocked by CORS policy".to_string(),
265 );
266 }
267 }
268 },
269 Err(err) => match kind {
270 FetchKind::Image { .. } | FetchKind::Audio { .. } => {
271 log::warn!("Media fetch failed without aborting page load: {url}");
272 }
273 FetchKind::Iframe { dom_id } => {
274 log::warn!("Iframe fetch failed without aborting page load: {url}");
275 self.on_fetch_failed_iframe(dom_id);
276 }
277 FetchKind::Script { index } => {
278 log::warn!("Classic script fetch failed without aborting page load: {url}");
279 self.on_fetch_failed_script(index);
280 }
281 FetchKind::DynamicScript { node_id } => {
282 log::warn!("Dynamic script fetch failed without aborting page load: {url}");
283 self.on_fetch_failed_dynamic_script(node_id);
284 }
285 FetchKind::DynamicCss { node_id } => {
286 log::warn!("Dynamic stylesheet fetch failed without aborting page load: {url}");
287 self.on_fetch_failed_dynamic_style(node_id);
288 }
289 FetchKind::JavaScript { request_id, .. } => {
290 self.on_fetch_failed_js(request_id, err.to_string());
291 }
292 FetchKind::Html | FetchKind::Css => self.on_fetch_failed(err, url),
293 },
294 }
295 }
296
297 pub fn on_css_fetched(&mut self, css: String) {
299 log::info!("CSS fetched in Tab");
300 if let Some(webview) = self.webview.as_mut() {
301 webview.on_css_fetched(css);
302 }
303 }
304
305 pub fn on_fetch_succeeded_html(&mut self, html: String) {
307 let Some(wv) = self.webview.as_mut() else {
308 return;
309 };
310
311 wv.on_html_fetched(html, self.document_url.as_ref().unwrap().clone());
312 self.title = wv.title().cloned();
313 let base_url = wv.base_url().unwrap().clone();
314 log::info!("HTML fetched, base_url={}", base_url);
315 self.base_url = Some(base_url);
316
317 if let TabState::Error(TabError::NetworkError(err), url_opt) = &self.state {
318 let error_message = match url_opt {
319 Some(url) => format!("Failed to load {}: {}", url, err),
320 None => format!("Failed to load page: {}", err),
321 };
322
323 let error_message_element = wv
324 .document_info()
325 .unwrap()
326 .dom
327 .get_elements_by_class_name("error-message");
328 let error_message_element = error_message_element.first().unwrap();
329 let new_child = TreeNode::new(HtmlNodeType::Text(error_message));
330 TreeNode::replace_child(error_message_element, 0, new_child);
331
332 wv.update_page();
335 } else {
336 self.state = TabState::Loaded;
337 }
338 }
339
340 pub fn on_fetch_succeeded_css(&mut self, css: String) {
341 let Some(wv) = self.webview.as_mut() else {
342 return;
343 };
344
345 wv.on_css_fetched(css);
346 }
347
348 pub fn on_fetch_succeeded_css_from(&mut self, css: String, stylesheet_url: &Url) {
349 let Some(wv) = self.webview.as_mut() else {
350 return;
351 };
352 wv.on_css_fetched_from(css, stylesheet_url);
353 }
354
355 pub fn on_fetch_succeeded_image(&mut self, source: String, bytes: &[u8]) {
357 let Some(wv) = self.webview.as_mut() else {
358 return;
359 };
360 if let Err(error) = wv.on_image_fetched(source, bytes) {
361 log::warn!("Failed to decode fetched image: {error}");
362 }
363 }
364
365 pub fn on_fetch_succeeded_audio(&mut self, source: String, bytes: &[u8]) {
367 if let Some(webview) = self.webview.as_mut() {
368 webview.on_audio_fetched(source, bytes);
369 }
370 }
371
372 pub fn on_fetch_succeeded_script(&mut self, index: usize, source: String) {
374 if let Some(webview) = self.webview.as_mut() {
375 webview.on_script_fetched(index, source);
376 }
377 }
378
379 pub fn on_fetch_failed_script(&mut self, index: usize) {
381 if let Some(webview) = self.webview.as_mut() {
382 webview.on_script_fetch_failed(index);
383 }
384 }
385
386 pub fn on_fetch_succeeded_dynamic_script(&mut self, node_id: u64, source: String) {
387 if let Some(webview) = self.webview.as_mut() {
388 webview.on_dynamic_script_fetched(node_id, source);
389 }
390 }
391
392 pub fn on_fetch_failed_dynamic_script(&mut self, node_id: u64) {
393 if let Some(webview) = self.webview.as_mut() {
394 webview.on_dynamic_script_fetch_failed(node_id);
395 }
396 }
397
398 pub fn on_fetch_succeeded_dynamic_style(&mut self, node_id: u64, source: String) {
399 if let Some(webview) = self.webview.as_mut() {
400 webview.on_dynamic_style_fetched(node_id, source);
401 }
402 }
403
404 pub fn on_fetch_failed_dynamic_style(&mut self, node_id: u64) {
405 if let Some(webview) = self.webview.as_mut() {
406 webview.on_dynamic_style_fetch_failed(node_id);
407 }
408 }
409
410 pub fn on_fetch_succeeded_iframe(&mut self, dom_id: u64, html: String) {
412 if let Some(webview) = self.webview.as_mut() {
413 webview.on_iframe_fetched(dom_id, html);
414 }
415 }
416
417 pub fn on_fetch_failed_iframe(&mut self, dom_id: u64) {
420 if let Some(webview) = self.webview.as_mut() {
421 webview.on_iframe_fetch_failed(dom_id);
422 }
423 }
424
425 pub fn on_fetch_succeeded_js(
427 &mut self,
428 request_id: u64,
429 response: BrowserResponse,
430 redirected: bool,
431 ) {
432 if let Some(webview) = self.webview.as_mut() {
433 webview.on_js_fetch_succeeded(
434 request_id,
435 JsFetchResponse {
436 url: response.url,
437 status: response.status.as_u16(),
438 status_text: response.status_text,
439 redirected,
440 body: response.body,
441 headers: response.headers,
442 },
443 );
444 }
445 }
446
447 pub fn on_fetch_failed_js(&mut self, request_id: u64, reason: String) {
449 if let Some(webview) = self.webview.as_mut() {
450 webview.on_js_fetch_failed(request_id, reason);
451 }
452 }
453
454 pub fn inspect(&mut self, method: &str, params: &str) -> Result<serde_json::Value, String> {
456 match self.webview.as_mut() {
457 Some(webview) => webview.inspect(method, params),
458 None => Err("no page".to_string()),
459 }
460 }
461
462 pub fn draw(&mut self, cmd_buf: &mut Vec<DrawCommand>, width: f32, height: f32) {
463 self.relayout((width, height));
464
465 if let Some((layout, info)) = self.layout_and_info() {
466 renderer_model::generate_draw_commands(cmd_buf, layout, info, (width, height));
467 self.clear_redraw_flag();
468 } else {
469 log::debug!(target: "Tab", "No layout/info available for tab");
470 }
471 }
472
473 pub fn handle_mouse_input(&mut self, px: f32, py: f32, state: ElementState) -> (bool, bool) {
474 let Some((_, info)) = self.layout_and_info() else {
477 return (false, false);
478 };
479
480 let path = self.hit_test(px, py);
481 let dom_id = crate::engine::input::hit_dom_id(&path);
482
483 let mut repaint = false;
484
485 match state {
486 ElementState::Pressed => {
487 crate::engine::input::dismiss_open_popups(info, &path);
488
489 let input_target = path.iter().find_map(|hit| {
490 if let layouter::types::NodeKind::Custom { node, .. } = &hit.info.kind
491 && node.accepts_text_input()
492 {
493 Some(Arc::clone(node))
494 } else {
495 None
496 }
497 });
498
499 let input_focused =
500 crate::engine::input::focus_text_input(info, input_target.as_ref());
501
502 crate::engine::input::dispatch_pointer(&path, PointerEvent::Down { x: px, y: py });
503
504 if let Some(href) = path.iter().find_map(|hit| {
505 if let layouter::types::NodeKind::Container { role, .. } = &hit.info.kind
506 && let layouter::types::ContainerRole::Link { href } = role
507 {
508 Some(href.clone())
509 } else {
510 None
511 }
512 }) {
513 self.move_to(&href);
514 }
515
516 repaint |= input_focused;
517
518 self.pressed_dom_id = dom_id;
519 }
520
521 ElementState::Released => {
522 crate::engine::input::dispatch_pointer(&path, PointerEvent::Up { x: px, y: py });
523
524 let pressed = self.pressed_dom_id.take();
525 if let (Some(pressed), Some(released)) = (pressed, dom_id)
526 && pressed == released
527 {
528 repaint |= self.on_js_click(released);
529 }
530 }
531 }
532
533 let (move_repaint, move_focused) = self.handle_pointer_move(px, py);
534 repaint |= move_repaint;
535
536 (repaint, move_focused)
537 }
538
539 pub fn handle_pointer_move(&mut self, px: f32, py: f32) -> (bool, bool) {
544 let move_path = self.hit_test(px, py);
545
546 let input_focused =
547 crate::engine::input::dispatch_pointer(&move_path, PointerEvent::Move { x: px, y: py });
548
549 let hover_changed = {
550 let previous = self.hovered.as_ref();
551 crate::engine::input::update_hover(&move_path, previous)
552 };
553
554 let mut repaint = false;
555 if hover_changed {
556 repaint = true;
557 self.hovered = crate::engine::input::hit_custom_node(&move_path).cloned();
558 }
559
560 (repaint, input_focused)
561 }
562
563 #[cfg(test)]
565 pub(crate) fn has_pending_press(&self) -> bool {
566 self.pressed_dom_id.is_some()
567 }
568
569 pub fn dispatch_text_input(&self, event: InputTextEvent) -> bool {
572 let Some((_, info)) = self.layout_and_info() else {
573 return false;
574 };
575 crate::engine::input::dispatch_text_input(info, event)
576 }
577
578 pub fn defocus_text_input(&self) -> bool {
580 let Some((_, info)) = self.layout_and_info() else {
581 return false;
582 };
583 crate::engine::input::focus_text_input(info, None)
584 }
585
586 pub fn link_at(&self, px: f32, py: f32) -> Option<String> {
588 let (layout, info) = self.layout_and_info()?;
589 let path = crate::engine::input::hit_test(layout, info, px, py);
590 path.iter().find_map(|hit| {
591 if let NodeKind::Container {
592 role: ContainerRole::Link { href },
593 ..
594 } = &hit.info.kind
595 {
596 Some(href.clone())
597 } else {
598 None
599 }
600 })
601 }
602
603 pub fn scroll_at(&mut self, px: f32, py: f32, dx: f32, dy: f32, viewport: (f32, f32)) {
605 let Some(scrolled_id) = self.layout_and_info_mut().and_then(|(layout, info)| {
606 crate::engine::input::scroll_at(layout, info, viewport, px, py, dx, dy)
607 }) else {
608 return;
609 };
610 if scrolled_id != crate::engine::input::NO_SCROLL_DOM_ID
611 && let Some(wv) = self.webview.as_mut()
612 {
613 wv.on_js_scroll(scrolled_id);
614 }
615 }
616
617 pub fn has_layout(&self) -> bool {
619 self.layout_and_info().is_some()
620 }
621
622 pub fn is_text_input_composing(&self) -> bool {
625 let Some((_, info)) = self.layout_and_info() else {
626 return false;
627 };
628 crate::engine::input::focused_text_input_is_composing(info)
629 }
630
631 fn hit_test<'a>(&'a self, px: f32, py: f32) -> Vec<HitItem<'a>> {
632 let Some((layout, info)) = self.layout_and_info() else {
633 return vec![];
634 };
635
636 crate::engine::input::hit_test(layout, info, px, py)
637 }
638
639 pub fn on_devtools_response(&mut self, id: u64, result: String) {
641 if let Some(webview) = self.webview.as_mut() {
642 webview.on_devtools_response(id, result);
643 }
644 }
645
646 pub fn on_fetch_failed(&mut self, err: BrowserNetworkError, failed_url: Url) {
648 self.navigate("resource:///error.html".parse().unwrap());
649 self.state = TabState::Error(TabError::NetworkError(err), Some(failed_url));
650 }
651
652 pub fn navigate(&mut self, url: Url) {
653 self.navigate_internal(url, true);
654 }
655
656 pub fn go_back(&mut self) -> bool {
660 let Some(previous) = self.history.pop() else {
661 return false;
662 };
663 self.navigate_internal(previous, false);
664 true
665 }
666
667 pub fn reload(&mut self) {
669 if let Some(url) = self.document_url.clone() {
670 self.navigate_internal(url, false);
671 }
672 }
673
674 pub fn can_go_back(&self) -> bool {
676 !self.history.is_empty()
677 }
678
679 fn navigate_internal(&mut self, url: Url, record_history: bool) {
680 if record_history
681 && self.document_url.as_ref() != Some(&url)
682 && let Some(previous) = self.document_url.clone()
683 {
684 self.history.push(previous);
685 }
686 self.document_url = Some(url);
687 let mut webview = WebView::new(self.system_color_scheme, self.js_policy);
688 webview.navigate();
689 self.webview = Some(webview);
690 self.state = TabState::Loading;
691 self.title = None;
692 self.base_url = None;
693 }
694
695 pub fn move_to(&mut self, href: &str) {
696 let base_url = match self.base_url.as_ref() {
697 Some(u) => u,
698 None => return,
699 };
700
701 let url = super::webview::resolve_url(base_url, href).unwrap();
702
703 self.navigate(url)
705 }
706
707 pub fn relayout(&mut self, viewport: (f32, f32)) {
708 if let Some(wv) = self.webview.as_mut() {
709 wv.relayout(viewport);
710 }
711 }
712
713 pub(crate) fn layout_and_info_mut(&mut self) -> Option<(&LayoutNode, &mut InfoNode)> {
716 self.webview
717 .as_mut()
718 .and_then(|wv| wv.layout_and_info_mut())
719 }
720
721 pub fn on_js_click(&mut self, dom_id: u32) -> bool {
725 self.webview
726 .as_mut()
727 .is_some_and(|wv| wv.on_js_click(dom_id))
728 }
729
730 pub fn set_system_color_scheme(&mut self, scheme: ColorScheme) {
731 self.system_color_scheme = scheme;
732 if let Some(wv) = self.webview.as_mut() {
733 wv.set_system_color_scheme(scheme)
734 }
735 }
736
737 pub fn title(&self) -> Option<String> {
739 self.title.clone()
740 }
741
742 pub fn document_url(&self) -> Option<Url> {
744 self.document_url.clone()
745 }
746
747 pub(crate) fn layout_and_info(&self) -> Option<(&LayoutNode, &InfoNode)> {
748 self.webview.as_ref().and_then(|wv| wv.layout_and_info())
749 }
750
751 pub fn needs_redraw(&self) -> bool {
752 self.webview.as_ref().is_some_and(|wv| wv.needs_redraw())
753 }
754
755 pub fn clear_redraw_flag(&mut self) {
756 if let Some(wv) = self.webview.as_mut() {
757 wv.clear_redraw_flag();
758 }
759 }
760
761 pub fn set_js_policy(&mut self, policy: JsPolicy) {
762 self.js_policy = policy;
763 if let Some(wv) = self.webview.as_mut() {
764 wv.set_js_policy(policy);
765 }
766 }
767}
768
769fn headers_allow_cors(headers: &[(String, String)], initiator: &Origin) -> bool {
774 let serialized = initiator.ascii_serialization();
775 headers
776 .iter()
777 .find(|(name, _)| name.eq_ignore_ascii_case("access-control-allow-origin"))
778 .is_some_and(|(_, value)| value == "*" || value == &serialized)
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 fn url(s: &str) -> Url {
786 Url::parse(s).unwrap()
787 }
788
789 #[test]
790 fn navigate_records_history() {
791 let mut tab = Tab::default();
792 tab.navigate(url("https://example.test/a"));
793 tab.navigate(url("https://example.test/b"));
794
795 assert!(tab.can_go_back());
796 assert_eq!(
797 tab.document_url().as_ref().map(Url::as_str),
798 Some("https://example.test/b")
799 );
800 }
801
802 #[test]
803 fn navigating_to_same_url_does_not_duplicate_history() {
804 let mut tab = Tab::default();
805 tab.navigate(url("https://example.test/a"));
806 tab.navigate(url("https://example.test/a"));
807 assert!(!tab.can_go_back());
808 }
809
810 #[test]
811 fn go_back_restores_previous_url() {
812 let mut tab = Tab::default();
813 tab.navigate(url("https://example.test/a"));
814 tab.navigate(url("https://example.test/b"));
815
816 assert!(tab.go_back());
817 assert_eq!(
818 tab.document_url().as_ref().map(Url::as_str),
819 Some("https://example.test/a")
820 );
821 assert!(!tab.can_go_back());
822 assert!(!tab.go_back());
824 }
825
826 #[test]
827 fn reload_keeps_url_without_recording_history() {
828 let mut tab = Tab::default();
829 tab.navigate(url("https://example.test/a"));
830 tab.reload();
831 assert_eq!(
832 tab.document_url().as_ref().map(Url::as_str),
833 Some("https://example.test/a")
834 );
835 assert!(!tab.can_go_back());
836 }
837
838 #[test]
839 fn pointer_move_between_press_and_release_keeps_click_pending() {
840 let mut tab = Tab::default();
841 tab.navigate(url("https://example.test/a"));
842 tab.on_fetch_succeeded_html("<html><body><p>click me</p></body></html>".to_string());
843
844 let mut buf = Vec::new();
847 tab.draw(&mut buf, 800.0, 600.0);
848 for _ in 0..500 {
849 for _ in tab.tick() {}
850 if tab.layout_and_info().is_some() {
851 break;
852 }
853 std::thread::sleep(std::time::Duration::from_millis(2));
854 }
855 tab.draw(&mut buf, 800.0, 600.0);
856
857 tab.handle_mouse_input(400.0, 12.0, ElementState::Pressed);
860 assert!(tab.has_pending_press(), "press should land on an element");
861
862 tab.handle_pointer_move(401.0, 13.0);
863 assert!(
864 tab.has_pending_press(),
865 "a pointer move must not cancel an in-flight press"
866 );
867
868 tab.handle_mouse_input(401.0, 13.0, ElementState::Released);
870 assert!(!tab.has_pending_press());
871 }
872
873 #[test]
874 fn cross_origin_fetch_requires_matching_cors_header() {
875 let tab = Tab::default();
876 let web_origin = Origin::from_url_string("https://example.test/index.html");
877 let url = Url::parse("https://other.test/data.json").unwrap();
878
879 assert!(!tab.may_read_fetch_response(&web_origin, &url, &[]));
880
881 let wildcard = vec![("Access-Control-Allow-Origin".to_string(), "*".to_string())];
882 assert!(tab.may_read_fetch_response(&web_origin, &url, &wildcard));
883
884 let matching = vec![(
885 "Access-Control-Allow-Origin".to_string(),
886 "https://example.test".to_string(),
887 )];
888 assert!(tab.may_read_fetch_response(&web_origin, &url, &matching));
889
890 let other = vec![(
891 "Access-Control-Allow-Origin".to_string(),
892 "https://other.test".to_string(),
893 )];
894 assert!(!tab.may_read_fetch_response(&web_origin, &url, &other));
895 }
896
897 #[test]
898 fn same_origin_fetch_is_readable_without_cors_header() {
899 let tab = Tab::default();
900 let web_origin = Origin::from_url_string("https://example.test/");
901 let url = Url::parse("https://example.test:443/api").unwrap();
902 assert!(tab.may_read_fetch_response(&web_origin, &url, &[]));
903 }
904
905 #[test]
906 fn internal_page_reads_any_response_without_cors() {
907 let tab = Tab::default();
908 let internal = Origin::opaque();
909 let url = Url::parse("https://other.test/api").unwrap();
910 assert!(tab.may_read_fetch_response(&internal, &url, &[]));
911 }
912
913 #[test]
914 fn web_page_reads_internal_scheme_responses_without_cors() {
915 let tab = Tab::default();
918 let web_origin = Origin::from_url_string("https://example.test/");
919 let url = Url::parse("data:text/plain,hello").unwrap();
920 assert!(tab.may_read_fetch_response(&web_origin, &url, &[]));
921 }
922
923 #[test]
924 fn headers_allow_cors_requires_exact_origin_value() {
925 let initiator = Origin::from_url_string("https://example.test/");
926 assert!(headers_allow_cors(
927 &[("Access-Control-Allow-Origin".to_string(), "*".to_string())],
928 &initiator
929 ));
930 assert!(headers_allow_cors(
931 &[(
932 "ACCEss-cOntRoL-aLLow-orIgIn".to_string(),
933 "https://example.test".to_string()
934 )],
935 &initiator
936 ));
937 assert!(!headers_allow_cors(
938 &[(
939 "Access-Control-Allow-Origin".to_string(),
940 "https://other.test".to_string()
941 )],
942 &initiator
943 ));
944 }
945}