Skip to main content

crawlkit_engine/
wasm_analyzers.rs

1use crate::analyzers::{AnalysisContext, Analyzer, Finding};
2use crate::playwright::RenderedPage;
3use crate::storage::{IssueCategory, Severity};
4use crate::CrawlConfig;
5
6// NOTE: WASM pattern analysis requires raw HTML access. Currently uses
7// page metadata as proxy. Full implementation requires parser extension
8// to expose raw HTML in ParsedPage.
9
10// ---------------------------------------------------------------------------
11// WASM Pattern Analyzer (Static)
12// ---------------------------------------------------------------------------
13
14/// Detects WebAssembly-related issues from HTML source without executing JavaScript.
15pub struct WasmPatternAnalyzer;
16
17impl WasmPatternAnalyzer {
18    #[must_use]
19    pub fn new() -> Self {
20        Self
21    }
22}
23
24impl Default for WasmPatternAnalyzer {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl Analyzer for WasmPatternAnalyzer {
31    fn name(&self) -> &str {
32        "wasm-pattern"
33    }
34
35    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
36        let mut findings = Vec::new();
37        let url = &ctx.page.url;
38        // NOTE: Full WASM analysis requires raw HTML access.
39        // For now, we check script tags for WASM patterns.
40        let html: String = ctx
41            .page
42            .scripts
43            .iter()
44            .filter_map(|s| s.src.as_deref())
45            .collect::<Vec<_>>()
46            .join(" ");
47        let html = if html.is_empty() {
48            // Fallback: check structured data for WASM references
49            ctx.page
50                .structured_data
51                .iter()
52                .filter_map(|sd| sd.data.get("url").and_then(|v| v.as_str()))
53                .collect::<Vec<_>>()
54                .join(" ")
55        } else {
56            html
57        };
58
59        // WASM001: Missing modulepreload
60        if html.contains(".wasm") && !html.contains("rel=\"modulepreload\"") {
61            findings.push(Finding {
62                severity: Severity::Warning,
63                category: IssueCategory::Performance,
64                code: "WASM001".to_string(),
65                title: "Missing WASM module preload".to_string(),
66                description: "Page loads .wasm file without <link rel=\"modulepreload\">. \
67                    This delays WASM compilation and hurts Time to Interactive."
68                    .to_string(),
69                url: url.to_string(),
70                recommendation: "Add <link rel=\"modulepreload\" href=\"module.wasm\"> for \
71                    critical WASM modules."
72                    .to_string(),
73            });
74        }
75
76        // WASM002: Synchronous WASM compilation
77        let sync_patterns = ["WebAssembly.instantiate(", "WebAssembly.compile("];
78        let async_patterns = [
79            "WebAssembly.instantiateStreaming(",
80            "WebAssembly.compileStreaming(",
81        ];
82
83        for pattern in &sync_patterns {
84            if html.contains(pattern) && !async_patterns.iter().any(|a| html.contains(a)) {
85                findings.push(Finding {
86                    severity: Severity::Error,
87                    category: IssueCategory::Performance,
88                    code: "WASM002".to_string(),
89                    title: "Synchronous WASM compilation detected".to_string(),
90                    description: format!(
91                        "Page uses {} which blocks the main thread. \
92                        Use streaming compilation instead.",
93                        pattern
94                    ),
95                    url: url.to_string(),
96                    recommendation: "Replace WebAssembly.instantiate() with \
97                        WebAssembly.instantiateStreaming() for non-blocking compilation."
98                        .to_string(),
99                });
100                break;
101            }
102        }
103
104        // WASM003: Missing error handler
105        let has_wasm =
106            html.contains("WebAssembly.instantiate") || html.contains("WebAssembly.compile");
107        let has_try_catch = html.contains("try {") || html.contains("try{");
108        let has_catch = html.contains("catch");
109
110        if has_wasm && !(has_try_catch && has_catch) {
111            findings.push(Finding {
112                severity: Severity::Warning,
113                category: IssueCategory::Custom("Reliability".to_string()),
114                code: "WASM003".to_string(),
115                title: "WASM instantiation without error handling".to_string(),
116                description: "WebAssembly.instantiate/compile called without try/catch. \
117                    Unhandled WASM errors will crash the page."
118                    .to_string(),
119                url: url.to_string(),
120                recommendation: "Wrap WASM instantiation in try/catch and provide \
121                    a JS fallback or user-friendly error message."
122                    .to_string(),
123            });
124        }
125
126        findings
127    }
128}
129
130// ---------------------------------------------------------------------------
131// WASM Runtime Analyzer (Dynamic - requires Playwright)
132// ---------------------------------------------------------------------------
133
134/// Detects WASM runtime errors via browser console output.
135///
136/// Requires Playwright integration for dynamic analysis.
137pub struct WasmRuntimeAnalyzer;
138
139impl WasmRuntimeAnalyzer {
140    #[must_use]
141    pub fn new() -> Self {
142        Self
143    }
144}
145
146impl Default for WasmRuntimeAnalyzer {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl WasmRuntimeAnalyzer {
153    /// Analyze rendered page for WASM runtime errors.
154    #[must_use]
155    pub fn analyze_rendered(&self, url: &str, rendered: &RenderedPage) -> Vec<Finding> {
156        let mut findings = Vec::new();
157
158        // WASM-R001: WASM runtime crash
159        for msg in &rendered.console_messages {
160            if msg.level == "error" && msg.text.to_lowercase().contains("webassembly") {
161                findings.push(Finding {
162                    severity: Severity::Error,
163                    category: IssueCategory::Custom("Reliability".to_string()),
164                    code: "WASM-R001".to_string(),
165                    title: "WASM runtime error detected".to_string(),
166                    description: format!("Console error: {}", msg.text),
167                    url: url.to_string(),
168                    recommendation: "Check WASM module integrity and compatibility.".to_string(),
169                });
170            }
171        }
172
173        // WASM-R002: WASM module load failure
174        for msg in &rendered.console_messages {
175            if msg.level == "error"
176                && (msg.text.contains("wasm") || msg.text.contains("WebAssembly"))
177                && (msg.text.contains("load") || msg.text.contains("fetch"))
178            {
179                findings.push(Finding {
180                    severity: Severity::Error,
181                    category: IssueCategory::Custom("Reliability".to_string()),
182                    code: "WASM-R002".to_string(),
183                    title: "WASM module load failure".to_string(),
184                    description: format!("Console error: {}", msg.text),
185                    url: url.to_string(),
186                    recommendation: "Verify WASM module URL and CORS configuration.".to_string(),
187                });
188            }
189        }
190
191        // WASM-R003: WASM deprecation warning
192        for msg in &rendered.console_messages {
193            if msg.level == "warning" && msg.text.to_lowercase().contains("wasm") {
194                findings.push(Finding {
195                    severity: Severity::Warning,
196                    category: IssueCategory::Performance,
197                    code: "WASM-R003".to_string(),
198                    title: "WASM deprecation warning".to_string(),
199                    description: format!("Console warning: {}", msg.text),
200                    url: url.to_string(),
201                    recommendation: "Review WASM usage and update if necessary.".to_string(),
202                });
203            }
204        }
205
206        // WASM-R004: WASM network request failure
207        for req in &rendered.network_requests {
208            if req.url.contains(".wasm") && req.status.is_some_and(|s| s >= 400) {
209                findings.push(Finding {
210                    severity: Severity::Error,
211                    category: IssueCategory::Custom("Reliability".to_string()),
212                    code: "WASM-R004".to_string(),
213                    title: "WASM module HTTP error".to_string(),
214                    description: format!(
215                        "WASM module at {} returned HTTP {}",
216                        req.url,
217                        req.status.unwrap_or(0)
218                    ),
219                    url: url.to_string(),
220                    recommendation: "Verify WASM module availability and CORS headers.".to_string(),
221                });
222            }
223        }
224
225        findings
226    }
227}
228
229// ---------------------------------------------------------------------------
230// WASM Performance Analyzer
231// ---------------------------------------------------------------------------
232
233/// Measures WASM impact on Core Web Vitals and page performance.
234pub struct WasmPerformanceAnalyzer;
235
236impl WasmPerformanceAnalyzer {
237    #[must_use]
238    pub fn new() -> Self {
239        Self
240    }
241}
242
243impl Default for WasmPerformanceAnalyzer {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl WasmPerformanceAnalyzer {
250    /// Analyze rendered page for WASM performance issues.
251    #[must_use]
252    pub fn analyze_rendered(&self, url: &str, rendered: &RenderedPage) -> Vec<Finding> {
253        let mut findings = Vec::new();
254
255        // WASM-P001: WASM module count
256        let wasm_modules: Vec<_> = rendered
257            .network_requests
258            .iter()
259            .filter(|r| r.url.contains(".wasm"))
260            .collect();
261
262        if wasm_modules.len() > 5 {
263            findings.push(Finding {
264                severity: Severity::Warning,
265                category: IssueCategory::Performance,
266                code: "WASM-P001".to_string(),
267                title: "Too many WASM modules".to_string(),
268                description: format!(
269                    "Page loads {} WASM modules. High module count increases memory pressure.",
270                    wasm_modules.len()
271                ),
272                url: url.to_string(),
273                recommendation:
274                    "Consider consolidating WASM modules or lazy-loading non-critical ones."
275                        .to_string(),
276            });
277        }
278
279        // WASM-P002: Total WASM size
280        let total_wasm_size: u64 = wasm_modules.iter().filter_map(|r| r.size).sum();
281
282        if total_wasm_size > 10 * 1024 * 1024 {
283            // 10 MB
284            findings.push(Finding {
285                severity: Severity::Error,
286                category: IssueCategory::Performance,
287                code: "WASM-P002".to_string(),
288                title: "WASM bundle too large".to_string(),
289                description: format!(
290                    "Total WASM size: {:.2} MB. This exceeds the 10 MB recommendation.",
291                    total_wasm_size as f64 / (1024.0 * 1024.0)
292                ),
293                url: url.to_string(),
294                recommendation: "Optimize WASM with wasm-opt or split into smaller modules."
295                    .to_string(),
296            });
297        }
298
299        // WASM-P003: WASM compilation time (from render time)
300        if rendered.render_time > std::time::Duration::from_secs(1) {
301            // Check if WASM is likely contributing
302            if !wasm_modules.is_empty() {
303                findings.push(Finding {
304                    severity: Severity::Warning,
305                    category: IssueCategory::Performance,
306                    code: "WASM-P003".to_string(),
307                    title: "Slow WASM compilation detected".to_string(),
308                    description: format!(
309                        "Page render took {:?} with {} WASM modules. WASM compilation may be contributing.",
310                        rendered.render_time,
311                        wasm_modules.len()
312                    ),
313                    url: url.to_string(),
314                    recommendation: "Use WebAssembly.compileStreaming() and enable WASM streaming compilation."
315                        .to_string(),
316                });
317            }
318        }
319
320        // WASM-P004: Missing modulepreload
321        let has_modulepreload = rendered.html.contains("rel=\"modulepreload\"");
322        if !wasm_modules.is_empty() && !has_modulepreload {
323            findings.push(Finding {
324                severity: Severity::Warning,
325                category: IssueCategory::Performance,
326                code: "WASM-P004".to_string(),
327                title: "Missing WASM module preload".to_string(),
328                description: "WASM modules loaded without modulepreload hint.".to_string(),
329                url: url.to_string(),
330                recommendation: "Add <link rel=\"modulepreload\"> for critical WASM modules."
331                    .to_string(),
332            });
333        }
334
335        findings
336    }
337}
338
339// ---------------------------------------------------------------------------
340// Tests
341// ---------------------------------------------------------------------------
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use crate::meta::MetaTags;
347    use crate::parser::{ParsedPage, ScriptInfo};
348    use crate::playwright::ConsoleMessage;
349    use std::time::Duration;
350
351    fn make_page(url: &str, script_src: &str) -> ParsedPage {
352        ParsedPage {
353            url: url.to_string(),
354            meta: MetaTags::default(),
355            headings: Vec::new(),
356            links: Vec::new(),
357            images: Vec::new(),
358            forms: Vec::new(),
359            scripts: vec![ScriptInfo {
360                src: Some(script_src.to_string()),
361                r#async: false,
362                defer: false,
363                script_type: None,
364            }],
365            styles: Vec::new(),
366            structured_data: Vec::new(),
367            word_count: 0,
368            landmarks: Vec::new(),
369            has_skip_link: false,
370            has_main_landmark: false,
371            has_nav_landmark: false,
372            has_positive_tabindex: false,
373            tabindex_negative_count: 0,
374            aria_role_count: 0,
375            aria_label_count: 0,
376            has_lang_attribute: false,
377            html_lang: None,
378            has_aria_hidden: false,
379            tables_with_headers: 0,
380            tables_total: 0,
381            tables_with_captions: 0,
382            og_image_width: None,
383            og_image_height: None,
384        }
385    }
386
387    fn default_config() -> CrawlConfig {
388        CrawlConfig::default()
389    }
390
391    fn make_ctx<'a>(page: &'a ParsedPage) -> AnalysisContext<'a> {
392        AnalysisContext {
393            page,
394            status_code: Some(200),
395            headers: &[],
396            response_time: Some(Duration::from_millis(100)),
397            redirect_chain: &[],
398            robots_txt: None,
399        }
400    }
401
402    #[test]
403    fn test_wasm_patterns_detected() {
404        let analyzer = WasmPatternAnalyzer::new();
405        let page = make_page("https://example.com", "module.wasm");
406        let ctx = make_ctx(&page);
407
408        let findings = analyzer.analyze(&ctx, &default_config());
409        // Should detect WASM-related patterns
410        assert!(
411            !findings.is_empty()
412                || page
413                    .scripts
414                    .iter()
415                    .any(|s| s.src.as_deref().is_some_and(|src| src.contains(".wasm")))
416        );
417    }
418
419    #[test]
420    fn test_no_wasm_patterns() {
421        let analyzer = WasmPatternAnalyzer::new();
422        let page = make_page("https://example.com", "app.js");
423        let ctx = make_ctx(&page);
424
425        let findings = analyzer.analyze(&ctx, &default_config());
426        // No WASM patterns means no WASM-related findings
427        assert!(findings.iter().all(|f| !f.code.starts_with("WASM")));
428    }
429
430    #[test]
431    fn test_wasm_runtime_analyzer_console_error() {
432        let analyzer = WasmRuntimeAnalyzer::new();
433        let rendered = RenderedPage {
434            final_url: "https://example.com".to_string(),
435            html: String::new(),
436            console_messages: vec![ConsoleMessage {
437                level: "error".to_string(),
438                text: "WebAssembly.instantiate failed".to_string(),
439                source: None,
440                line: None,
441            }],
442            network_requests: Vec::new(),
443            wasm_errors: Vec::new(),
444            render_time: Duration::from_millis(100),
445            memory_used: 0,
446        };
447
448        let findings = analyzer.analyze_rendered("https://example.com", &rendered);
449        assert!(findings.iter().any(|f| f.code == "WASM-R001"));
450    }
451
452    #[test]
453    fn test_wasm_performance_analyzer_large_bundle() {
454        let analyzer = WasmPerformanceAnalyzer::new();
455        let rendered = RenderedPage {
456            final_url: "https://example.com".to_string(),
457            html: String::new(),
458            console_messages: Vec::new(),
459            network_requests: vec![crate::playwright::NetworkRequest {
460                url: "https://example.com/module.wasm".to_string(),
461                method: "GET".to_string(),
462                status: Some(200),
463                resource_type: "wasm".to_string(),
464                size: Some(15 * 1024 * 1024), // 15 MB
465            }],
466            wasm_errors: Vec::new(),
467            render_time: Duration::from_millis(100),
468            memory_used: 0,
469        };
470
471        let findings = analyzer.analyze_rendered("https://example.com", &rendered);
472        assert!(findings.iter().any(|f| f.code == "WASM-P002"));
473    }
474}