Skip to main content

mcp_tester/
app_validator.rs

1//! MCP App metadata validation for tools and resources.
2//!
3//! Validates that App-capable tools have correct `_meta` structure,
4//! cross-references with `resources/list`, and (in ChatGPT mode)
5//! checks for `openai/*` keys.
6
7use 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
15/// Valid MIME types for MCP App resources.
16const APP_MIME_TYPES: &[&str] = &[
17    "text/html",
18    "text/html+mcp",
19    "text/html+skybridge",
20    "text/html;profile=mcp-app",
21];
22
23// =====================================================================
24// REGEX ACCESSORS — compile-once via OnceLock. Each accessor is cog 1.
25// All regex literals are static, so .unwrap() is safe at runtime.
26//
27// `#[allow(dead_code)]` is applied to the regex accessors and scanner
28// helpers because `mcp-tester` is a lib + bin crate and `src/main.rs`
29// includes `mod app_validator;` directly. The bin currently does NOT
30// invoke `AppValidator::validate_widgets` (Plan 02 wires it via
31// `cargo pmcp test apps`). Until Plan 02 lands, the bin sees these
32// helpers as transitively dead. The lib + tests both exercise them
33// (the new public API is consumed by the unit-test mod).
34// =====================================================================
35
36#[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    // G1: SDK runtime log prefix. Survives Vite singlefile minification because
54    // it's a bracketed string literal in console.log calls inside the inlined
55    // SDK runtime (e.g. `[ext-apps] App.${e}() called before connect`).
56    // Source: cost-coach prod feedback 2026-05-02.
57    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    // G1: JSON-RPC method literal. Protocol-level identifier — minifiers
64    // never rename quoted method-name strings.
65    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    // G1: JSON-RPC method literal. Same rationale as ui/initialize.
72    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    // G2 cycle-2 generalization (Plan 78-10): matches `new <id>({<prefix>
79    // name:<value>,<...>version:<value>...})` where:
80    //   - `<id>` is any 1-21 char JS identifier (mangled or unmangled)
81    //   - `<value>` is any expression up to 100 chars (literal string,
82    //     concatenation, function call, template literal, etc.)
83    //   - `<prefix>` is any non-`}` content up to 200 chars
84    //
85    // Real-prod motivation (cost-coach @ 29f46efd, all 6 widgets):
86    //   `new yl({name:"cost-coach-"+t,version:"1.0.0"})` — string
87    //   concatenation, NOT a literal. The cycle-1 regex required
88    //   `name:"<lit>",version:"<lit>"` and missed all 8 prod widgets per
89    //   uat-evidence/2026-05-02-cost-coach-prod-rerun.md.
90    //
91    // Bounded character classes (`{0,200}`, `{0,100}`) prevent catastrophic
92    // backtracking on adversarial input from the fuzz target's
93    // `\PC{0,4096}` alphabet.
94    //
95    // Continues to match cycle-1 unit-test positives:
96    //   new yl({name:"cost-coach-cost-summary",version:"1.0.0"})
97    //   new App({name: "tool", version: "1.0.0"})
98    // And NOT match cycle-1 negatives:
99    //   new Date(2026,1,1)         (no `{...}` payload)
100    //   new URL("http://x")        (no object payload at all)
101    //   new Foo({foo:1, bar:2})    (no name/version keys)
102    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// REVISION HIGH-3: comment-stripping regexes. Stripped BEFORE signal sweeps so
153// commented-out scaffolding code containing signal literals does not produce
154// false positives.
155#[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    // Match `//` to end of line. Best-effort: this regex does NOT understand
171    // string-literal context, so `// inside a "string"` could in theory be
172    // stripped incorrectly. See `strip_js_comments` docstring for the
173    // accepted simplification.
174    RE.get_or_init(|| Regex::new(r"//[^\r\n]*").unwrap())
175}
176
177/// Static-scan signals for one widget body. Pure data, no methods.
178/// Visibility: `pub(crate)` — internal scanner state can change without
179/// breaking downstream consumers (RESEARCH Open Question 3 RESOLVED).
180#[allow(dead_code)]
181#[derive(Debug, Default, Clone, PartialEq, Eq)]
182pub(crate) struct WidgetSignals {
183    // === SDK-presence signals (G1: any one suffices) ===
184    /// Legacy: literal `@modelcontextprotocol/ext-apps` import. Survives
185    /// in unminified source (Plan 07 `--widgets-dir` path). Vite
186    /// singlefile inlines it away in production bundles.
187    has_ext_apps_import: bool,
188    /// G1: `[ext-apps]` log-prefix string inside the inlined SDK runtime.
189    /// Survives minification because bracketed string literals inside
190    /// console.log calls are not renamed.
191    has_log_prefix: bool,
192    /// G1: `ui/initialize` JSON-RPC method literal. Protocol-level
193    /// identifier; minifiers never rename quoted method-name strings.
194    has_method_initialize: bool,
195    /// G1: `ui/notifications/tool-result` JSON-RPC method literal.
196    has_method_tool_result: bool,
197    /// G1 verdict: OR of the four SDK-presence signals above. This is the
198    /// field consumed by emission helpers (G3 cascade-free).
199    has_sdk: bool,
200    // === Constructor signal (G2 — mangled-id-tolerant) ===
201    /// G2: `true` when a `new <id>({name:"...",version:"..."})` call is
202    /// found. Identifier `<id>` may be any 1-6 char JS identifier
203    /// (mangled by Vite singlefile or the literal `App`).
204    has_app_constructor: bool,
205    // === Connect + handlers (G3 — independent of has_sdk) ===
206    has_connect: bool,
207    has_chatgpt_only_channels: bool,
208    /// Each entry is the handler name found (e.g. "onteardown"). Order is
209    /// stable because the four handlers are tested in a fixed order.
210    /// `ontoolresult` is NOT in this vec — it's a separate field for the
211    /// WARN-tier check.
212    handlers_present: Vec<&'static str>,
213    /// G3: derived — `true` when any handler member-assignment matched (i.e.
214    /// `!handlers_present.is_empty()`). Computed in `scan_widget` from the
215    /// independent member-name regexes; INDEPENDENT of `has_sdk`.
216    has_handlers: bool,
217    has_ontoolresult: bool,
218}
219
220/// Best-effort comment stripper. Strips HTML, JS block, and JS line comments
221/// from `src` while preserving comment delimiters that appear INSIDE JS
222/// string literals.
223///
224/// Cycle-2 fix (Plan 78-10): the cycle-1 implementation used unconditional
225/// regex replacement, which destroyed thousands of bytes of code in
226/// cost-coach prod bundles where a JS string literal contained
227/// `"/*.example.com..."` (a CSP frame-src directive value) — the regex saw
228/// that `/*` as a block-comment opener and matched the next `*/` it found,
229/// far away. See `tests/fixtures/widgets/bundled/real-prod/CAPTURE.md` for
230/// the step-by-step probe evidence.
231///
232/// State machine: outside-strings (`out`) → block_comment / line_comment /
233/// single_string / double_string / template_string. Inside any string state,
234/// `/*` and `//` are NOT comment delimiters and are preserved in the output.
235/// Escape sequences (`\<any>`) are consumed as a unit inside string states.
236///
237/// HTML comments are stripped by the existing `html_comment_re` regex up
238/// front (HTML comments do not appear inside JS string literals in any
239/// realistic bundle).
240///
241/// Accepted simplification: template-literal interpolations `${...}` are
242/// NOT recursively parsed — comment delimiters inside an interpolation
243/// expression are still treated as part of the template string body. Real
244/// prod bundles rarely embed signal literals inside template
245/// interpolations, and full recursive parsing would require tracking
246/// brace depth. The property test exercises arbitrary input.
247#[allow(dead_code)]
248fn strip_js_comments(src: &str) -> String {
249    // Strip HTML comments first; the JS state machine then walks pure JS.
250    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    // States:
258    //   0 = outside strings/comments
259    //   1 = inside /* ... */ block comment
260    //   2 = inside // line comment
261    //   3 = inside '...' single-quoted string
262    //   4 = inside "..." double-quoted string
263    //   5 = inside `...` template-literal string
264    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
280/// State-0 step: outside any string or comment. Detects comment starts,
281/// transitions into string states, and emits ordinary bytes.
282fn 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
309/// State-1 step: inside a `/* ... */` block comment. Bytes are dropped;
310/// `*/` returns to state 0.
311fn 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
320/// State-2 step: inside a `// ...` line comment. Bytes are dropped; the
321/// terminating newline/carriage-return is emitted and returns to state 0.
322fn 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
330/// States 3/4/5 step: inside a `'`/`"`/`` ` `` string. Bytes are emitted
331/// verbatim; escapes consume two bytes; the matching closing quote returns
332/// to state 0.
333fn 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/// Concatenate the bodies of all inline `<script>` tags except those with
360/// `type="application/json"` or `src=` attribute. Strips JS/HTML comments
361/// from each body (REVISION HIGH-3) before concatenation. Returns a single
362/// String for downstream regex sweeps.
363#[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            // REVISION HIGH-3: strip comments BEFORE adding to `out` so the
373            // signal regexes never see commented-out signal literals.
374            let stripped = strip_js_comments(body.as_str());
375            out.push_str(&stripped);
376            out.push('\n');
377        }
378    }
379    out
380}
381
382/// Run the regex sweep over a widget body and return signal flags.
383#[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    // G1: four independent SDK-presence signals (any one suffices).
400    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/// Validation mode controlling which keys are checked.
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424pub enum AppValidationMode {
425    /// Standard mode: nested `ui.resourceUri` only.
426    Standard,
427    /// ChatGPT mode: also checks `openai/*` keys and flat `ui/resourceUri`.
428    ChatGpt,
429    /// Claude Desktop mode: strictly validates widget HTML for MCP Apps SDK
430    /// wiring (`@modelcontextprotocol/ext-apps` import or >=3 of 4 handler
431    /// property assignments, `new App({...})` constructor, four required
432    /// handlers — `onteardown`, `ontoolinput`, `ontoolcancelled`, `onerror` —
433    /// and `app.connect()`). Missing signals emit one `TestStatus::Failed`
434    /// row per signal (full breakdown).
435    ///
436    /// Per-mode widget validation emission shape (THREE-WAY split per
437    /// RESEARCH Q4 RESOLVED):
438    ///   * `ClaudeDesktop` — per-signal Failed rows (this variant)
439    ///   * `Standard` — ONE summary Warning row per widget
440    ///   * `ChatGpt` — ZERO widget-related rows (preserves AC-78-4
441    ///     "chatgpt mode unchanged")
442    ///
443    /// See `validate_widgets`.
444    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
471/// Validates MCP App metadata on tools discovered via `tools/list`.
472pub struct AppValidator {
473    mode: AppValidationMode,
474    tool_filter: Option<String>,
475}
476
477impl AppValidator {
478    /// Create a new `AppValidator`.
479    pub fn new(mode: AppValidationMode, tool_filter: Option<String>) -> Self {
480        Self { mode, tool_filter }
481    }
482
483    /// Main entry point: validate all (or filtered) App-capable tools.
484    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    /// Returns `true` if the tool has App metadata (nested or flat `resourceUri`).
529    pub fn is_app_capable(tool: &ToolInfo) -> bool {
530        Self::extract_resource_uri(tool).is_some()
531    }
532
533    /// Extract the resource URI from either nested `ui.resourceUri` or flat `ui/resourceUri`.
534    ///
535    /// Public so cargo-pmcp's `read_widget_bodies` plumbing in
536    /// `commands/test/apps.rs` can derive widget URIs from tool metadata.
537    pub fn extract_resource_uri(tool: &ToolInfo) -> Option<String> {
538        let meta = tool._meta.as_ref()?;
539
540        // Nested: _meta.ui.resourceUri
541        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        // Flat legacy: _meta["ui/resourceUri"]
548        if let Some(Value::String(uri)) = meta.get("ui/resourceUri") {
549            return Some(uri.clone());
550        }
551
552        None
553    }
554
555    /// Validate the tool's `_meta` structure for App keys.
556    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                // Validate URI format (non-empty with scheme separator)
584                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    /// Cross-reference a tool's resource URI against the resources list.
624    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                // Validate MIME type
658                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    /// Validate ChatGPT-specific `openai/*` keys in tool metadata.
703    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        // Also check flat legacy key ui/resourceUri
732        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    /// Validate the `outputSchema` structure on a tool.
755    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    // =====================================================================
781    // Plan 78-01 Task 2 — widget HTML validation (mode-driven emission)
782    // =====================================================================
783
784    /// Validate inline widget HTML for Claude Desktop / MCP Apps SDK wiring.
785    ///
786    /// Pure function: takes already-fetched widget bodies and returns
787    /// `TestResult`s.
788    ///
789    /// Each tuple is `(tool_name, uri, html)`. The tool name is included in
790    /// emitted `TestResult.name` strings so error reports identify which tool
791    /// the widget belongs to (REVISION HIGH-4). Plan 02 applies `tool_filter`
792    /// at the read site, so the bodies passed here are already filtered.
793    ///
794    /// Mode-driven emission shape (per RESEARCH Open Question 4 RESOLVED —
795    /// THREE-WAY split):
796    /// - `ClaudeDesktop` — emits ONE `TestStatus::Failed` row per missing
797    ///   signal/handler (full breakdown so each error is independently
798    ///   actionable). Pre-deploy gate.
799    /// - `Standard` — emits ONE summary `TestStatus::Warning` row per widget
800    ///   that lists which signals/handlers are missing in the `details`
801    ///   field. The "permissive default" intent.
802    /// - `ChatGpt` — returns an EMPTY `Vec<TestResult>`. Preserves AC-78-4
803    ///   "chatgpt mode unchanged" (REVISION HIGH-1). The widget-validation
804    ///   surface did not exist before this phase, so the only correct
805    ///   preservation is no new rows.
806    #[allow(dead_code)]
807    pub fn validate_widgets(&self, widget_bodies: &[(String, String, String)]) -> Vec<TestResult> {
808        // REVISION HIGH-1: ChatGpt is a no-op for widget validation. Bail
809        // before scanning so the function does no work in that mode.
810        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                // Unreachable: bailed above. Kept for exhaustive match.
828                AppValidationMode::ChatGpt => {},
829            }
830        }
831        results
832    }
833
834    /// ClaudeDesktop mode: one Failed row per missing signal/handler.
835    /// `ontoolresult` stays Warning (soft) regardless of mode per RESEARCH
836    /// Locked Decision 3.
837    #[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        // SDK presence: G1 four-signal OR computed in scan_widget.
846        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        // App constructor: G2 mangled-id-tolerant regex.
854        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        // Required handlers (each is its own row so error messages name them)
862        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        // ontoolresult is soft (Warning even in ClaudeDesktop)
873        out.push(self.widget_ontoolresult_result(tool_name, uri, s));
874        // connect()
875        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        // ChatGPT-only channels: ERROR in ClaudeDesktop only
883        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    /// Standard mode: ONE summary WARN row per widget listing the missing
890    /// signals in the `details` field. Returns None if the widget is fully
891    /// wired (zero missing signals → no summary needed).
892    /// Per RESEARCH Open Question 4 RESOLVED.
893    #[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    /// Build a Failed (or Passed if `present`) row for one strict-mode signal.
937    /// Used only in ClaudeDesktop mode. `name` includes both tool and uri.
938    #[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        // ontoolresult is always soft (Warning), regardless of mode, per
973        // RESEARCH Locked Decision 3 (some widgets render from
974        // getHostContext().toolOutput).
975        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        // ChatGPT-only channels with no ext-apps wiring: ERROR in ClaudeDesktop.
996        // Only called from emit_results_for_claude_desktop; never from standard.
997        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        // Simulate strict mode as callers do via report.apply_strict_mode()
1116        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        // "other" has no _meta, so validation should report failure for it
1141        assert!(results.iter().any(|r| r.name.contains("other")));
1142        assert!(!results.iter().any(|r| r.name.contains("chess")));
1143    }
1144
1145    // ==========================================================================
1146    // Task 1 (Plan 78-01) — WidgetSignals scanner + comment-stripper tests
1147    // ==========================================================================
1148
1149    /// Wrap a list of script body snippets in <script>...</script> blocks and
1150    /// concatenate into a minimal HTML document. Used by widget-scanner tests.
1151    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        // Simply touching every accessor proves they compile and panic-free.
1165        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        // A <script src="..."></script> tag's body is empty; the filter must
1207        // drop the tag entirely so its (empty) body never enters output.
1208        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        // Minified form where the App binding is renamed to `n`.
1220        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        // REVISION HIGH-3 LOAD-BEARING TEST. All signal literals appear ONLY
1294        // inside comments. The scanner must NOT treat them as present.
1295        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    // ==========================================================================
1319    // Plan 78-06 Task 1 — G1 (4-signal SDK presence) + G2 (mangled-id ctor) unit tests
1320    // ==========================================================================
1321
1322    #[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    // ==========================================================================
1422    // Plan 78-06 Task 2 — G3 cascade-elimination unit tests
1423    // ==========================================================================
1424
1425    #[test]
1426    fn scan_widget_g3_handlers_detected_independently_of_has_sdk() {
1427        // The synthetic cascade fixture shape: handlers + connect present,
1428        // ALL SDK signals absent.
1429        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        // chatgpt-only channels + no SDK + no handlers → chatgpt-only-failed fires.
1461        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        // chatgpt-only channels + has_sdk → chatgpt-only-failed must NOT fire
1467        // (this is what the compound predicate in emit_results_for_claude_desktop guards).
1468        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    // ==========================================================================
1475    // Task 2 (Plan 78-01) — validate_widgets + per-mode emission tests
1476    // ==========================================================================
1477
1478    /// A widget HTML body fully wired for MCP Apps SDK (used as the
1479    /// "corrected" baseline for several tests).
1480    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        // REVISION HIGH-4: every Failed row's name must contain the tool name.
1517        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        // G3 cascade-elimination contract (Plan 78-06): the v1 `>=3 of 4
1616        // handler-count` fallback is REMOVED. A widget with handlers but no
1617        // genuine SDK presence signal must report SDK Failed independently
1618        // of the handler rows. This test was previously
1619        // `sdk_signal_accepts_handler_count_fallback` and asserted the
1620        // opposite (the bug Plan 06 fixes).
1621        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        // Handlers themselves must still pass independently.
1640        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        // Widget uses window.openai with no ext-apps wiring at all.
1654        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        // REVISION HIGH-1 LOAD-BEARING TEST. Widget missing EVERY signal (no
1679        // SDK, no handlers, no new App, no connect, with chatgpt-only
1680        // channels). Under ChatGpt mode the validator MUST return zero
1681        // results — preserving AC-78-4 "chatgpt mode unchanged".
1682        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        // Regression test for Pitfall 5 — under ClaudeDesktop mode, NO
1707        // Warning rows are emitted for the four required handlers. Only
1708        // ontoolresult MAY remain Warning (RESEARCH Locked Decision 3).
1709        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        // The ONLY Warning allowed is ontoolresult.
1722        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        // Re-asserts ChatGpt is silent regardless of widget shape.
1760        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    // ===== Cycle 2 unit tests (Plan 78-10) =====
1778    //
1779    // Cycle-2 fixes the comment-stripper bug + widens G2 regex. Source:
1780    // tests/fixtures/widgets/bundled/real-prod/CAPTURE.md "Root cause
1781    // discovered" section.
1782
1783    #[test]
1784    fn strip_js_comments_preserves_block_comment_inside_double_quoted_string() {
1785        // The cost-coach prod bug: a CSP frame-src directive value contains
1786        // `/*.example.com`. The cycle-1 stripper saw that `/*` as a
1787        // block-comment opener and matched the next `*/` (a license-header
1788        // banner thousands of bytes later), destroying the SDK section
1789        // between.
1790        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        // \" inside a "..." string must not exit the string state.
1850        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        // Real cost-coach prod constructor shape — name uses string
1870        // concatenation: `name:"cost-coach-"+t,version:"1.0.0"`. The
1871        // cycle-1 regex required a literal value; cycle-2 widening
1872        // accepts any expression up to 100 chars.
1873        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        // Some bundles emit 3-4-or-longer mangled identifiers. Cycle-1
1884        // capped at {0,5}; cycle-2 widens to {0,20}.
1885        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        // Negative: `new Foo({key1, key2})` must NOT match — needs both
1893        // `name` AND `version`.
1894        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        // Verbatim shape from cost-coach prod (cost-summary.html, function Rw).
1905        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        // Regression for the cost-coach prod root cause: a `/*` inside a
1916        // string literal must not strip away the SDK section that
1917        // follows. With the cycle-1 stripper, the `/*.example.com` CSP
1918        // string opened a phantom block comment that matched the next
1919        // `*/` (a real license-header banner), destroying the entire
1920        // SDK runtime between. Cycle-2's string-literal-aware stripper
1921        // preserves the SDK section.
1922        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}