1use super::{GeneratedCss, GeneratedHtml, GeneratedJs};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct HtmlValidationResult {
12 pub valid: bool,
14 pub errors: Vec<String>,
16 pub warnings: Vec<String>,
18}
19
20impl HtmlValidationResult {
21 #[must_use]
23 pub fn is_valid(&self) -> bool {
24 self.valid && self.errors.is_empty()
25 }
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct CssLintResult {
31 pub valid: bool,
33 pub errors: Vec<String>,
35 pub warnings: Vec<String>,
37}
38
39impl CssLintResult {
40 #[must_use]
42 pub fn is_valid(&self) -> bool {
43 self.valid && self.errors.is_empty()
44 }
45}
46
47#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct JsLintResult {
50 pub valid: bool,
52 pub errors: Vec<String>,
54 pub warnings: Vec<String>,
56 pub security_issues: Vec<SecurityIssue>,
58}
59
60impl JsLintResult {
61 #[must_use]
63 pub fn is_valid(&self) -> bool {
64 self.valid && self.errors.is_empty() && self.security_issues.is_empty()
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct SecurityIssue {
71 pub severity: Severity,
73 pub description: String,
75 pub line: Option<usize>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81pub enum Severity {
82 Low,
84 Medium,
86 High,
88 Critical,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct AccessibilityIssue {
95 pub severity: Severity,
97 pub description: String,
99 pub element_id: Option<String>,
101 pub wcag_ref: Option<String>,
103}
104
105#[derive(Debug, Clone, Default, Serialize, Deserialize)]
107pub struct ValidationReport {
108 pub html: HtmlValidationResult,
110 pub css: CssLintResult,
112 pub js: JsLintResult,
114 pub accessibility: Vec<AccessibilityIssue>,
116}
117
118impl ValidationReport {
119 #[must_use]
121 pub fn is_valid(&self) -> bool {
122 self.html.is_valid()
123 && self.css.is_valid()
124 && self.js.is_valid()
125 && self
126 .accessibility
127 .iter()
128 .all(|a| a.severity != Severity::Critical)
129 }
130
131 #[must_use]
133 pub fn error_count(&self) -> usize {
134 self.html.errors.len() + self.css.errors.len() + self.js.errors.len()
135 }
136
137 #[must_use]
139 pub fn warning_count(&self) -> usize {
140 self.html.warnings.len() + self.css.warnings.len() + self.js.warnings.len()
141 }
142}
143
144#[derive(Debug, Clone, Copy, Default)]
146pub struct WebValidator;
147
148impl WebValidator {
149 #[must_use]
151 pub fn validate_html(html: &GeneratedHtml) -> HtmlValidationResult {
152 let mut result = HtmlValidationResult {
153 valid: true,
154 errors: Vec::new(),
155 warnings: Vec::new(),
156 };
157
158 if !html.content.contains("<!DOCTYPE html>") {
160 result
161 .errors
162 .push("Missing DOCTYPE declaration".to_string());
163 result.valid = false;
164 }
165
166 if !html.content.contains("<html") {
168 result.errors.push("Missing <html> tag".to_string());
169 result.valid = false;
170 }
171
172 if !html.content.contains("<head>") {
173 result.errors.push("Missing <head> tag".to_string());
174 result.valid = false;
175 }
176
177 if !html.content.contains("<body>") {
178 result.errors.push("Missing <body> tag".to_string());
179 result.valid = false;
180 }
181
182 if !html.content.contains("charset=") {
184 result.warnings.push("Missing charset meta tag".to_string());
185 }
186
187 if !html.content.contains("viewport") {
189 result
190 .warnings
191 .push("Missing viewport meta tag".to_string());
192 }
193
194 if !html.content.contains("<title>") || html.title.is_empty() {
196 result
197 .errors
198 .push("Missing or empty <title> tag".to_string());
199 result.valid = false;
200 }
201
202 if !html.content.contains("lang=") {
204 result
205 .warnings
206 .push("Missing lang attribute on <html>".to_string());
207 }
208
209 result
210 }
211
212 #[must_use]
214 pub fn lint_css(css: &GeneratedCss) -> CssLintResult {
215 let mut result = CssLintResult {
216 valid: true,
217 errors: Vec::new(),
218 warnings: Vec::new(),
219 };
220
221 if css.content.trim().is_empty() && css.rules.is_empty() {
223 result.warnings.push("Empty stylesheet".to_string());
224 }
225
226 for rule in &css.rules {
228 if rule.selector.trim().is_empty() {
230 result.errors.push("Empty CSS selector".to_string());
231 result.valid = false;
232 }
233
234 for (_, value) in &rule.declarations {
236 if value.contains("!important") {
237 result
238 .warnings
239 .push(format!("Use of !important in {}", rule.selector));
240 }
241 }
242 }
243
244 if css.content.contains("-webkit-") && !css.content.contains("webkit") {
246 result
247 .warnings
248 .push("Vendor prefix -webkit- used".to_string());
249 }
250
251 result
252 }
253
254 #[must_use]
256 pub fn lint_js(js: &GeneratedJs) -> JsLintResult {
257 let mut result = JsLintResult {
258 valid: true,
259 errors: Vec::new(),
260 warnings: Vec::new(),
261 security_issues: Vec::new(),
262 };
263
264 if js.line_count > super::js_builder::MAX_JS_LINES {
266 result.errors.push(format!(
267 "JavaScript exceeds {} line limit: {} lines",
268 super::js_builder::MAX_JS_LINES,
269 js.line_count
270 ));
271 result.valid = false;
272 }
273
274 Self::check_js_security(js, &mut result);
276
277 result
278 }
279
280 fn check_js_security(js: &GeneratedJs, result: &mut JsLintResult) {
282 if js.content.contains("eval(") {
284 result.security_issues.push(SecurityIssue {
285 severity: Severity::Critical,
286 description: "Use of eval() is forbidden".to_string(),
287 line: None,
288 });
289 result.valid = false;
290 }
291
292 if js.content.contains("new Function(") {
294 result.security_issues.push(SecurityIssue {
295 severity: Severity::Critical,
296 description: "Use of Function constructor is forbidden".to_string(),
297 line: None,
298 });
299 result.valid = false;
300 }
301
302 if js.content.contains("innerHTML") {
304 result.security_issues.push(SecurityIssue {
305 severity: Severity::High,
306 description: "Use of innerHTML can lead to XSS".to_string(),
307 line: None,
308 });
309 }
310
311 if js.content.contains("document.write") {
313 result.security_issues.push(SecurityIssue {
314 severity: Severity::Medium,
315 description: "Use of document.write is deprecated".to_string(),
316 line: None,
317 });
318 }
319
320 if js.content.contains("setTimeout(\"") || js.content.contains("setInterval(\"") {
322 result.security_issues.push(SecurityIssue {
323 severity: Severity::High,
324 description: "String argument to setTimeout/setInterval is eval-like".to_string(),
325 line: None,
326 });
327 }
328 }
329
330 #[must_use]
332 pub fn check_accessibility(html: &GeneratedHtml) -> Vec<AccessibilityIssue> {
333 let mut issues = Vec::new();
334
335 for element in &html.elements {
337 if let super::Element::Canvas {
338 id,
339 role,
340 aria_label,
341 ..
342 } = element
343 {
344 if role.is_empty() {
345 issues.push(AccessibilityIssue {
346 severity: Severity::Medium,
347 description: "Canvas element missing role attribute".to_string(),
348 element_id: Some(id.clone()),
349 wcag_ref: Some("WCAG 4.1.2".to_string()),
350 });
351 }
352
353 if aria_label.is_empty() {
354 issues.push(AccessibilityIssue {
355 severity: Severity::Medium,
356 description: "Canvas element missing aria-label".to_string(),
357 element_id: Some(id.clone()),
358 wcag_ref: Some("WCAG 1.1.1".to_string()),
359 });
360 }
361 }
362
363 if let super::Element::Button { id, aria_label, .. } = element {
364 if aria_label.is_empty() {
365 issues.push(AccessibilityIssue {
366 severity: Severity::Medium,
367 description: "Button missing aria-label".to_string(),
368 element_id: Some(id.clone()),
369 wcag_ref: Some("WCAG 4.1.2".to_string()),
370 });
371 }
372 }
373
374 if let super::Element::Input { id, aria_label, .. } = element {
375 if aria_label.is_empty() {
376 issues.push(AccessibilityIssue {
377 severity: Severity::Medium,
378 description: "Input missing aria-label".to_string(),
379 element_id: Some(id.clone()),
380 wcag_ref: Some("WCAG 1.3.1".to_string()),
381 });
382 }
383 }
384 }
385
386 if !html.content.contains("lang=") {
388 issues.push(AccessibilityIssue {
389 severity: Severity::High,
390 description: "Missing lang attribute on <html>".to_string(),
391 element_id: None,
392 wcag_ref: Some("WCAG 3.1.1".to_string()),
393 });
394 }
395
396 issues
397 }
398
399 #[must_use]
401 pub fn validate_all(
402 html: &GeneratedHtml,
403 css: &GeneratedCss,
404 js: &GeneratedJs,
405 ) -> ValidationReport {
406 ValidationReport {
407 html: Self::validate_html(html),
408 css: Self::lint_css(css),
409 js: Self::lint_js(js),
410 accessibility: Self::check_accessibility(html),
411 }
412 }
413}
414
415#[cfg(test)]
416#[allow(clippy::unwrap_used, clippy::expect_used)]
417mod tests {
418 use super::*;
419 use crate::web::{CssBuilder, HtmlBuilder, JsBuilder};
420
421 #[test]
426 fn h0_val_01_valid_html() {
427 let html = HtmlBuilder::new()
428 .title("Test")
429 .canvas("c", 100, 100)
430 .build()
431 .unwrap();
432
433 let result = WebValidator::validate_html(&html);
434 assert!(result.is_valid());
435 }
436
437 #[test]
438 fn h0_val_02_missing_doctype() {
439 let html = GeneratedHtml {
440 title: "Test".to_string(),
441 body_content: String::new(),
442 content: "<html><head></head><body></body></html>".to_string(),
443 elements: vec![],
444 };
445
446 let result = WebValidator::validate_html(&html);
447 assert!(!result.is_valid());
448 assert!(result.errors.iter().any(|e| e.contains("DOCTYPE")));
449 }
450
451 #[test]
452 fn h0_val_03_missing_title() {
453 let html = GeneratedHtml {
454 title: String::new(),
455 body_content: String::new(),
456 content: "<!DOCTYPE html><html><head></head><body></body></html>".to_string(),
457 elements: vec![],
458 };
459
460 let result = WebValidator::validate_html(&html);
461 assert!(!result.is_valid());
462 assert!(result.errors.iter().any(|e| e.contains("title")));
463 }
464
465 #[test]
470 fn h0_val_04_valid_css() {
471 let css = CssBuilder::new().reset().build().unwrap();
472
473 let result = WebValidator::lint_css(&css);
474 assert!(result.is_valid());
475 }
476
477 #[test]
478 fn h0_val_05_empty_css_warning() {
479 let css = GeneratedCss {
480 content: String::new(),
481 rules: vec![],
482 variables: vec![],
483 };
484
485 let result = WebValidator::lint_css(&css);
486 assert!(result.is_valid()); assert!(result.warnings.iter().any(|w| w.contains("Empty")));
488 }
489
490 #[test]
491 fn h0_val_06_important_warning() {
492 let css = GeneratedCss {
493 content: ".test { color: red !important; }".to_string(),
494 rules: vec![super::super::CssRule {
495 selector: ".test".to_string(),
496 declarations: vec![("color".to_string(), "red !important".to_string())],
497 }],
498 variables: vec![],
499 };
500
501 let result = WebValidator::lint_css(&css);
502 assert!(result.warnings.iter().any(|w| w.contains("!important")));
503 }
504
505 #[test]
510 fn h0_val_07_valid_js() {
511 let js = JsBuilder::new("app.wasm", "canvas").build().unwrap();
512
513 let result = WebValidator::lint_js(&js);
514 assert!(result.is_valid());
515 }
516
517 #[test]
518 fn h0_val_08_js_eval_blocked() {
519 let js = GeneratedJs {
520 content: "eval('code')".to_string(),
521 line_count: 1,
522 functions: vec![],
523 };
524
525 let result = WebValidator::lint_js(&js);
526 assert!(!result.is_valid());
527 assert!(result
528 .security_issues
529 .iter()
530 .any(|s| s.severity == Severity::Critical));
531 }
532
533 #[test]
534 fn h0_val_09_js_function_constructor_blocked() {
535 let js = GeneratedJs {
536 content: "new Function('return 1')".to_string(),
537 line_count: 1,
538 functions: vec![],
539 };
540
541 let result = WebValidator::lint_js(&js);
542 assert!(!result.is_valid());
543 }
544
545 #[test]
546 fn h0_val_10_js_innerhtml_warning() {
547 let js = GeneratedJs {
548 content: "el.innerHTML = 'test'".to_string(),
549 line_count: 1,
550 functions: vec![],
551 };
552
553 let result = WebValidator::lint_js(&js);
554 assert!(result
555 .security_issues
556 .iter()
557 .any(|s| s.severity == Severity::High));
558 }
559
560 #[test]
565 fn h0_val_11_canvas_accessibility() {
566 let html = HtmlBuilder::new()
567 .title("Test")
568 .canvas("c", 100, 100)
569 .build()
570 .unwrap();
571
572 let issues = WebValidator::check_accessibility(&html);
573 assert!(issues.is_empty() || issues.iter().all(|i| i.severity != Severity::Critical));
575 }
576
577 #[test]
578 fn h0_val_12_missing_role_warning() {
579 let html = GeneratedHtml {
580 title: "Test".to_string(),
581 body_content: String::new(),
582 content: "<!DOCTYPE html><html lang=\"en\"><head><title>Test</title></head><body></body></html>".to_string(),
583 elements: vec![super::super::Element::Canvas {
584 id: "c".to_string(),
585 width: 100,
586 height: 100,
587 role: String::new(), aria_label: "Test".to_string(),
589 }],
590 };
591
592 let issues = WebValidator::check_accessibility(&html);
593 assert!(issues.iter().any(|i| i.description.contains("role")));
594 }
595
596 #[test]
601 fn h0_val_13_validate_all() {
602 let html = HtmlBuilder::new()
603 .title("Test")
604 .canvas("c", 100, 100)
605 .build()
606 .unwrap();
607 let css = CssBuilder::new().reset().build().unwrap();
608 let js = JsBuilder::new("app.wasm", "c").build().unwrap();
609
610 let report = WebValidator::validate_all(&html, &css, &js);
611 assert!(report.is_valid());
612 }
613
614 #[test]
615 fn h0_val_14_error_count() {
616 let report = ValidationReport {
617 html: HtmlValidationResult {
618 valid: false,
619 errors: vec!["e1".to_string(), "e2".to_string()],
620 warnings: vec![],
621 },
622 css: CssLintResult {
623 valid: false,
624 errors: vec!["e3".to_string()],
625 warnings: vec!["w1".to_string()],
626 },
627 js: JsLintResult::default(),
628 accessibility: vec![],
629 };
630
631 assert_eq!(report.error_count(), 3);
632 assert_eq!(report.warning_count(), 1);
633 }
634
635 #[test]
640 fn h0_val_15_severity_comparison() {
641 assert_ne!(Severity::Low, Severity::Critical);
642 assert_eq!(Severity::High, Severity::High);
643 }
644
645 #[test]
646 fn h0_val_16_validation_result_is_valid() {
647 let valid = HtmlValidationResult {
648 valid: true,
649 errors: vec![],
650 warnings: vec!["warning".to_string()],
651 };
652 assert!(valid.is_valid());
653
654 let invalid = HtmlValidationResult {
655 valid: true, errors: vec!["error".to_string()], warnings: vec![],
658 };
659 assert!(!invalid.is_valid());
660 }
661
662 #[test]
667 fn h0_val_17_js_document_write_warning() {
668 let js = GeneratedJs {
669 content: "document.write('test')".to_string(),
670 line_count: 1,
671 functions: vec![],
672 };
673
674 let result = WebValidator::lint_js(&js);
675 assert!(result
676 .security_issues
677 .iter()
678 .any(|s| s.severity == Severity::Medium && s.description.contains("document.write")));
679 }
680
681 #[test]
682 fn h0_val_18_js_settimeout_string_warning() {
683 let js = GeneratedJs {
684 content: r#"setTimeout("alert(1)", 100)"#.to_string(),
685 line_count: 1,
686 functions: vec![],
687 };
688
689 let result = WebValidator::lint_js(&js);
690 assert!(result
691 .security_issues
692 .iter()
693 .any(|s| s.severity == Severity::High && s.description.contains("setTimeout")));
694 }
695
696 #[test]
697 fn h0_val_19_js_setinterval_string_warning() {
698 let js = GeneratedJs {
699 content: r#"setInterval("tick()", 1000)"#.to_string(),
700 line_count: 1,
701 functions: vec![],
702 };
703
704 let result = WebValidator::lint_js(&js);
705 assert!(result
706 .security_issues
707 .iter()
708 .any(|s| s.description.contains("setInterval")));
709 }
710
711 #[test]
716 fn h0_val_20_css_empty_selector() {
717 let css = GeneratedCss {
718 content: String::new(),
719 rules: vec![super::super::CssRule {
720 selector: " ".to_string(), declarations: vec![("color".to_string(), "red".to_string())],
722 }],
723 variables: vec![],
724 };
725
726 let result = WebValidator::lint_css(&css);
727 assert!(!result.is_valid());
728 assert!(result
729 .errors
730 .iter()
731 .any(|e| e.contains("Empty CSS selector")));
732 }
733
734 #[test]
735 fn h0_val_21_css_vendor_prefix_warning() {
736 let css = GeneratedCss {
740 content: "-webkit-transform: rotate(45deg);".to_string(),
741 rules: vec![],
742 variables: vec![],
743 };
744
745 let result = WebValidator::lint_css(&css);
746 assert!(result.is_valid());
749 }
750
751 #[test]
756 fn h0_val_22_button_missing_aria_label() {
757 let html = GeneratedHtml {
758 title: "Test".to_string(),
759 body_content: String::new(),
760 content: "<!DOCTYPE html><html lang=\"en\"><head><title>Test</title></head><body></body></html>".to_string(),
761 elements: vec![super::super::Element::Button {
762 id: "btn".to_string(),
763 text: "Click".to_string(),
764 aria_label: String::new(), }],
766 };
767
768 let issues = WebValidator::check_accessibility(&html);
769 assert!(issues
770 .iter()
771 .any(|i| i.description.contains("Button") && i.description.contains("aria-label")));
772 }
773
774 #[test]
775 fn h0_val_23_input_missing_aria_label() {
776 let html = GeneratedHtml {
777 title: "Test".to_string(),
778 body_content: String::new(),
779 content: "<!DOCTYPE html><html lang=\"en\"><head><title>Test</title></head><body></body></html>".to_string(),
780 elements: vec![super::super::Element::Input {
781 id: "input1".to_string(),
782 input_type: "text".to_string(),
783 placeholder: "Enter text".to_string(),
784 aria_label: String::new(), }],
786 };
787
788 let issues = WebValidator::check_accessibility(&html);
789 assert!(issues
790 .iter()
791 .any(|i| i.description.contains("Input") && i.description.contains("aria-label")));
792 }
793
794 #[test]
795 fn h0_val_24_canvas_missing_aria_label() {
796 let html = GeneratedHtml {
797 title: "Test".to_string(),
798 body_content: String::new(),
799 content: "<!DOCTYPE html><html lang=\"en\"><head><title>Test</title></head><body></body></html>".to_string(),
800 elements: vec![super::super::Element::Canvas {
801 id: "c".to_string(),
802 width: 100,
803 height: 100,
804 role: "img".to_string(),
805 aria_label: String::new(), }],
807 };
808
809 let issues = WebValidator::check_accessibility(&html);
810 assert!(issues
811 .iter()
812 .any(|i| i.description.contains("Canvas") && i.description.contains("aria-label")));
813 }
814
815 #[test]
816 fn h0_val_25_missing_lang_attribute() {
817 let html = GeneratedHtml {
818 title: "Test".to_string(),
819 body_content: String::new(),
820 content: "<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>"
821 .to_string(),
822 elements: vec![],
823 };
824
825 let issues = WebValidator::check_accessibility(&html);
826 assert!(issues.iter().any(
827 |i| i.description.contains("lang") && i.wcag_ref == Some("WCAG 3.1.1".to_string())
828 ));
829 }
830
831 #[test]
836 fn h0_val_26_js_lint_result_with_errors_only() {
837 let result = JsLintResult {
838 valid: true, errors: vec!["error".to_string()],
840 warnings: vec![],
841 security_issues: vec![],
842 };
843 assert!(!result.is_valid()); }
845
846 #[test]
847 fn h0_val_27_js_lint_result_with_security_only() {
848 let result = JsLintResult {
849 valid: true,
850 errors: vec![],
851 warnings: vec![],
852 security_issues: vec![SecurityIssue {
853 severity: Severity::Low,
854 description: "test".to_string(),
855 line: Some(1),
856 }],
857 };
858 assert!(!result.is_valid()); }
860
861 #[test]
862 fn h0_val_28_css_lint_result_with_errors() {
863 let result = CssLintResult {
864 valid: true,
865 errors: vec!["error".to_string()],
866 warnings: vec![],
867 };
868 assert!(!result.is_valid());
869 }
870
871 #[test]
876 fn h0_val_29_report_with_critical_accessibility() {
877 let report = ValidationReport {
878 html: HtmlValidationResult::default(),
879 css: CssLintResult::default(),
880 js: JsLintResult::default(),
881 accessibility: vec![AccessibilityIssue {
882 severity: Severity::Critical,
883 description: "Critical issue".to_string(),
884 element_id: None,
885 wcag_ref: None,
886 }],
887 };
888 assert!(!report.is_valid()); }
890
891 #[test]
892 fn h0_val_30_report_with_non_critical_accessibility() {
893 let report = ValidationReport {
894 html: HtmlValidationResult {
895 valid: true,
896 errors: vec![],
897 warnings: vec![],
898 },
899 css: CssLintResult {
900 valid: true,
901 errors: vec![],
902 warnings: vec![],
903 },
904 js: JsLintResult {
905 valid: true,
906 errors: vec![],
907 warnings: vec![],
908 security_issues: vec![],
909 },
910 accessibility: vec![AccessibilityIssue {
911 severity: Severity::Medium,
912 description: "Medium issue".to_string(),
913 element_id: Some("el1".to_string()),
914 wcag_ref: Some("WCAG 1.1.1".to_string()),
915 }],
916 };
917 assert!(report.is_valid()); }
919
920 #[test]
921 fn h0_val_31_js_line_count_exceeded() {
922 let js = GeneratedJs {
923 content: "// code".to_string(),
924 line_count: 1000, functions: vec![],
926 };
927
928 let result = WebValidator::lint_js(&js);
929 assert!(!result.is_valid());
930 assert!(result.errors.iter().any(|e| e.contains("line limit")));
931 }
932
933 #[test]
934 fn h0_val_32_html_missing_html_tag() {
935 let html = GeneratedHtml {
936 title: "Test".to_string(),
937 body_content: String::new(),
938 content: "<!DOCTYPE html><head><title>Test</title></head><body></body>".to_string(),
939 elements: vec![],
940 };
941
942 let result = WebValidator::validate_html(&html);
943 assert!(!result.is_valid());
944 assert!(result.errors.iter().any(|e| e.contains("<html>")));
945 }
946
947 #[test]
948 fn h0_val_33_html_missing_head_tag() {
949 let html = GeneratedHtml {
950 title: "Test".to_string(),
951 body_content: String::new(),
952 content: "<!DOCTYPE html><html><body></body></html>".to_string(),
953 elements: vec![],
954 };
955
956 let result = WebValidator::validate_html(&html);
957 assert!(!result.is_valid());
958 assert!(result.errors.iter().any(|e| e.contains("<head>")));
959 }
960
961 #[test]
962 fn h0_val_34_html_missing_body_tag() {
963 let html = GeneratedHtml {
964 title: "Test".to_string(),
965 body_content: String::new(),
966 content: "<!DOCTYPE html><html><head><title>Test</title></head></html>".to_string(),
967 elements: vec![],
968 };
969
970 let result = WebValidator::validate_html(&html);
971 assert!(!result.is_valid());
972 assert!(result.errors.iter().any(|e| e.contains("<body>")));
973 }
974}