1use crate::report::{TestCategory, TestResult, TestStatus};
8use pmcp::types::ui::CHATGPT_DESCRIPTOR_KEYS;
9use pmcp::types::{ResourceInfo, ToolInfo};
10use regex::Regex;
11use serde_json::Value;
12use std::sync::OnceLock;
13use std::time::Duration;
14
15const APP_MIME_TYPES: &[&str] = &[
17 "text/html",
18 "text/html+mcp",
19 "text/html+skybridge",
20 "text/html;profile=mcp-app",
21];
22
23#[allow(dead_code)]
37fn script_block_re() -> &'static Regex {
38 static RE: OnceLock<Regex> = OnceLock::new();
39 RE.get_or_init(|| {
40 Regex::new(r#"(?is)<script(?P<attrs>[^>]*)>(?P<body>[\s\S]*?)</script>"#).unwrap()
41 })
42}
43
44#[allow(dead_code)]
45fn ext_apps_import_re() -> &'static Regex {
46 static RE: OnceLock<Regex> = OnceLock::new();
47 RE.get_or_init(|| Regex::new(r"@modelcontextprotocol/ext-apps").unwrap())
48}
49
50#[allow(dead_code)]
51fn ext_apps_log_prefix_re() -> &'static Regex {
52 static RE: OnceLock<Regex> = OnceLock::new();
53 RE.get_or_init(|| Regex::new(r"\[ext-apps\]").unwrap())
58}
59
60#[allow(dead_code)]
61fn ui_initialize_method_re() -> &'static Regex {
62 static RE: OnceLock<Regex> = OnceLock::new();
63 RE.get_or_init(|| Regex::new(r"ui/initialize").unwrap())
66}
67
68#[allow(dead_code)]
69fn ui_tool_result_method_re() -> &'static Regex {
70 static RE: OnceLock<Regex> = OnceLock::new();
71 RE.get_or_init(|| Regex::new(r"ui/notifications/tool-result").unwrap())
73}
74
75#[allow(dead_code)]
76fn app_constructor_re() -> &'static Regex {
77 static RE: OnceLock<Regex> = OnceLock::new();
78 RE.get_or_init(|| {
103 Regex::new(
104 r"new [a-zA-Z_$][a-zA-Z0-9_$]{0,20}\(\s*\{[^}]{0,200}\bname\s*:[^,}]{0,100},\s*version\s*:",
105 )
106 .unwrap()
107 })
108}
109
110#[allow(dead_code)]
111fn handler_onteardown_re() -> &'static Regex {
112 static RE: OnceLock<Regex> = OnceLock::new();
113 RE.get_or_init(|| Regex::new(r"\.\s*onteardown\s*=").unwrap())
114}
115
116#[allow(dead_code)]
117fn handler_ontoolinput_re() -> &'static Regex {
118 static RE: OnceLock<Regex> = OnceLock::new();
119 RE.get_or_init(|| Regex::new(r"\.\s*ontoolinput\s*=").unwrap())
120}
121
122#[allow(dead_code)]
123fn handler_ontoolcancelled_re() -> &'static Regex {
124 static RE: OnceLock<Regex> = OnceLock::new();
125 RE.get_or_init(|| Regex::new(r"\.\s*ontoolcancelled\s*=").unwrap())
126}
127
128#[allow(dead_code)]
129fn handler_onerror_re() -> &'static Regex {
130 static RE: OnceLock<Regex> = OnceLock::new();
131 RE.get_or_init(|| Regex::new(r"\.\s*onerror\s*=").unwrap())
132}
133
134#[allow(dead_code)]
135fn handler_ontoolresult_re() -> &'static Regex {
136 static RE: OnceLock<Regex> = OnceLock::new();
137 RE.get_or_init(|| Regex::new(r"\.\s*ontoolresult\s*=").unwrap())
138}
139
140#[allow(dead_code)]
141fn connect_call_re() -> &'static Regex {
142 static RE: OnceLock<Regex> = OnceLock::new();
143 RE.get_or_init(|| Regex::new(r"\.\s*connect\s*\(").unwrap())
144}
145
146#[allow(dead_code)]
147fn chatgpt_only_channels_re() -> &'static Regex {
148 static RE: OnceLock<Regex> = OnceLock::new();
149 RE.get_or_init(|| Regex::new(r"window\.openai|window\.mcpBridge").unwrap())
150}
151
152#[allow(dead_code)]
156fn html_comment_re() -> &'static Regex {
157 static RE: OnceLock<Regex> = OnceLock::new();
158 RE.get_or_init(|| Regex::new(r"(?s)<!--.*?-->").unwrap())
159}
160
161#[allow(dead_code)]
162fn js_block_comment_re() -> &'static Regex {
163 static RE: OnceLock<Regex> = OnceLock::new();
164 RE.get_or_init(|| Regex::new(r"(?s)/\*.*?\*/").unwrap())
165}
166
167#[allow(dead_code)]
168fn js_line_comment_re() -> &'static Regex {
169 static RE: OnceLock<Regex> = OnceLock::new();
170 RE.get_or_init(|| Regex::new(r"//[^\r\n]*").unwrap())
175}
176
177#[allow(dead_code)]
181#[derive(Debug, Default, Clone, PartialEq, Eq)]
182pub(crate) struct WidgetSignals {
183 has_ext_apps_import: bool,
188 has_log_prefix: bool,
192 has_method_initialize: bool,
195 has_method_tool_result: bool,
197 has_sdk: bool,
200 has_app_constructor: bool,
205 has_connect: bool,
207 has_chatgpt_only_channels: bool,
208 handlers_present: Vec<&'static str>,
213 has_handlers: bool,
217 has_ontoolresult: bool,
218}
219
220#[allow(dead_code)]
248fn strip_js_comments(src: &str) -> String {
249 let after_html = html_comment_re().replace_all(src, "");
251
252 let bytes = after_html.as_bytes();
253 let mut out = String::with_capacity(bytes.len());
254 let mut i = 0;
255 let n = bytes.len();
256
257 let mut state: u8 = 0;
265
266 while i < n {
267 let c = bytes[i];
268 i = match state {
269 0 => step_js_outside(c, bytes, i, n, &mut out, &mut state),
270 1 => step_js_block_comment(c, bytes, i, n, &mut state),
271 2 => step_js_line_comment(c, &mut out, i, &mut state),
272 3..=5 => step_js_string(c, bytes, i, n, &mut out, &mut state),
273 _ => unreachable!(),
274 };
275 }
276
277 out
278}
279
280fn step_js_outside(
283 c: u8,
284 bytes: &[u8],
285 i: usize,
286 n: usize,
287 out: &mut String,
288 state: &mut u8,
289) -> usize {
290 if c == b'/' && i + 1 < n && bytes[i + 1] == b'*' {
291 *state = 1;
292 return i + 2;
293 }
294 if c == b'/' && i + 1 < n && bytes[i + 1] == b'/' {
295 *state = 2;
296 return i + 2;
297 }
298 if c == b'\'' {
299 *state = 3;
300 } else if c == b'"' {
301 *state = 4;
302 } else if c == b'`' {
303 *state = 5;
304 }
305 out.push(c as char);
306 i + 1
307}
308
309fn step_js_block_comment(c: u8, bytes: &[u8], i: usize, n: usize, state: &mut u8) -> usize {
312 if c == b'*' && i + 1 < n && bytes[i + 1] == b'/' {
313 *state = 0;
314 i + 2
315 } else {
316 i + 1
317 }
318}
319
320fn step_js_line_comment(c: u8, out: &mut String, i: usize, state: &mut u8) -> usize {
323 if c == b'\n' || c == b'\r' {
324 *state = 0;
325 out.push(c as char);
326 }
327 i + 1
328}
329
330fn step_js_string(
334 c: u8,
335 bytes: &[u8],
336 i: usize,
337 n: usize,
338 out: &mut String,
339 state: &mut u8,
340) -> usize {
341 if c == b'\\' && i + 1 < n {
342 out.push(c as char);
343 out.push(bytes[i + 1] as char);
344 return i + 2;
345 }
346 let close = match *state {
347 3 => b'\'',
348 4 => b'"',
349 5 => b'`',
350 _ => unreachable!(),
351 };
352 if c == close {
353 *state = 0;
354 }
355 out.push(c as char);
356 i + 1
357}
358
359#[allow(dead_code)]
364fn extract_inline_scripts(html: &str) -> String {
365 let mut out = String::new();
366 for cap in script_block_re().captures_iter(html) {
367 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
368 if attrs.contains("application/json") || attrs.contains("src=") {
369 continue;
370 }
371 if let Some(body) = cap.name("body") {
372 let stripped = strip_js_comments(body.as_str());
375 out.push_str(&stripped);
376 out.push('\n');
377 }
378 }
379 out
380}
381
382#[allow(dead_code)]
384fn scan_widget(html: &str) -> WidgetSignals {
385 let scripts = extract_inline_scripts(html);
386 let mut handlers_present: Vec<&'static str> = Vec::new();
387 if handler_onteardown_re().is_match(&scripts) {
388 handlers_present.push("onteardown");
389 }
390 if handler_ontoolinput_re().is_match(&scripts) {
391 handlers_present.push("ontoolinput");
392 }
393 if handler_ontoolcancelled_re().is_match(&scripts) {
394 handlers_present.push("ontoolcancelled");
395 }
396 if handler_onerror_re().is_match(&scripts) {
397 handlers_present.push("onerror");
398 }
399 let has_ext_apps_import = ext_apps_import_re().is_match(&scripts);
401 let has_log_prefix = ext_apps_log_prefix_re().is_match(&scripts);
402 let has_method_initialize = ui_initialize_method_re().is_match(&scripts);
403 let has_method_tool_result = ui_tool_result_method_re().is_match(&scripts);
404 let has_sdk =
405 has_ext_apps_import || has_log_prefix || has_method_initialize || has_method_tool_result;
406 let has_handlers = !handlers_present.is_empty();
407 WidgetSignals {
408 has_ext_apps_import,
409 has_log_prefix,
410 has_method_initialize,
411 has_method_tool_result,
412 has_sdk,
413 has_app_constructor: app_constructor_re().is_match(&scripts),
414 has_connect: connect_call_re().is_match(&scripts),
415 has_chatgpt_only_channels: chatgpt_only_channels_re().is_match(&scripts),
416 handlers_present,
417 has_handlers,
418 has_ontoolresult: handler_ontoolresult_re().is_match(&scripts),
419 }
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424pub enum AppValidationMode {
425 Standard,
427 ChatGpt,
429 ClaudeDesktop,
445}
446
447impl std::fmt::Display for AppValidationMode {
448 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 match self {
450 Self::Standard => write!(f, "standard"),
451 Self::ChatGpt => write!(f, "chatgpt"),
452 Self::ClaudeDesktop => write!(f, "claude-desktop"),
453 }
454 }
455}
456
457impl std::str::FromStr for AppValidationMode {
458 type Err = String;
459 fn from_str(s: &str) -> Result<Self, Self::Err> {
460 match s {
461 "standard" => Ok(Self::Standard),
462 "chatgpt" => Ok(Self::ChatGpt),
463 "claude-desktop" => Ok(Self::ClaudeDesktop),
464 other => Err(format!(
465 "Unknown validation mode: '{other}'. Valid: standard, chatgpt, claude-desktop"
466 )),
467 }
468 }
469}
470
471pub struct AppValidator {
473 mode: AppValidationMode,
474 tool_filter: Option<String>,
475}
476
477impl AppValidator {
478 pub fn new(mode: AppValidationMode, tool_filter: Option<String>) -> Self {
480 Self { mode, tool_filter }
481 }
482
483 pub fn validate_tools(
485 &self,
486 tools: &[ToolInfo],
487 resources: &[ResourceInfo],
488 ) -> Vec<TestResult> {
489 let mut results = Vec::new();
490
491 let app_tools: Vec<&ToolInfo> = tools
492 .iter()
493 .filter(|t| {
494 if let Some(ref filter) = self.tool_filter {
495 t.name == *filter
496 } else {
497 Self::is_app_capable(t)
498 }
499 })
500 .collect();
501
502 if app_tools.is_empty() {
503 return results;
504 }
505
506 for tool in &app_tools {
507 let uri = Self::extract_resource_uri(tool);
508 results.extend(self.validate_tool_meta(tool, uri.as_deref()));
509
510 if let Some(ref uri) = uri {
511 results.extend(self.validate_resource_match(&tool.name, uri, resources));
512 }
513
514 if self.mode == AppValidationMode::ChatGpt {
515 if let Some(ref meta) = tool._meta {
516 results.extend(self.validate_chatgpt_keys(&tool.name, meta));
517 }
518 }
519
520 if let Some(ref schema) = tool.output_schema {
521 results.extend(self.validate_output_schema(&tool.name, schema));
522 }
523 }
524
525 results
526 }
527
528 pub fn is_app_capable(tool: &ToolInfo) -> bool {
530 Self::extract_resource_uri(tool).is_some()
531 }
532
533 pub fn extract_resource_uri(tool: &ToolInfo) -> Option<String> {
538 let meta = tool._meta.as_ref()?;
539
540 if let Some(Value::Object(ui)) = meta.get("ui") {
542 if let Some(Value::String(uri)) = ui.get("resourceUri") {
543 return Some(uri.clone());
544 }
545 }
546
547 if let Some(Value::String(uri)) = meta.get("ui/resourceUri") {
549 return Some(uri.clone());
550 }
551
552 None
553 }
554
555 fn validate_tool_meta(&self, tool: &ToolInfo, uri: Option<&str>) -> Vec<TestResult> {
557 let mut results = Vec::new();
558 let tool_name = &tool.name;
559
560 if tool._meta.is_none() {
561 results.push(TestResult {
562 name: format!("[{tool_name}] _meta present"),
563 category: TestCategory::Apps,
564 status: TestStatus::Failed,
565 duration: Duration::from_secs(0),
566 error: Some("Tool has no _meta field".to_string()),
567 details: None,
568 });
569 return results;
570 }
571
572 match uri {
573 Some(uri) => {
574 results.push(TestResult {
575 name: format!("[{tool_name}] ui.resourceUri present"),
576 category: TestCategory::Apps,
577 status: TestStatus::Passed,
578 duration: Duration::from_secs(0),
579 error: None,
580 details: None,
581 });
582
583 if uri.is_empty() || !uri.contains("://") {
585 results.push(TestResult {
586 name: format!("[{tool_name}] resourceUri format"),
587 category: TestCategory::Apps,
588 status: TestStatus::Warning,
589 duration: Duration::from_secs(0),
590 error: None,
591 details: Some(format!(
592 "URI may not be well-formed: '{uri}' (no scheme separator)"
593 )),
594 });
595 } else {
596 results.push(TestResult {
597 name: format!("[{tool_name}] resourceUri format"),
598 category: TestCategory::Apps,
599 status: TestStatus::Passed,
600 duration: Duration::from_secs(0),
601 error: None,
602 details: Some(format!("URI: {uri}")),
603 });
604 }
605 },
606 None => {
607 results.push(TestResult {
608 name: format!("[{tool_name}] ui.resourceUri present"),
609 category: TestCategory::Apps,
610 status: TestStatus::Failed,
611 duration: Duration::from_secs(0),
612 error: Some(
613 "_meta exists but missing ui.resourceUri (nested or flat)".to_string(),
614 ),
615 details: None,
616 });
617 },
618 }
619
620 results
621 }
622
623 fn validate_resource_match(
625 &self,
626 tool_name: &str,
627 resource_uri: &str,
628 resources: &[ResourceInfo],
629 ) -> Vec<TestResult> {
630 let mut results = Vec::new();
631
632 let matching = resources.iter().find(|r| r.uri == resource_uri);
633
634 match matching {
635 None => {
636 results.push(TestResult {
637 name: format!("[{tool_name}] resource cross-reference"),
638 category: TestCategory::Apps,
639 status: TestStatus::Warning,
640 duration: Duration::from_secs(0),
641 error: None,
642 details: Some(format!(
643 "No resource found with URI '{resource_uri}' in resources/list"
644 )),
645 });
646 },
647 Some(resource) => {
648 results.push(TestResult {
649 name: format!("[{tool_name}] resource cross-reference"),
650 category: TestCategory::Apps,
651 status: TestStatus::Passed,
652 duration: Duration::from_secs(0),
653 error: None,
654 details: Some(format!("Found resource: {}", resource.name)),
655 });
656
657 match &resource.mime_type {
659 None => {
660 results.push(TestResult {
661 name: format!("[{tool_name}] resource MIME type"),
662 category: TestCategory::Apps,
663 status: TestStatus::Warning,
664 duration: Duration::from_secs(0),
665 error: None,
666 details: Some("Resource has no MIME type set".to_string()),
667 });
668 },
669 Some(mime) => {
670 let is_valid = APP_MIME_TYPES.iter().any(|v| mime.eq_ignore_ascii_case(v));
671
672 if is_valid {
673 results.push(TestResult {
674 name: format!("[{tool_name}] resource MIME type"),
675 category: TestCategory::Apps,
676 status: TestStatus::Passed,
677 duration: Duration::from_secs(0),
678 error: None,
679 details: Some(format!("MIME type: {mime}")),
680 });
681 } else {
682 results.push(TestResult {
683 name: format!("[{tool_name}] resource MIME type"),
684 category: TestCategory::Apps,
685 status: TestStatus::Warning,
686 duration: Duration::from_secs(0),
687 error: None,
688 details: Some(format!(
689 "Unexpected MIME type '{mime}', expected one of: {}",
690 APP_MIME_TYPES.join(", ")
691 )),
692 });
693 }
694 },
695 }
696 },
697 }
698
699 results
700 }
701
702 fn validate_chatgpt_keys(
704 &self,
705 tool_name: &str,
706 meta: &serde_json::Map<String, Value>,
707 ) -> Vec<TestResult> {
708 let mut results = Vec::new();
709
710 for key in CHATGPT_DESCRIPTOR_KEYS {
711 let present = meta.get(*key).is_some();
712
713 results.push(TestResult {
714 name: format!("[{tool_name}] ChatGPT key: {key}"),
715 category: TestCategory::Apps,
716 status: if present {
717 TestStatus::Passed
718 } else {
719 TestStatus::Warning
720 },
721 duration: Duration::from_secs(0),
722 error: None,
723 details: if present {
724 None
725 } else {
726 Some(format!("Missing ChatGPT key: {key}"))
727 },
728 });
729 }
730
731 let has_flat = meta.get("ui/resourceUri").is_some();
733
734 results.push(TestResult {
735 name: format!("[{tool_name}] ChatGPT flat ui/resourceUri"),
736 category: TestCategory::Apps,
737 status: if has_flat {
738 TestStatus::Passed
739 } else {
740 TestStatus::Warning
741 },
742 duration: Duration::from_secs(0),
743 error: None,
744 details: if has_flat {
745 None
746 } else {
747 Some("Missing flat legacy key ui/resourceUri (needed for ChatGPT)".to_string())
748 },
749 });
750
751 results
752 }
753
754 fn validate_output_schema(&self, tool_name: &str, schema: &Value) -> Vec<TestResult> {
756 let mut results = Vec::new();
757
758 let is_valid = schema.is_object() && schema.get("type").is_some();
759
760 results.push(TestResult {
761 name: format!("[{tool_name}] outputSchema structure"),
762 category: TestCategory::Apps,
763 status: if is_valid {
764 TestStatus::Passed
765 } else {
766 TestStatus::Warning
767 },
768 duration: Duration::from_secs(0),
769 error: None,
770 details: if is_valid {
771 None
772 } else {
773 Some("outputSchema should be an object with a 'type' field".to_string())
774 },
775 });
776
777 results
778 }
779
780 #[allow(dead_code)]
807 pub fn validate_widgets(&self, widget_bodies: &[(String, String, String)]) -> Vec<TestResult> {
808 if matches!(self.mode, AppValidationMode::ChatGpt) {
811 return Vec::new();
812 }
813 let mut results = Vec::new();
814 for (tool_name, uri, html) in widget_bodies {
815 let signals = scan_widget(html);
816 match self.mode {
817 AppValidationMode::ClaudeDesktop => {
818 results.extend(self.emit_results_for_claude_desktop(tool_name, uri, &signals));
819 },
820 AppValidationMode::Standard => {
821 if let Some(summary) =
822 self.emit_summary_warning_for_standard(tool_name, uri, &signals)
823 {
824 results.push(summary);
825 }
826 },
827 AppValidationMode::ChatGpt => {},
829 }
830 }
831 results
832 }
833
834 #[allow(dead_code)]
838 fn emit_results_for_claude_desktop(
839 &self,
840 tool_name: &str,
841 uri: &str,
842 s: &WidgetSignals,
843 ) -> Vec<TestResult> {
844 let mut out = Vec::new();
845 out.push(self.widget_result_strict(
847 tool_name,
848 uri,
849 "MCP Apps SDK wiring",
850 s.has_sdk,
851 "Widget does not contain any of the four SDK-presence signals: `@modelcontextprotocol/ext-apps` import literal, `[ext-apps]` log prefix, `ui/initialize` method literal, or `ui/notifications/tool-result` method literal. [guide:handlers-before-connect]",
852 ));
853 out.push(self.widget_result_strict(
855 tool_name,
856 uri,
857 "App constructor",
858 s.has_app_constructor,
859 "Widget does not call `new <App>({name, version})`. Searched for any minified-id constructor (e.g. `new yl({name:..., version:...})`). [guide:handlers-before-connect]",
860 ));
861 for name in ["onteardown", "ontoolinput", "ontoolcancelled", "onerror"] {
863 let present = s.handlers_present.contains(&name);
864 out.push(self.widget_result_strict(
865 tool_name,
866 uri,
867 &format!("handler: {name}"),
868 present,
869 &format!("Widget does not register `app.{name}` before `connect()`. [guide:handlers-before-connect]"),
870 ));
871 }
872 out.push(self.widget_ontoolresult_result(tool_name, uri, s));
874 out.push(self.widget_result_strict(
876 tool_name,
877 uri,
878 "connect() call",
879 s.has_connect,
880 "Widget does not call `app.connect()`. [guide:handlers-before-connect]",
881 ));
882 if s.has_chatgpt_only_channels && !s.has_sdk && s.handlers_present.is_empty() {
884 out.push(self.widget_chatgpt_only_failed(tool_name, uri));
885 }
886 out
887 }
888
889 #[allow(dead_code)]
894 fn emit_summary_warning_for_standard(
895 &self,
896 tool_name: &str,
897 uri: &str,
898 s: &WidgetSignals,
899 ) -> Option<TestResult> {
900 let mut missing: Vec<String> = Vec::new();
901 if !s.has_sdk {
902 missing.push(
903 "MCP Apps SDK presence (any of: @modelcontextprotocol/ext-apps import, [ext-apps] log prefix, ui/initialize method, or ui/notifications/tool-result method)"
904 .to_string(),
905 );
906 }
907 if !s.has_app_constructor {
908 missing.push("App constructor (looked for `new <id>({name, version})`)".to_string());
909 }
910 for name in ["onteardown", "ontoolinput", "ontoolcancelled", "onerror"] {
911 if !s.handlers_present.contains(&name) {
912 missing.push(format!("handler: {name}"));
913 }
914 }
915 if !s.has_connect {
916 missing.push("app.connect() call".to_string());
917 }
918 if missing.is_empty() {
919 return None;
920 }
921 let details = format!(
922 "Widget is missing {n} required signal(s): {list}. For Claude Desktop compatibility, run `--mode claude-desktop` to see per-signal errors. [guide:handlers-before-connect]",
923 n = missing.len(),
924 list = missing.join(", "),
925 );
926 Some(TestResult {
927 name: format!("[{tool_name}][{uri}] MCP Apps widget wiring (summary)"),
928 category: TestCategory::Apps,
929 status: TestStatus::Warning,
930 duration: Duration::from_secs(0),
931 error: None,
932 details: Some(details),
933 })
934 }
935
936 #[allow(dead_code)]
939 fn widget_result_strict(
940 &self,
941 tool_name: &str,
942 uri: &str,
943 label: &str,
944 present: bool,
945 missing_details: &str,
946 ) -> TestResult {
947 TestResult {
948 name: format!("[{tool_name}][{uri}] {label}"),
949 category: TestCategory::Apps,
950 status: if present {
951 TestStatus::Passed
952 } else {
953 TestStatus::Failed
954 },
955 duration: Duration::from_secs(0),
956 error: None,
957 details: if present {
958 None
959 } else {
960 Some(missing_details.to_string())
961 },
962 }
963 }
964
965 #[allow(dead_code)]
966 fn widget_ontoolresult_result(
967 &self,
968 tool_name: &str,
969 uri: &str,
970 s: &WidgetSignals,
971 ) -> TestResult {
972 TestResult {
976 name: format!("[{tool_name}][{uri}] handler: ontoolresult"),
977 category: TestCategory::Apps,
978 status: if s.has_ontoolresult {
979 TestStatus::Passed
980 } else {
981 TestStatus::Warning
982 },
983 duration: Duration::from_secs(0),
984 error: None,
985 details: if s.has_ontoolresult {
986 None
987 } else {
988 Some("Widget does not register `app.ontoolresult` (soft warning — may render from getHostContext().toolOutput). [guide:handlers-before-connect]".to_string())
989 },
990 }
991 }
992
993 #[allow(dead_code)]
994 fn widget_chatgpt_only_failed(&self, tool_name: &str, uri: &str) -> TestResult {
995 TestResult {
998 name: format!("[{tool_name}][{uri}] chatgpt-only channels detected"),
999 category: TestCategory::Apps,
1000 status: TestStatus::Failed,
1001 duration: Duration::from_secs(0),
1002 error: None,
1003 details: Some(
1004 "Widget uses `window.openai`/`window.mcpBridge` channels but does not wire ext-apps SDK. ChatGPT will render fine; Claude Desktop will tear down the connection. [guide:common-failures-claude]".to_string(),
1005 ),
1006 }
1007 }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013 use serde_json::json;
1014
1015 fn make_tool(name: &str, meta: Option<serde_json::Map<String, Value>>) -> ToolInfo {
1016 let mut tool = ToolInfo::new(name, None, json!({"type": "object"}));
1017 tool._meta = meta;
1018 tool
1019 }
1020
1021 fn make_resource(uri: &str, mime: Option<&str>) -> ResourceInfo {
1022 let mut info = ResourceInfo::new(uri, uri);
1023 if let Some(m) = mime {
1024 info = info.with_mime_type(m);
1025 }
1026 info
1027 }
1028
1029 #[test]
1030 fn test_is_app_capable_nested() {
1031 let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
1032 "ui": { "resourceUri": "ui://app/test" }
1033 }))
1034 .unwrap();
1035 let tool = make_tool("t1", Some(meta));
1036 assert!(AppValidator::is_app_capable(&tool));
1037 }
1038
1039 #[test]
1040 fn test_is_app_capable_flat() {
1041 let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
1042 "ui/resourceUri": "ui://app/test"
1043 }))
1044 .unwrap();
1045 let tool = make_tool("t2", Some(meta));
1046 assert!(AppValidator::is_app_capable(&tool));
1047 }
1048
1049 #[test]
1050 fn test_not_app_capable() {
1051 let tool = make_tool("t3", None);
1052 assert!(!AppValidator::is_app_capable(&tool));
1053 }
1054
1055 #[test]
1056 fn test_validate_tools_no_app_tools() {
1057 let validator = AppValidator::new(AppValidationMode::Standard, None);
1058 let tools = vec![make_tool("plain", None)];
1059 let results = validator.validate_tools(&tools, &[]);
1060 assert!(results.is_empty());
1061 }
1062
1063 #[test]
1064 fn test_validate_tools_with_resource_match() {
1065 let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
1066 "ui": { "resourceUri": "ui://app/chess" }
1067 }))
1068 .unwrap();
1069 let tool = make_tool("chess", Some(meta));
1070 let resource = make_resource("ui://app/chess", Some("text/html"));
1071
1072 let validator = AppValidator::new(AppValidationMode::Standard, None);
1073 let results = validator.validate_tools(&[tool], &[resource]);
1074
1075 let passed = results
1076 .iter()
1077 .filter(|r| r.status == TestStatus::Passed)
1078 .count();
1079 assert!(
1080 passed >= 3,
1081 "Expected at least 3 passed results, got {passed}"
1082 );
1083 }
1084
1085 #[test]
1086 fn test_chatgpt_mode_checks_openai_keys() {
1087 let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
1088 "ui": { "resourceUri": "ui://app/test" },
1089 "openai/outputTemplate": "<div></div>"
1090 }))
1091 .unwrap();
1092 let tool = make_tool("t", Some(meta));
1093
1094 let validator = AppValidator::new(AppValidationMode::ChatGpt, None);
1095 let results = validator.validate_tools(&[tool], &[]);
1096
1097 let chatgpt_results: Vec<_> = results
1098 .iter()
1099 .filter(|r| r.name.contains("ChatGPT"))
1100 .collect();
1101 assert!(!chatgpt_results.is_empty());
1102 }
1103
1104 #[test]
1105 fn test_strict_mode_promotes_warnings() {
1106 let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
1107 "ui": { "resourceUri": "ui://app/test" }
1108 }))
1109 .unwrap();
1110 let tool = make_tool("t", Some(meta));
1111
1112 let validator = AppValidator::new(AppValidationMode::Standard, None);
1113 let mut results = validator.validate_tools(&[tool], &[]);
1114
1115 for r in &mut results {
1117 if r.status == TestStatus::Warning {
1118 r.status = TestStatus::Failed;
1119 }
1120 }
1121 let warnings = results
1122 .iter()
1123 .filter(|r| r.status == TestStatus::Warning)
1124 .count();
1125 assert_eq!(warnings, 0, "Strict mode should have zero warnings");
1126 }
1127
1128 #[test]
1129 fn test_tool_filter() {
1130 let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
1131 "ui": { "resourceUri": "ui://app/chess" }
1132 }))
1133 .unwrap();
1134 let tool1 = make_tool("chess", Some(meta));
1135 let tool2 = make_tool("other", None);
1136
1137 let validator = AppValidator::new(AppValidationMode::Standard, Some("other".to_string()));
1138 let results = validator.validate_tools(&[tool1, tool2], &[]);
1139
1140 assert!(results.iter().any(|r| r.name.contains("other")));
1142 assert!(!results.iter().any(|r| r.name.contains("chess")));
1143 }
1144
1145 fn make_widget_html(snippets: &[&str]) -> String {
1152 let mut s = String::from("<!doctype html><html><body>");
1153 for snip in snippets {
1154 s.push_str("<script>");
1155 s.push_str(snip);
1156 s.push_str("</script>");
1157 }
1158 s.push_str("</body></html>");
1159 s
1160 }
1161
1162 #[test]
1163 fn regexes_compile() {
1164 let _ = script_block_re();
1166 let _ = ext_apps_import_re();
1167 let _ = ext_apps_log_prefix_re();
1168 let _ = ui_initialize_method_re();
1169 let _ = ui_tool_result_method_re();
1170 let _ = app_constructor_re();
1171 let _ = handler_onteardown_re();
1172 let _ = handler_ontoolinput_re();
1173 let _ = handler_ontoolcancelled_re();
1174 let _ = handler_onerror_re();
1175 let _ = handler_ontoolresult_re();
1176 let _ = connect_call_re();
1177 let _ = chatgpt_only_channels_re();
1178 let _ = html_comment_re();
1179 let _ = js_block_comment_re();
1180 let _ = js_line_comment_re();
1181 }
1182
1183 #[test]
1184 fn extract_inline_scripts_concatenates() {
1185 let out = extract_inline_scripts("<script>A</script><script>B</script>");
1186 assert!(out.contains('A'), "must contain script body A: {out}");
1187 assert!(out.contains('B'), "must contain script body B: {out}");
1188 }
1189
1190 #[test]
1191 fn extract_inline_scripts_excludes_json() {
1192 let html = r#"<script type="application/json">{"x":"@modelcontextprotocol/ext-apps"}</script><script>real</script>"#;
1193 let out = extract_inline_scripts(html);
1194 assert!(
1195 !out.contains("@modelcontextprotocol/ext-apps"),
1196 "JSON data island must NOT be included: {out}"
1197 );
1198 assert!(
1199 out.contains("real"),
1200 "real script body must be present: {out}"
1201 );
1202 }
1203
1204 #[test]
1205 fn extract_inline_scripts_excludes_src() {
1206 let html = r#"<script src="foo.js"></script><script>inline</script>"#;
1209 let out = extract_inline_scripts(html);
1210 assert!(out.contains("inline"), "inline body must remain: {out}");
1211 assert!(
1212 !out.contains("foo.js"),
1213 "src attribute must NOT appear in body output: {out}"
1214 );
1215 }
1216
1217 #[test]
1218 fn scan_widget_detects_handlers_via_property_assignment() {
1219 let html = make_widget_html(&[
1221 r#"var n=new App({name:"x",version:"1.0.0"});n.onteardown=async()=>{};n.ontoolinput=()=>{};n.ontoolcancelled=()=>{};n.onerror=()=>{};n.connect();"#,
1222 ]);
1223 let signals = scan_widget(&html);
1224 assert!(signals.has_app_constructor, "must detect new App({{...}})");
1225 assert!(signals.has_connect, "must detect .connect()");
1226 assert_eq!(
1227 signals.handlers_present.len(),
1228 4,
1229 "must detect all 4 handlers via property-assignment regex (got {:?})",
1230 signals.handlers_present
1231 );
1232 }
1233
1234 #[test]
1235 fn scan_widget_detects_import_literal() {
1236 let html = r#"<!doctype html><html><body><script type="module">
1237 import { App } from "@modelcontextprotocol/ext-apps";
1238 const a=new App({name:"x",version:"1"});
1239 a.connect();
1240 </script></body></html>"#;
1241 let signals = scan_widget(html);
1242 assert!(
1243 signals.has_ext_apps_import,
1244 "must detect @modelcontextprotocol/ext-apps import literal"
1245 );
1246 }
1247
1248 #[test]
1249 fn scan_widget_detects_chatgpt_only_channels() {
1250 let html = make_widget_html(&[r#"window.openai.something()"#]);
1251 let signals = scan_widget(&html);
1252 assert!(
1253 signals.has_chatgpt_only_channels,
1254 "must detect window.openai usage"
1255 );
1256 }
1257
1258 #[test]
1259 fn strip_js_comments_strips_line_comments() {
1260 let out = strip_js_comments("a // hidden\nb");
1261 assert!(
1262 !out.contains("hidden"),
1263 "line-comment text must be stripped: {out}"
1264 );
1265 }
1266
1267 #[test]
1268 fn strip_js_comments_strips_block_comments() {
1269 let out = strip_js_comments("a /* hidden */ b");
1270 assert!(
1271 !out.contains("hidden"),
1272 "block-comment text must be stripped: {out}"
1273 );
1274 assert!(out.contains('a'), "non-comment 'a' must remain: {out}");
1275 assert!(out.contains('b'), "non-comment 'b' must remain: {out}");
1276 }
1277
1278 #[test]
1279 fn strip_js_comments_strips_html_comments() {
1280 let out = strip_js_comments("<!-- hidden -->visible");
1281 assert!(
1282 !out.contains("hidden"),
1283 "html-comment text must be stripped: {out}"
1284 );
1285 assert!(
1286 out.contains("visible"),
1287 "non-comment 'visible' must remain: {out}"
1288 );
1289 }
1290
1291 #[test]
1292 fn scan_widget_ignores_signals_inside_comments() {
1293 let html = r#"<!doctype html><html><body><script type="module">
1296 // import { App } from "@modelcontextprotocol/ext-apps";
1297 /* const a = new App({name:"x",version:"1"});
1298 a.onteardown=()=>{}; a.ontoolinput=()=>{}; */
1299 <!-- a.connect(); a.onerror=()=>{}; a.ontoolcancelled=()=>{}; -->
1300 </script></body></html>"#;
1301 let signals = scan_widget(html);
1302 assert!(
1303 !signals.has_ext_apps_import,
1304 "ext-apps import in comment must NOT match"
1305 );
1306 assert!(
1307 !signals.has_app_constructor,
1308 "new App() in comment must NOT match"
1309 );
1310 assert!(!signals.has_connect, "connect() in comment must NOT match");
1311 assert!(
1312 signals.handlers_present.is_empty(),
1313 "handlers in comments must NOT match (got {:?})",
1314 signals.handlers_present
1315 );
1316 }
1317
1318 #[test]
1323 fn scan_widget_g1_log_prefix_alone_satisfies_has_sdk() {
1324 let html = r#"<html><body><script>console.log("[ext-apps] boot");</script></body></html>"#;
1325 let s = scan_widget(html);
1326 assert!(s.has_log_prefix, "expected has_log_prefix true");
1327 assert!(s.has_sdk, "G1: log prefix alone must satisfy has_sdk");
1328 assert!(!s.has_ext_apps_import);
1329 assert!(!s.has_method_initialize);
1330 assert!(!s.has_method_tool_result);
1331 }
1332
1333 #[test]
1334 fn scan_widget_g1_method_initialize_alone_satisfies_has_sdk() {
1335 let html = r#"<html><body><script>rpc("ui/initialize",{});</script></body></html>"#;
1336 let s = scan_widget(html);
1337 assert!(
1338 s.has_method_initialize,
1339 "expected has_method_initialize true"
1340 );
1341 assert!(s.has_sdk, "G1: ui/initialize alone must satisfy has_sdk");
1342 }
1343
1344 #[test]
1345 fn scan_widget_g1_method_tool_result_alone_satisfies_has_sdk() {
1346 let html =
1347 r#"<html><body><script>rpc("ui/notifications/tool-result",{});</script></body></html>"#;
1348 let s = scan_widget(html);
1349 assert!(
1350 s.has_method_tool_result,
1351 "expected has_method_tool_result true"
1352 );
1353 assert!(
1354 s.has_sdk,
1355 "G1: ui/notifications/tool-result alone must satisfy has_sdk"
1356 );
1357 }
1358
1359 #[test]
1360 fn scan_widget_g1_legacy_import_still_satisfies_has_sdk() {
1361 let html = r#"<html><body><script type="module">import { App } from "@modelcontextprotocol/ext-apps";</script></body></html>"#;
1362 let s = scan_widget(html);
1363 assert!(s.has_ext_apps_import, "expected has_ext_apps_import true");
1364 assert!(
1365 s.has_sdk,
1366 "Legacy import literal must still satisfy has_sdk"
1367 );
1368 }
1369
1370 #[test]
1371 fn scan_widget_g1_no_signals_means_no_sdk() {
1372 let html = r#"<html><body><script>var x=1;</script></body></html>"#;
1373 let s = scan_widget(html);
1374 assert!(!s.has_sdk, "no SDK signals → has_sdk = false");
1375 assert!(!s.has_ext_apps_import);
1376 assert!(!s.has_log_prefix);
1377 assert!(!s.has_method_initialize);
1378 assert!(!s.has_method_tool_result);
1379 }
1380
1381 #[test]
1382 fn scan_widget_g2_mangled_yl_constructor_matches() {
1383 let html = r#"<html><body><script>var a=new yl({name:"cost-coach-cost-summary",version:"1.0.0"});</script></body></html>"#;
1384 let s = scan_widget(html);
1385 assert!(
1386 s.has_app_constructor,
1387 "G2: mangled `yl` constructor with intact payload must match"
1388 );
1389 }
1390
1391 #[test]
1392 fn scan_widget_g2_mangled_gl_constructor_matches() {
1393 let html = r#"<html><body><script>var a=new gl({name:"cost-coach-cost-over-time",version:"1.0.0"});</script></body></html>"#;
1394 let s = scan_widget(html);
1395 assert!(
1396 s.has_app_constructor,
1397 "G2: mangled `gl` constructor must match"
1398 );
1399 }
1400
1401 #[test]
1402 fn scan_widget_g2_unminified_app_constructor_still_matches() {
1403 let html = r#"<html><body><script type="module">const app = new App({name: "tool", version: "1.0.0"});</script></body></html>"#;
1404 let s = scan_widget(html);
1405 assert!(
1406 s.has_app_constructor,
1407 "G2: unminified `App` constructor must still match (App is a valid identifier under the regex)"
1408 );
1409 }
1410
1411 #[test]
1412 fn scan_widget_g2_random_new_call_without_name_version_payload_does_not_match() {
1413 let html = r#"<html><body><script>var d=new Date(2026,1,1);var u=new URL("http://x");</script></body></html>"#;
1414 let s = scan_widget(html);
1415 assert!(
1416 !s.has_app_constructor,
1417 "G2: random `new <X>(...)` calls without {{name, version}} payload must NOT match"
1418 );
1419 }
1420
1421 #[test]
1426 fn scan_widget_g3_handlers_detected_independently_of_has_sdk() {
1427 let html = r#"<html><body><script>
1430 var obj={};
1431 obj.onteardown=async()=>({});
1432 obj.ontoolinput=function(p){};
1433 obj.ontoolcancelled=function(p){};
1434 obj.onerror=function(e){};
1435 obj.connect();
1436 </script></body></html>"#;
1437 let s = scan_widget(html);
1438 assert!(!s.has_sdk, "G3: no SDK signals → has_sdk false");
1439 assert!(
1440 !s.has_app_constructor,
1441 "G3: no constructor → has_app_constructor false"
1442 );
1443 assert!(
1444 s.has_handlers,
1445 "G3: handlers detected independently of has_sdk"
1446 );
1447 assert!(
1448 s.has_connect,
1449 "G3: connect detected independently of has_sdk"
1450 );
1451 assert_eq!(
1452 s.handlers_present.len(),
1453 4,
1454 "G3: all 4 handlers detected by member-name regex"
1455 );
1456 }
1457
1458 #[test]
1459 fn scan_widget_g3_chatgpt_only_diagnosis_requires_genuine_evidence_absence() {
1460 let html_a = r#"<html><body><script>window.openai.x();</script></body></html>"#;
1462 let s_a = scan_widget(html_a);
1463 assert!(s_a.has_chatgpt_only_channels);
1464 assert!(!s_a.has_sdk);
1465 assert!(s_a.handlers_present.is_empty());
1466 let html_b = r#"<html><body><script>console.log("[ext-apps]");window.openai.x();</script></body></html>"#;
1469 let s_b = scan_widget(html_b);
1470 assert!(s_b.has_chatgpt_only_channels);
1471 assert!(s_b.has_sdk);
1472 }
1473
1474 fn corrected_widget_html() -> &'static str {
1481 r#"<!doctype html><html><body><script type="module">
1482 import { App } from "@modelcontextprotocol/ext-apps";
1483 const a = new App({ name: "x", version: "1.0.0" });
1484 a.onteardown = () => {};
1485 a.ontoolinput = () => {};
1486 a.ontoolcancelled = () => {};
1487 a.onerror = () => {};
1488 a.connect();
1489 </script></body></html>"#
1490 }
1491
1492 #[test]
1493 fn claude_desktop_mode_emits_failed_for_missing_handlers() {
1494 let html = r#"<!doctype html><html><body><script type="module">
1495 import { App } from "@modelcontextprotocol/ext-apps";
1496 const a = new App({ name: "x", version: "1.0.0" });
1497 a.connect();
1498 </script></body></html>"#;
1499 let validator = AppValidator::new(AppValidationMode::ClaudeDesktop, None);
1500 let results = validator.validate_widgets(&[(
1501 "cost-coach".to_string(),
1502 "ui://test".to_string(),
1503 html.to_string(),
1504 )]);
1505 let failed: Vec<_> = results
1506 .iter()
1507 .filter(|r| r.status == TestStatus::Failed)
1508 .collect();
1509 assert!(
1510 failed.len() >= 4,
1511 "must emit >=4 Failed rows (got {})",
1512 failed.len()
1513 );
1514 let any_onteardown = failed.iter().any(|r| r.name.contains("onteardown"));
1515 assert!(any_onteardown, "must emit a Failed row naming onteardown");
1516 for r in &failed {
1518 assert!(
1519 r.name.contains("cost-coach"),
1520 "Failed row name must include tool name (REVISION HIGH-4): {}",
1521 r.name
1522 );
1523 }
1524 }
1525
1526 #[test]
1527 fn standard_mode_emits_one_summary_warn_per_widget() {
1528 let html = r#"<!doctype html><html><body><script type="module">
1529 import { App } from "@modelcontextprotocol/ext-apps";
1530 const a = new App({ name: "x", version: "1.0.0" });
1531 a.onerror = () => {};
1532 a.connect();
1533 </script></body></html>"#;
1534 let validator = AppValidator::new(AppValidationMode::Standard, None);
1535 let results = validator.validate_widgets(&[(
1536 "cost-coach".to_string(),
1537 "ui://test".to_string(),
1538 html.to_string(),
1539 )]);
1540 let warns: Vec<_> = results
1541 .iter()
1542 .filter(|r| r.status == TestStatus::Warning)
1543 .collect();
1544 assert_eq!(
1545 warns.len(),
1546 1,
1547 "Standard mode must emit EXACTLY 1 Warning per widget (got {} for results: {:?})",
1548 warns.len(),
1549 results
1550 .iter()
1551 .map(|r| (&r.name, &r.status))
1552 .collect::<Vec<_>>(),
1553 );
1554 let warn = warns[0];
1555 assert!(
1556 warn.name.contains("cost-coach"),
1557 "summary WARN name must include tool name (REVISION HIGH-4): {}",
1558 warn.name
1559 );
1560 assert!(
1561 warn.name.contains("ui://test"),
1562 "summary WARN name must include uri: {}",
1563 warn.name
1564 );
1565 let details = warn
1566 .details
1567 .as_ref()
1568 .expect("summary WARN must have details");
1569 assert!(
1570 details.contains("onteardown"),
1571 "summary details must list onteardown as missing: {details}"
1572 );
1573 assert!(
1574 details.contains("ontoolinput"),
1575 "summary details must list ontoolinput as missing: {details}"
1576 );
1577 assert!(
1578 details.contains("ontoolcancelled"),
1579 "summary details must list ontoolcancelled as missing: {details}"
1580 );
1581 let failed = results
1582 .iter()
1583 .filter(|r| r.status == TestStatus::Failed)
1584 .count();
1585 assert_eq!(
1586 failed, 0,
1587 "Standard mode must NOT emit any Failed rows from widget signals"
1588 );
1589 }
1590
1591 #[test]
1592 fn claude_desktop_mode_passes_corrected_widget() {
1593 let validator = AppValidator::new(AppValidationMode::ClaudeDesktop, None);
1594 let results = validator.validate_widgets(&[(
1595 "good".to_string(),
1596 "ui://good".to_string(),
1597 corrected_widget_html().to_string(),
1598 )]);
1599 let failed = results
1600 .iter()
1601 .filter(|r| r.status == TestStatus::Failed)
1602 .count();
1603 assert_eq!(
1604 failed, 0,
1605 "Corrected widget must produce ZERO Failed rows under ClaudeDesktop (got {failed} for results: {:?})",
1606 results
1607 .iter()
1608 .map(|r| (&r.name, &r.status))
1609 .collect::<Vec<_>>(),
1610 );
1611 }
1612
1613 #[test]
1614 fn sdk_signal_requires_independent_evidence_no_fallback() {
1615 let html = make_widget_html(&[
1622 r#"var n=new App({name:"x",version:"1.0.0"});n.onteardown=()=>{};n.ontoolinput=()=>{};n.ontoolcancelled=()=>{};n.connect();"#,
1623 ]);
1624 let validator = AppValidator::new(AppValidationMode::ClaudeDesktop, None);
1625 let results = validator.validate_widgets(&[(
1626 "minified".to_string(),
1627 "ui://minified".to_string(),
1628 html,
1629 )]);
1630 let sdk_row = results
1631 .iter()
1632 .find(|r| r.name.contains("MCP Apps SDK wiring"))
1633 .expect("must emit MCP Apps SDK wiring row");
1634 assert_eq!(
1635 sdk_row.status,
1636 TestStatus::Failed,
1637 "G3: SDK signal must NOT cascade off handler count — handlers alone do not imply SDK presence: {sdk_row:?}"
1638 );
1639 let onteardown_row = results
1641 .iter()
1642 .find(|r| r.name.contains("handler: onteardown"))
1643 .expect("must emit handler: onteardown row");
1644 assert_eq!(
1645 onteardown_row.status,
1646 TestStatus::Passed,
1647 "G3: handler row passes independently of SDK row: {onteardown_row:?}"
1648 );
1649 }
1650
1651 #[test]
1652 fn chatgpt_only_channels_fails_in_claude_desktop() {
1653 let html = make_widget_html(&[
1655 r#"window.openai.something();window.parent.postMessage({type:"x"}, "*");"#,
1656 ]);
1657 let validator = AppValidator::new(AppValidationMode::ClaudeDesktop, None);
1658 let results = validator.validate_widgets(&[(
1659 "chatgpt-flavored".to_string(),
1660 "ui://chatgpt".to_string(),
1661 html,
1662 )]);
1663 let chatgpt_row = results
1664 .iter()
1665 .find(|r| r.status == TestStatus::Failed && r.name.contains("chatgpt-only"));
1666 assert!(
1667 chatgpt_row.is_some(),
1668 "must emit a Failed row mentioning chatgpt-only channels under ClaudeDesktop (got: {:?})",
1669 results
1670 .iter()
1671 .map(|r| (&r.name, &r.status))
1672 .collect::<Vec<_>>(),
1673 );
1674 }
1675
1676 #[test]
1677 fn chatgpt_mode_emits_no_widget_results() {
1678 let html = r#"<!doctype html><html><body><script>
1683 window.openai = {};
1684 window.parent.postMessage({type:"x"}, "*");
1685 </script></body></html>"#;
1686 let validator = AppValidator::new(AppValidationMode::ChatGpt, None);
1687 let results = validator.validate_widgets(&[(
1688 "broken-tool".to_string(),
1689 "ui://broken".to_string(),
1690 html.to_string(),
1691 )]);
1692 assert_eq!(
1693 results.len(),
1694 0,
1695 "ChatGpt mode must emit zero widget-related rows (got {} rows: {:?})",
1696 results.len(),
1697 results
1698 .iter()
1699 .map(|r| (&r.name, &r.status))
1700 .collect::<Vec<_>>(),
1701 );
1702 }
1703
1704 #[test]
1705 fn claude_desktop_mode_emits_failed_not_warning() {
1706 let html = r#"<!doctype html><html><body><script type="module">
1710 // No handlers at all, just an import + new App + connect.
1711 import { App } from "@modelcontextprotocol/ext-apps";
1712 const a = new App({ name: "x", version: "1.0.0" });
1713 a.connect();
1714 </script></body></html>"#;
1715 let validator = AppValidator::new(AppValidationMode::ClaudeDesktop, None);
1716 let results = validator.validate_widgets(&[(
1717 "broken".to_string(),
1718 "ui://broken".to_string(),
1719 html.to_string(),
1720 )]);
1721 let warning_rows: Vec<_> = results
1723 .iter()
1724 .filter(|r| r.status == TestStatus::Warning)
1725 .collect();
1726 for w in &warning_rows {
1727 assert!(
1728 w.name.contains("ontoolresult"),
1729 "Under ClaudeDesktop, only `ontoolresult` may stay Warning. Found: {}",
1730 w.name
1731 );
1732 }
1733 }
1734
1735 #[test]
1736 fn standard_mode_corrected_widget_emits_zero_warnings() {
1737 let validator = AppValidator::new(AppValidationMode::Standard, None);
1738 let results = validator.validate_widgets(&[(
1739 "good".to_string(),
1740 "ui://good".to_string(),
1741 corrected_widget_html().to_string(),
1742 )]);
1743 let warnings = results
1744 .iter()
1745 .filter(|r| r.status == TestStatus::Warning)
1746 .count();
1747 assert_eq!(
1748 warnings, 0,
1749 "Fully corrected widget under Standard mode must produce ZERO Warning rows (got {warnings} for results: {:?})",
1750 results
1751 .iter()
1752 .map(|r| (&r.name, &r.status))
1753 .collect::<Vec<_>>(),
1754 );
1755 }
1756
1757 #[test]
1758 fn chatgpt_mode_corrected_widget_also_emits_zero() {
1759 let validator = AppValidator::new(AppValidationMode::ChatGpt, None);
1761 let results = validator.validate_widgets(&[(
1762 "good".to_string(),
1763 "ui://good".to_string(),
1764 corrected_widget_html().to_string(),
1765 )]);
1766 assert_eq!(
1767 results.len(),
1768 0,
1769 "ChatGpt mode emits zero widget rows even for fully corrected widgets (got: {:?})",
1770 results
1771 .iter()
1772 .map(|r| (&r.name, &r.status))
1773 .collect::<Vec<_>>(),
1774 );
1775 }
1776
1777 #[test]
1784 fn strip_js_comments_preserves_block_comment_inside_double_quoted_string() {
1785 let src = r#"var csp = "frame-src /*.example.com"; var i = "[ext-apps] App.connect() failed"; /* license */ var x = 1;"#;
1791 let stripped = strip_js_comments(src);
1792 assert!(
1793 stripped.contains("[ext-apps]"),
1794 "SDK literal must be preserved when /* appears inside a string literal; got: {stripped}",
1795 );
1796 assert!(
1797 stripped.contains("/*.example.com"),
1798 "CSP string content must be preserved; got: {stripped}",
1799 );
1800 assert!(
1801 !stripped.contains("license"),
1802 "Real block comments outside strings MUST still be stripped; got: {stripped}",
1803 );
1804 }
1805
1806 #[test]
1807 fn strip_js_comments_preserves_block_comment_inside_single_quoted_string() {
1808 let src = "var csp = 'frame-src /*.example.com'; /* real */ var x = 1;";
1809 let stripped = strip_js_comments(src);
1810 assert!(stripped.contains("/*.example.com"));
1811 assert!(!stripped.contains("real"));
1812 }
1813
1814 #[test]
1815 fn strip_js_comments_preserves_line_comment_marker_inside_string() {
1816 let src = "var url = \"https://example.com/path\"; // real comment\nvar keep = 1;";
1817 let stripped = strip_js_comments(src);
1818 assert!(
1819 stripped.contains("https://example.com/path"),
1820 "URL string with // must be preserved; got: {stripped}",
1821 );
1822 assert!(
1823 !stripped.contains("real comment"),
1824 "Real // line comment outside strings MUST still be stripped; got: {stripped}",
1825 );
1826 assert!(stripped.contains("var keep = 1"));
1827 }
1828
1829 #[test]
1830 fn strip_js_comments_still_strips_real_block_comments_outside_strings() {
1831 let src = "var x = 1; /* this is a comment */ var y = 2;";
1832 let stripped = strip_js_comments(src);
1833 assert!(!stripped.contains("this is a comment"));
1834 assert!(stripped.contains("var x = 1"));
1835 assert!(stripped.contains("var y = 2"));
1836 }
1837
1838 #[test]
1839 fn strip_js_comments_still_strips_real_line_comments_outside_strings() {
1840 let src = "var x = 1; // line comment\nvar y = 2;";
1841 let stripped = strip_js_comments(src);
1842 assert!(!stripped.contains("line comment"));
1843 assert!(stripped.contains("var x = 1"));
1844 assert!(stripped.contains("var y = 2"));
1845 }
1846
1847 #[test]
1848 fn strip_js_comments_handles_escaped_string_delimiters() {
1849 let src = r#"var s = "He said \"hi\" /* not a comment */"; var z = 1;"#;
1851 let stripped = strip_js_comments(src);
1852 assert!(
1853 stripped.contains("not a comment"),
1854 "Block-comment-style text inside a string with escaped quotes must be preserved; got: {stripped}",
1855 );
1856 assert!(stripped.contains("var z = 1"));
1857 }
1858
1859 #[test]
1860 fn strip_js_comments_handles_template_literal() {
1861 let src = r#"var t = `template /* not a comment */`; /* real */ var x = 1;"#;
1862 let stripped = strip_js_comments(src);
1863 assert!(stripped.contains("not a comment"));
1864 assert!(!stripped.contains("real"));
1865 }
1866
1867 #[test]
1868 fn scan_widget_g2_cycle2_string_concat_name_value_matches() {
1869 let html = r#"<script>function f(t){var i=new yl({name:"cost-coach-"+t,version:"1.0.0"});}</script>"#;
1874 let signals = scan_widget(html);
1875 assert!(
1876 signals.has_app_constructor,
1877 "G2 cycle-2: must match new <id>({{name:<concat-expr>, version:<expr>}}); signals: {signals:?}",
1878 );
1879 }
1880
1881 #[test]
1882 fn scan_widget_g2_cycle2_longer_mangled_id_matches() {
1883 let html = r#"<script>var i=new abcdefg({name:"x",version:"1"});</script>"#;
1886 let signals = scan_widget(html);
1887 assert!(signals.has_app_constructor);
1888 }
1889
1890 #[test]
1891 fn scan_widget_g2_cycle2_random_new_call_with_unrelated_keys_does_not_match() {
1892 let html = r#"<script>var i=new Date({year:2026,month:1});</script>"#;
1895 let signals = scan_widget(html);
1896 assert!(
1897 !signals.has_app_constructor,
1898 "G2 cycle-2: must NOT match new Date(...) without name/version keys; signals: {signals:?}",
1899 );
1900 }
1901
1902 #[test]
1903 fn scan_widget_g2_cycle2_real_cost_coach_prod_pattern() {
1904 let html = r#"<script>function Rw(t,e){var i=new yl({name:"cost-coach-"+t,version:"1.0.0"});return i.connect(),i;}</script>"#;
1906 let signals = scan_widget(html);
1907 assert!(
1908 signals.has_app_constructor,
1909 "G2 cycle-2: real prod shape; signals: {signals:?}",
1910 );
1911 }
1912
1913 #[test]
1914 fn scan_widget_g1_cycle2_csp_string_does_not_steal_sdk_section() {
1915 let html = concat!(
1923 r##"<script>var csp = "frame-src /*.example.com"; "##,
1924 r##"var msg = "[ext-apps] App.connect() called before connect"; "##,
1925 r##"function f(t){var i=new yl({name:"cost-coach-"+t,version:"1.0.0"}); "##,
1926 r##"i.onteardown = function(){}; "##,
1927 r##"i.ontoolinput = function(){}; "##,
1928 r##"i.ontoolcancelled = function(){}; "##,
1929 r##"i.onerror = function(){}; "##,
1930 r##"i.connect();} "##,
1931 r##"/* @license real banner */ var x = 1;</script>"##,
1932 );
1933 let signals = scan_widget(html);
1934 assert!(
1935 signals.has_log_prefix,
1936 "G1 cycle-2: [ext-apps] preserved through string-literal-aware stripping; signals: {signals:?}",
1937 );
1938 assert!(signals.has_sdk);
1939 assert!(signals.has_app_constructor);
1940 assert!(signals.has_handlers);
1941 assert!(signals.has_connect);
1942 }
1943}