diff --git a/src/js/main.js b/src/js/main.js index 7ef8d245..cebd1212 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -18,6 +18,16 @@ import { updateTabList } from "./utils/updateTabList.js"; import * as THREE from "three"; import NotificationManager from "./utils/notifications.js"; +import("./msp/msp_debug_tools.js") + .then(() => { + console.log("πŸ”§ MSP Debug Tools loaded for development environment"); + console.log("β€’ Press Ctrl+Shift+M to toggle debug dashboard"); + console.log("β€’ Use MSPTestRunner.help() for all commands"); + }) + .catch((err) => { + console.warn("Failed to load MSP debug tools:", err); + }); + if (typeof String.prototype.replaceAll === "undefined") { String.prototype.replaceAll = function (match, replace) { return this.replace(new RegExp(match, "g"), () => replace); @@ -118,7 +128,8 @@ function startProcess() { console.log(`Libraries: jQuery - ${$.fn.jquery}, three.js - ${THREE.REVISION}`); // Check if this is the first visit - if (getConfig("firstRun").firstRun === undefined) { + const firstRunCfg = getConfig("firstRun") ?? {}; + if (firstRunCfg.firstRun === undefined) { setConfig({ firstRun: true }); import("./tabs/static_tab.js").then(({ staticTab }) => { staticTab.initialize("options", () => { diff --git a/src/js/msp.js b/src/js/msp.js index 9a980a91..5886ce85 100644 --- a/src/js/msp.js +++ b/src/js/msp.js @@ -51,16 +51,13 @@ const MSP = { message_buffer: null, message_buffer_uint8_view: null, message_checksum: 0, - messageIsJumboFrame: false, crcError: false, callbacks: [], packet_error: 0, unsupported: 0, - MIN_TIMEOUT: 200, - MAX_TIMEOUT: 2000, - timeout: 200, + TIMEOUT: 1000, last_received_timestamp: null, listeners: [], @@ -374,28 +371,19 @@ const MSP = { serial.send(bufferOut); }, send_message(code, data, callback_sent, callback_msp, doCallbackOnError) { - const connected = serial.connected; - - if (code === undefined || !connected || CONFIGURATOR.virtualMode) { + if (code === undefined || !serial.connected || CONFIGURATOR.virtualMode) { if (callback_msp) { callback_msp(); } return false; } - let requestExists = false; - for (const instance of this.callbacks) { - if (instance.code === code) { - requestExists = true; - - break; - } - } + const requestExists = this.callbacks.some((instance) => instance.code === code); const bufferOut = code <= 254 ? this.encode_message_v1(code, data) : this.encode_message_v2(code, data); const obj = { - code: code, + code, requestBuffer: bufferOut, callback: callback_msp, callbackOnError: doCallbackOnError, @@ -412,31 +400,31 @@ const MSP = { serial.send(bufferOut, (_sendInfo) => { obj.stop = performance.now(); const executionTime = Math.round(obj.stop - obj.start); - this.timeout = Math.max(this.MIN_TIMEOUT, Math.min(executionTime, this.MAX_TIMEOUT)); + // We should probably give up connection if the request takes too long ? + if (executionTime > 5000) { + console.warn( + `MSP: data request took too long: ${code} ID: ${serial.connectionId} TAB: ${GUI.active_tab} EXECUTION TIME: ${executionTime}ms`, + ); + } + + clearTimeout(obj.timer); // prevent leaks }); - }, this.timeout); + }, this.TIMEOUT); } this.callbacks.push(obj); // always send messages with data payload (even when there is a message already in the queue) if (data || !requestExists) { - if (this.timeout > this.MIN_TIMEOUT) { - this.timeout--; - } - serial.send(bufferOut, (sendInfo) => { - if (sendInfo.bytesSent === bufferOut.byteLength) { - if (callback_sent) { - callback_sent(); - } + if (sendInfo.bytesSent === bufferOut.byteLength && callback_sent) { + callback_sent(); } }); } return true; }, - /** * resolves: {command: code, data: data, length: message_length} */ diff --git a/src/js/msp/MSP_DEBUG_README.md b/src/js/msp/MSP_DEBUG_README.md new file mode 100644 index 00000000..687a23b4 --- /dev/null +++ b/src/js/msp/MSP_DEBUG_README.md @@ -0,0 +1,359 @@ +# MSP Debug Tools + +Comprehensive monitoring and stress testing tools for the MSP (MultiWii Serial Protocol) implementation in Betaflight Configurator. + +## Features + +πŸ” **Real-time Queue Monitoring** +- Track queue size, response times, and success rates +- Detect memory leaks and performance bottlenecks +- Alert system for potential issues + +πŸ§ͺ **Comprehensive Stress Testing** +- Queue flooding tests +- Timeout recovery validation +- Memory leak detection +- Performance under load testing + +πŸ“Š **Visual Dashboard** +- Real-time metrics display with smart updates +- Live charts and graphs +- Queue analysis tools +- Test result visualization +- **Interactive-friendly updates**: Dashboard pauses updates during user interactions +- **Clickable test results**: Click on any test result for detailed information +- Visual pause indicators when updates are suspended + +⚑ **Easy-to-use API** +- Console commands for quick testing +- Programmable test scenarios +- Detailed reporting and export + +## Quick Start + +### 1. Load the Debug Tools + +Include the debug tools in your page: + +```javascript +import './src/js/msp_debug_tools.js'; +``` + +Or in development, load via console: +```javascript +import('./src/js/msp_debug_tools.js'); +``` + +### 2. Basic Usage + +**Start monitoring:** +```javascript +MSPDebug.startMonitoring(); +``` + +**Show visual dashboard:** +```javascript +MSPDebug.show(); +// Or press Ctrl+Shift+M +``` + +**Quick test of alert system:** +```javascript +MSPDebug.testAlerts(); +``` + +**Run stress tests:** +```javascript +// Run specific test +MSPDebug.runTests(); + +// Run complete stress test suite with detailed console output +MSPDebug.runFullSuite(); + +// Run individual test by name +MSPDebug.runTest('queue-flooding'); + +// Quick health check +MSPDebug.quickHealthCheck(); + +// Run stress scenario +MSPDebug.stressScenario('high-frequency'); +``` + +## Available Commands + +The MSP Debug Tools provide two APIs: +- **MSPDebug**: Modern, simplified API (recommended) +- **MSPTestRunner**: Legacy API with additional methods + +Both APIs provide the same core functionality. Use `MSPDebug` for new code. + +### Monitoring Commands + +| Command | Description | +|---------|-------------| +| `MSPDebug.startMonitoring()` | Start monitoring with console output | +| `MSPDebug.stopMonitoring()` | Stop monitoring | +| `MSPDebug.getStatus()` | Get current MSP status | +| `MSPDebug.monitor.getStatus()` | Get current MSP status (alternative) | +| `MSPDebug.analyze()` | Analyze current queue contents | + +### Testing Commands + +| Command | Description | +|---------|-------------| +| `MSPDebug.runTests()` | Run stress test suite | +| `MSPDebug.runFullSuite()` | Run complete stress test suite with detailed output | +| `MSPDebug.runTest('test-name')` | Run a specific test by name | +| `MSPDebug.quickHealthCheck()` | Run a quick MSP health check | +| `MSPDebug.stressScenario('scenario')` | Run specific stress test scenario | +| `MSPDebug.testAlerts()` | Test alert system | +| `MSPDebug.triggerTestAlerts()` | Manually trigger alerts | + +### Alert Testing + +| Command | Description | +|---------|-------------| +| `MSPDebug.setTestThresholds()` | Lower thresholds for easier testing | +| `MSPDebug.setNormalThresholds()` | Restore normal thresholds | +| `MSPDebug.testAlerts()` | Complete alert system test | + +### Visual Tools + +| Command | Description | +|---------|-------------| +| `MSPDebug.show()` | Show visual debug dashboard | +| `MSPDebug.report()` | Generate and download report | + +## Dashboard Interactions + +The visual dashboard includes smart interaction handling to ensure a smooth user experience: + +### Automatic Update Pausing + +- **Mouse hover**: Updates pause for 3 seconds when hovering over interactive elements +- **Click events**: Updates pause for 5 seconds when clicking buttons or test results +- **Focus events**: Updates pause for 10 seconds when focusing on input elements +- **Visual indicator**: Orange "Updates Paused" indicator appears when updates are suspended + +### Clickable Test Results + +- Click on any test result item to see detailed information including: + - Full error messages + - Performance metrics + - JSON response data + - Test duration and status +- Details remain stable and clickable while displayed +- Use the "Close Details" button to dismiss and resume normal updates + +### Interactive Elements + +- All buttons remain stable during interactions +- Queue analysis results are preserved during examination +- Export functionality works without interference from updates + +### Keyboard Shortcuts + +- **Ctrl+Shift+M**: Toggle dashboard visibility +- Use console commands for programmatic control + +## Available Tests + +### Individual Test Names (for `runTest`) + +1. **queue-flooding** - Tests queue limits with many simultaneous requests +2. **rapid-fire** - Tests high-frequency request handling +3. **duplicates** - Validates duplicate request management +4. **timeout-recovery** - Tests timeout and retry mechanisms +5. **memory-leaks** - Checks for proper cleanup of completed requests +6. **concurrent-mixed** - Tests various request types simultaneously +7. **queue-overflow** - Tests behavior when queue reaches capacity +8. **connection-disruption** - Simulates connection issues +9. **performance-load** - Tests sustained load performance + +### Stress Scenarios (for `stressScenario`) + +- **high-frequency** - High-frequency requests every 10ms for 5 seconds +- **queue-overflow** - Floods queue beyond capacity +- **mixed-load** - Various request types and sizes + +### Full Test Suite + +The `runFullSuite()` command runs all individual tests in sequence with detailed console output and generates a comprehensive report. + +## Monitoring Metrics + +The tools track various metrics: + +- **Queue Size**: Current number of pending requests +- **Response Times**: Average, minimum, and maximum response times +- **Success Rate**: Percentage of successful requests +- **Timeout Rate**: Percentage of requests that timeout +- **Request Distribution**: Breakdown by MSP command codes +- **Error Tracking**: Categorized error types and frequencies + +## Alert System + +The monitoring system provides alerts for: + +- 🚨 **Queue Full**: Queue approaching capacity +- ⏱️ **High Timeout Rate**: Excessive request timeouts +- 🐌 **Slow Responses**: Average response time too high +- πŸ’Ύ **Memory Leak**: Callbacks not being cleaned up properly + +## Dashboard Features + +The visual dashboard provides: + +- **Real-time Status**: Current queue state and metrics +- **Live Charts**: Queue size and response time trends +- **Queue Analysis**: Detailed breakdown of pending requests +- **Alert Display**: Active alerts and warnings +- **Test Integration**: Run tests directly from the UI +- **Export Tools**: Generate and download reports + +## Keyboard Shortcuts + +- `Ctrl+Shift+M` - Toggle debug dashboard +- Dashboard is draggable and resizable + +## Example Usage Scenarios + +### Development Testing +```javascript +// Start monitoring during development +MSPDebug.startMonitoring(); + +// Quick health check +MSPDebug.quickHealthCheck(); + +// Test the alert system +MSPDebug.testAlerts(); + +// Show visual dashboard +MSPDebug.show(); +``` + +### Performance Analysis +```javascript +// Show dashboard for visual monitoring +MSPDebug.show(); + +// Run complete stress tests with detailed output +MSPDebug.runFullSuite(); + +// Test specific scenarios +MSPDebug.stressScenario('high-frequency'); + +// Generate detailed report +MSPDebug.report(); +``` + +### Issue Debugging +```javascript +// Get current status +MSPDebug.getStatus(); + +// Analyze current queue state +MSPDebug.analyze(); + +// Test specific problematic scenario +MSPDebug.runTest('memory-leaks'); + +// Check for alerts with low thresholds +MSPDebug.setTestThresholds(); +MSPDebug.triggerTestAlerts(); + +// Generate diagnostic report +MSPDebug.report(); +``` + +## Integration with Existing Code + +The debug tools are designed to be non-intrusive: + +- They hook into existing MSP methods without modifying core functionality +- Monitoring can be enabled/disabled at runtime +- No performance impact when not actively monitoring +- Original MSP behavior is preserved + +### Auto-loading +The debug tools auto-load when `msp_debug_tools.js` is imported. They detect the presence of the global MSP object and initialize automatically. + +### Keyboard Shortcuts +- `Ctrl+Shift+M`: Toggle debug dashboard + +## Implementation Status + +### βœ… Current Features + +#### Alert System +- Enhanced debug logging with reduced console noise +- Test infrastructure: `triggerTestAlerts()`, `setTestThresholds()`, `setNormalThresholds()` +- Visual alert display in dashboard +- Smart threshold management for testing + +#### Interactive Dashboard +- Smart update pausing during user interactions +- Clickable test results with detailed information +- Enhanced interaction handling for all UI elements +- Visual feedback with updates pause indicator + +#### Complete API +- Dual API support: `MSPDebug` (modern) and `MSPTestRunner` (legacy) +- All documented commands implemented and verified +- Comprehensive testing methods (9 test types + 3 stress scenarios) +- Real-time monitoring with alert detection + +### βœ… Verified Working +- Alert system triggers correctly when thresholds exceeded +- Dashboard displays alerts visually without update interference +- Test results provide comprehensive detailed information +- All API commands function as documented +- Auto-loading works in development environment + +## File Structure + +``` +src/js/ +β”œβ”€β”€ msp_queue_monitor.js # Core monitoring functionality +β”œβ”€β”€ msp_stress_test.js # Stress testing framework +β”œβ”€β”€ msp_debug_dashboard.js # Visual dashboard UI +β”œβ”€β”€ msp_test_runner.js # Console command interface +└── msp_debug_tools.js # Integration and auto-loading +``` + +## Requirements + +- Modern browser with ES6 module support +- Access to the global `MSP` object +- Console access for command-line interface + +## Troubleshooting + +**Tools not loading:** +- Ensure MSP object is available globally +- Check browser console for import errors + +**Tests failing unexpectedly:** +- Verify serial connection is active +- Check that flight controller is responding +- Review console for specific error messages + +**Dashboard not appearing:** +- Try `MSPTestRunner.showDashboard()` from console +- Check for CSS conflicts +- Verify no popup blockers are interfering + +## Contributing + +When adding new tests or monitoring features: + +1. Add test methods to `MSPStressTest` class +2. Update monitor metrics in `MSPQueueMonitor` +3. Extend dashboard UI as needed +4. Update this documentation + +## License + +Same as Betaflight Configurator project. diff --git a/src/js/msp/msp_debug_dashboard.js b/src/js/msp/msp_debug_dashboard.js new file mode 100644 index 00000000..e0c90810 --- /dev/null +++ b/src/js/msp/msp_debug_dashboard.js @@ -0,0 +1,1192 @@ +/** + * MSP Debug Dashboard - Visual interface for monitoring MSP queue and running stress tests + */ + +import { mspQueueMonitor } from "./msp_queue_monitor.js"; +import { mspStressTest } from "./msp_stress_test.js"; + +export class MSPDebugDashboard { + constructor() { + this.isVisible = false; + this.updateInterval = null; + this.chartData = { + queueSize: [], + responseTime: [], + timestamps: [], + }; + this.maxDataPoints = 50; + this.updatesPaused = false; + this.pauseTimeout = null; + this.lastUpdateData = {}; + + // Performance optimization properties + this.lastChartUpdate = 0; + this.chartUpdatePending = false; + this.elementCache = new Map(); // Cache DOM elements to avoid repeated queries + + this.createDashboard(); + this.setupEventListeners(); + } + + /** + * Escape HTML characters to prevent XSS + */ + escapeHtml(text) { + if (typeof text !== "string") { + return text; + } + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + /** + * Create the dashboard HTML structure + */ + createDashboard() { + // Create dashboard container + this.container = document.createElement("div"); + this.container.id = "msp-debug-dashboard"; + this.container.innerHTML = ` +
+

πŸ”§ MSP Debug Dashboard

+
+ + + + +
+ +
+ +
+ +
+

πŸ“Š Status Overview

+
+
+ + 0 + / 50 +
+
+ + 100% +
+
+ + 0ms +
+
+ + 0 +
+
+
+ + +
+

🚨 Alerts

+
+ +
+
+
No active alerts
+
+
+ + +
+

πŸ“‹ Queue Analysis

+
+ + +
+
+
+ + +
+

πŸ“ˆ Live Metrics

+ +
+ + +
+

πŸ” Current Queue Contents

+
+
+ + +
+

πŸ§ͺ Test Results

+
+
+
+ `; + + // Add CSS styles + this.addStyles(); + + // Add to document but keep hidden initially + document.body.appendChild(this.container); + this.container.style.display = "none"; + } + + /** + * Add CSS styles for the dashboard + */ + addStyles() { + const style = document.createElement("style"); + style.textContent = ` + #msp-debug-dashboard { + position: fixed; + top: 20px; + right: 20px; + width: 500px; + max-height: 80vh; + background: #1e1e1e; + color: #ffffff; + border: 1px solid #444; + border-radius: 8px; + font-family: 'Courier New', monospace; + font-size: 12px; + z-index: 10000; + overflow-y: auto; + box-shadow: 0 4px 20px rgba(0,0,0,0.5); + } + + .msp-dashboard-header { + background: #2d2d2d; + padding: 10px 15px; + border-bottom: 1px solid #444; + display: flex; + justify-content: space-between; + align-items: center; + } + + .msp-dashboard-header h3 { + margin: 0; + font-size: 14px; + } + + .dashboard-controls { + display: flex; + gap: 5px; + } + + .dashboard-controls button { + padding: 4px 8px; + font-size: 11px; + background: #444; + color: white; + border: 1px solid #666; + border-radius: 3px; + cursor: pointer; + } + + .dashboard-controls button:hover { + background: #555; + } + + .updates-status { + position: absolute; + top: 50%; + right: 160px; + transform: translateY(-50%); + font-size: 11px; + color: #ffaa00; + background: rgba(255, 170, 0, 0.15); + padding: 3px 8px; + border-radius: 3px; + border: 1px solid #ffaa00; + font-weight: bold; + z-index: 10001; + } + + .msp-dashboard-content { + padding: 15px; + } + + .status-section, .alerts-section, .queue-section, .chart-section, .details-section, .test-section { + margin-bottom: 20px; + border: 1px solid #333; + border-radius: 4px; + padding: 10px; + } + + .status-section h4, .alerts-section h4, .queue-section h4, .chart-section h4, .details-section h4, .test-section h4 { + margin: 0 0 10px 0; + font-size: 13px; + color: #ffd700; + } + + .status-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + .status-item { + background: #2a2a2a; + padding: 8px; + border-radius: 3px; + } + + .status-item label { + display: block; + font-size: 11px; + color: #ccc; + margin-bottom: 3px; + } + + .status-item .value { + font-weight: bold; + color: #00ff00; + } + + .max-value { + color: #888; + font-size: 10px; + } + + .alerts-container { + min-height: 30px; + } + + .alert-item { + background: #ff4444; + color: white; + padding: 5px 10px; + border-radius: 3px; + margin-bottom: 5px; + font-size: 11px; + } + + .alert-item.warning { + background: #ffaa00; + } + + .no-alerts { + color: #888; + font-style: italic; + text-align: center; + padding: 10px; + } + + .queue-controls { + margin-bottom: 10px; + } + + .queue-controls button { + padding: 5px 10px; + margin-right: 5px; + background: #0066cc; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + } + + .queue-controls button:hover { + background: #0088ff; + } + + .queue-analysis { + background: #2a2a2a; + padding: 10px; + border-radius: 3px; + font-size: 11px; + max-height: 200px; + overflow-y: auto; + pointer-events: auto; /* Ensure clickability */ + } + + .queue-contents { + background: #2a2a2a; + padding: 10px; + border-radius: 3px; + max-height: 150px; + overflow-y: auto; + pointer-events: auto; /* Ensure clickability */ + } + + .queue-item { + display: flex; + justify-content: space-between; + padding: 3px 0; + border-bottom: 1px solid #333; + font-size: 11px; + } + + .queue-item:last-child { + border-bottom: none; + } + + .queue-item-empty { + opacity: 0.3; + font-style: italic; + } + + .test-results { + background: #2a2a2a; + padding: 10px; + border-radius: 3px; + max-height: 200px; + overflow-y: auto; + font-size: 11px; + pointer-events: auto; /* Ensure clickability */ + } + + .test-result-item { + display: flex; + justify-content: space-between; + padding: 5px 0; + border-bottom: 1px solid #333; + cursor: pointer; + min-height: 20px; /* Prevent height changes during updates */ + } + + .test-result-item:hover { + background: rgba(255, 255, 255, 0.1); + } + + .test-result-item:last-child { + border-bottom: none; + } + + .test-passed { + color: #00ff00; + } + + .test-failed { + color: #ff4444; + } + + #msp-metrics-chart { + width: 100%; + height: 150px; + background: #2a2a2a; + border-radius: 3px; + display: block; + } + + .test-result-item { + padding: 5px 10px; + margin: 2px 0; + border-radius: 3px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + background: #1a1a1a; + border: 1px solid #444; + transition: all 0.2s ease; + user-select: none; + } + + .test-result-item:hover { + background: #333 !important; + border-color: #666; + transform: translateX(2px); + } + + .test-result-item:active { + background: #444 !important; + transform: translateX(0px); + } + + .queue-item { + padding: 5px; + margin: 2px 0; + background: #1a1a1a; + border-radius: 3px; + display: flex; + justify-content: space-between; + font-size: 11px; + border: 1px solid #333; + } + + .alert-item { + padding: 5px 10px; + margin: 2px 0; + background: #4a2a2a; + border-radius: 3px; + border-left: 3px solid #ff4444; + color: #ffcccc; + } + + #updates-status { + position: absolute; + top: 5px; + right: 50px; + background: rgba(255, 165, 0, 0.9); + color: #000; + padding: 2px 8px; + border-radius: 3px; + font-size: 10px; + font-weight: bold; + display: none; + z-index: 1001; + } + `; + + document.head.appendChild(style); + } + + /** + * Setup event listeners + */ + setupEventListeners() { + // Toggle monitoring - scoped to dashboard container + this.container.addEventListener("click", (e) => { + if (e.target.id === "msp-toggle-monitoring") { + this.toggleMonitoring(); + } else if (e.target.id === "msp-run-stress-test") { + this.runStressTest(); + } else if (e.target.id === "msp-clear-metrics") { + this.clearMetrics(); + } else if (e.target.id === "clear-alerts") { + this.clearAlerts(); + } else if (e.target.id === "msp-close-dashboard") { + this.hide(); + } else if (e.target.id === "analyze-queue") { + this.analyzeQueue(); + } else if (e.target.id === "export-report") { + this.exportReport(); + } else { + // Handle test result item clicks + const testResultItem = e.target.closest(".test-result-item"); + if (testResultItem) { + const testIndex = parseInt(testResultItem.getAttribute("data-test-index"), 10); + if (!isNaN(testIndex)) { + this.showTestDetails(testIndex); + } + } + + // Handle close details button clicks + if (e.target.classList.contains("close-details-btn")) { + const testDetails = e.target.closest(".test-details"); + if (testDetails) { + testDetails.remove(); + this.pauseUpdates(1000); + } + } + } + }); + + // Enhanced interaction handling + this.setupInteractionHandlers(); + + // Listen to monitor events + mspQueueMonitor.addListener((status) => { + this.updateDisplay(status); + }); + + // Keyboard shortcut to toggle dashboard + document.addEventListener("keydown", (e) => { + if (e.ctrlKey && e.shiftKey && e.key === "M") { + this.toggle(); + } + }); + + // Handle window resize to redraw canvas with correct dimensions + window.addEventListener("resize", () => { + if (this.isVisible) { + // Delay redraw to ensure layout is updated + setTimeout(() => this.drawChart(), 100); + } + }); + } + + /** + * Show the dashboard + */ + show() { + this.container.style.display = "block"; + this.isVisible = true; + this.updateDisplay(); + } + + /** + * Hide the dashboard + */ + hide() { + this.container.style.display = "none"; + this.isVisible = false; + } + + /** + * Toggle dashboard visibility + */ + toggle() { + if (this.isVisible) { + this.hide(); + } else { + this.show(); + } + } + + /** + * Setup enhanced interaction handlers + */ + setupInteractionHandlers() { + // Pause updates on any hover over interactive elements + const interactiveSelectors = [ + ".test-results", + ".queue-analysis", + ".test-result-item", + "button", + "select", + "input", + ".queue-item", + ".alert-item", + ]; + + interactiveSelectors.forEach((selector) => { + this.container.addEventListener( + "mouseenter", + (e) => { + if (e.target.matches(selector) || e.target.closest(selector)) { + this.pauseUpdates(3000); + } + }, + true, + ); + }); + + // Extended pause on clicks + this.container.addEventListener("click", (e) => { + const isInteractive = interactiveSelectors.some( + (selector) => e.target.matches(selector) || e.target.closest(selector), + ); + + if (isInteractive) { + this.pauseUpdates(5000); // Longer pause for clicks + } + }); + + // Pause on focus for input elements + this.container.addEventListener("focusin", (e) => { + if (e.target.matches("input, select, textarea, button")) { + this.pauseUpdates(10000); // Long pause for focused elements + } + }); + + // Resume updates when focus is lost + this.container.addEventListener("focusout", (e) => { + // Check if focus moved to another element within the dashboard + if (!this.container.contains(e.relatedTarget)) { + this.pauseUpdates(1000); // Short pause before resuming + } + }); + + // Special handling for test result items + this.container.addEventListener( + "mouseenter", + (e) => { + const testResultItem = e.target.closest(".test-result-item"); + if (testResultItem) { + testResultItem.style.backgroundColor = "#333"; + this.pauseUpdates(2000); + } + }, + true, + ); + + this.container.addEventListener( + "mouseleave", + (e) => { + const testResultItem = e.target.closest(".test-result-item"); + if (testResultItem) { + testResultItem.style.backgroundColor = ""; + } + }, + true, + ); + } + + /** + * Toggle monitoring + */ + toggleMonitoring() { + const button = document.getElementById("msp-toggle-monitoring"); + + if (mspQueueMonitor.isMonitoring) { + mspQueueMonitor.stopMonitoring(); + button.textContent = "Start Monitoring"; + button.style.background = "#444"; + } else { + mspQueueMonitor.startMonitoring(500); + button.textContent = "Stop Monitoring"; + button.style.background = "#00aa00"; + } + } + + /** + * Run stress test + */ + async runStressTest() { + const button = document.getElementById("msp-run-stress-test"); + const originalText = button.textContent; + + button.textContent = "Running Tests..."; + button.disabled = true; + + try { + const results = await mspStressTest.runStressTestSuite(); + this.displayTestResults(results); + } catch (error) { + console.error("Stress test failed:", error); + this.displayTestResults({ + summary: { failed: 1, error: error.message }, + detailedResults: [], + }); + } finally { + button.textContent = originalText; + button.disabled = false; + } + } + + /** + * Clear metrics + */ + clearMetrics() { + mspQueueMonitor.resetMetrics(); + this.chartData = { + queueSize: [], + responseTime: [], + timestamps: [], + }; + this.updateDisplay(); + } + + /** + * Clear alerts only + */ + clearAlerts() { + mspQueueMonitor.clearAlerts(); + } + + /** + * Update display with current status + */ + updateDisplay(status = null) { + if (!this.isVisible || this.updatesPaused) { + return; + } + + status = status || mspQueueMonitor.getStatus(); + + // Only update if data has actually changed to avoid unnecessary DOM manipulation + if (this._hasDataChanged(status)) { + this._updateStatusMetrics(status); + this._updateAlertsIfChanged(status.alerts); + this._updateQueueContentsIfChanged(status.queueContents); + this._updateChart(status); + + // Store current data for comparison + this.lastUpdateData = { + currentQueueSize: status.currentQueueSize, + totalRequests: status.metrics.totalRequests, + successRate: status.metrics.successRate, + avgResponseTime: status.metrics.avgResponseTime, + alerts: JSON.stringify(status.alerts), + queueContents: JSON.stringify(status.queueContents), + }; + } + } + + /** + * Check if data has changed to avoid unnecessary updates + */ + _hasDataChanged(status) { + const lastData = this.lastUpdateData; + if (!lastData) return true; + + // Quick primitive checks first (fastest) + if ( + lastData.currentQueueSize !== status.currentQueueSize || + lastData.totalRequests !== status.metrics.totalRequests || + lastData.successRate !== status.metrics.successRate || + lastData.avgResponseTime !== status.metrics.avgResponseTime + ) { + return true; + } + + // More expensive object comparisons only if needed + try { + const currentAlertsStr = JSON.stringify(status.alerts); + const currentQueueStr = JSON.stringify(status.queueContents); + + return lastData.alerts !== currentAlertsStr || lastData.queueContents !== currentQueueStr; + } catch (e) { + // If JSON.stringify fails, assume data changed + console.warn("JSON stringify failed in dashboard update check:", e); + return true; + } + } + + /** + * Update status metrics only + */ + _updateStatusMetrics(status) { + this.updateElement("queue-size", status.currentQueueSize); + this.updateElement("max-queue-size", status.maxQueueSize); + this.updateElement("success-rate", `${Math.round((status.metrics.successRate || 0) * 100)}%`); + this.updateElement("avg-response-time", `${Math.round(status.metrics.avgResponseTime || 0)}ms`); + this.updateElement("total-requests", status.metrics.totalRequests); + } + + /** + * Update alerts only if they've changed + */ + _updateAlertsIfChanged(alerts) { + const currentAlerts = JSON.stringify(alerts); + if (this.lastUpdateData.alerts !== currentAlerts) { + this.updateAlerts(alerts); + } + } + + /** + * Update queue contents only if they've changed + */ + _updateQueueContentsIfChanged(queueContents) { + const currentQueue = JSON.stringify(queueContents); + if (this.lastUpdateData.queueContents !== currentQueue) { + this.updateQueueContents(queueContents); + } + } + + /** + * Pause updates for a specified duration + */ + pauseUpdates(duration = 2000) { + this.updatesPaused = true; + + // Show pause indicator + const pauseIndicator = document.getElementById("updates-status"); + if (pauseIndicator) { + pauseIndicator.style.display = "block"; + } + + if (this.pauseTimeout) { + clearTimeout(this.pauseTimeout); + } + + this.pauseTimeout = setTimeout(() => { + this.updatesPaused = false; + + // Hide pause indicator + if (pauseIndicator) { + pauseIndicator.style.display = "none"; + } + + // Force an update when resuming + this.updateDisplay(); + }, duration); + } + + /** + * Update element text content with caching + */ + updateElement(id, value) { + let element = this.elementCache.get(id); + if (!element) { + element = document.getElementById(id); + if (element) { + this.elementCache.set(id, element); + } + } + + if (element && element.textContent !== value) { + element.textContent = value; + } + } + + /** + * Update alerts display + */ + updateAlerts(alerts) { + const container = document.getElementById("alerts-container"); + if (!container) { + return; + } + + const activeAlerts = Object.entries(alerts).filter(([_, active]) => active); + + if (activeAlerts.length === 0) { + container.innerHTML = '
No active alerts
'; + return; + } + + const alertMessages = { + queueFull: "Queue is near capacity", + highTimeout: "High timeout rate detected", + slowResponses: "Slow response times detected", + memoryLeak: "Potential memory leak detected", + }; + + container.innerHTML = activeAlerts + .map( + ([alertType, _]) => + `
${alertMessages[alertType] || this.escapeHtml(alertType)}
`, + ) + .join(""); + } + + /** + * Update queue contents display + */ + updateQueueContents(queueContents) { + const container = document.getElementById("queue-contents"); + if (!container) { + return; + } + + // Always show exactly 5 slots to prevent layout shifts + const maxSlots = 5; + const items = queueContents || []; + const slotsHtml = []; + + // Add actual queue items + for (let i = 0; i < maxSlots; i++) { + if (i < items.length) { + const item = items[i]; + slotsHtml.push(` +
+ Code: ${this.escapeHtml(item.code)} + Age: ${Math.round(item.age)}ms + Attempts: ${this.escapeHtml(item.attempts)} + ${item.hasTimer ? "βœ“" : "βœ—"} +
+ `); + } else { + // Add empty slot placeholder + slotsHtml.push(` +
+ β€” + β€” + β€” + β€” +
+ `); + } + } + + container.innerHTML = slotsHtml.join(""); + } + + /** + * Update chart with new data + */ + _updateChart(status) { + this.updateChart(status); + } + + /** + * Update chart with new data + */ + updateChart(status) { + const now = Date.now(); + + // Throttle chart updates to reduce performance impact + if (this.lastChartUpdate && now - this.lastChartUpdate < 200) { + return; // Skip update if less than 200ms since last update + } + + this.chartData.timestamps.push(now); + this.chartData.queueSize.push(status.currentQueueSize); + this.chartData.responseTime.push(status.metrics.avgResponseTime || 0); + + // Keep only recent data points + if (this.chartData.timestamps.length > this.maxDataPoints) { + this.chartData.timestamps.shift(); + this.chartData.queueSize.shift(); + this.chartData.responseTime.shift(); + } + + this.lastChartUpdate = now; + + // Use requestAnimationFrame for smoother chart updates + if (!this.chartUpdatePending) { + this.chartUpdatePending = true; + requestAnimationFrame(() => { + this.drawChart(); + this.chartUpdatePending = false; + }); + } + } + + /** + * Draw the metrics chart + */ + drawChart() { + const canvas = document.getElementById("msp-metrics-chart"); + if (!canvas) { + return; + } + + const ctx = canvas.getContext("2d"); + + // Get the display size (CSS pixels) + const rect = canvas.getBoundingClientRect(); + const displayWidth = rect.width; + const displayHeight = rect.height; + + // Get the device pixel ratio, falling back to 1 + const devicePixelRatio = window.devicePixelRatio || 1; + + // Set the internal canvas size to actual pixels for Hi-DPI displays + canvas.width = displayWidth * devicePixelRatio; + canvas.height = displayHeight * devicePixelRatio; + + // Scale the canvas back down using CSS + canvas.style.width = `${displayWidth}px`; + canvas.style.height = `${displayHeight}px`; + + // Scale the drawing context so everything draws at the correct size + ctx.scale(devicePixelRatio, devicePixelRatio); + + const width = displayWidth; + const height = displayHeight; + + // Clear canvas + ctx.fillStyle = "#2a2a2a"; + ctx.fillRect(0, 0, width, height); + + if (this.chartData.timestamps.length < 2) { + return; + } + + // Draw queue size line + ctx.strokeStyle = "#00ff00"; + ctx.lineWidth = 2; + ctx.beginPath(); + + const maxQueueSize = Math.max(...this.chartData.queueSize, 10); + + this.chartData.queueSize.forEach((size, i) => { + const x = (i / (this.chartData.queueSize.length - 1)) * width; + const y = height - (size / maxQueueSize) * height; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + + ctx.stroke(); + + // Draw labels with proper font scaling + ctx.fillStyle = "#ffffff"; + ctx.font = "10px monospace"; + ctx.fillText("Queue Size", 5, 15); + ctx.fillText(`Max: ${maxQueueSize}`, 5, height - 5); + } + + /** + * Analyze current queue + */ + analyzeQueue() { + const analysis = mspQueueMonitor.analyzeQueue(); + const container = document.getElementById("queue-analysis"); + + if (!container) { + return; + } + + container.innerHTML = ` +
Total Items: ${analysis.totalItems}
+
Age Distribution:
+
+ Fresh (<1s): ${analysis.ageDistribution.fresh}
+ Recent (1-5s): ${analysis.ageDistribution.recent}
+ Stale (5-10s): ${analysis.ageDistribution.stale}
+ Ancient (>10s): ${analysis.ageDistribution.ancient} +
+
By Code:
+
+ ${Object.entries(analysis.byCode) + .map(([code, count]) => `Code ${this.escapeHtml(String(code))}: ${count}`) + .join("
")} +
+ ${ + analysis.potentialIssues.length > 0 + ? ` +
Issues:
+
+ ${analysis.potentialIssues.map((p) => this.escapeHtml(String(p))).join("
")} +
+ ` + : "" +} + `; + } + + /** + * Display test results + */ + displayTestResults(results) { + const container = document.getElementById("test-results"); + if (!container) { + return; + } + + const summary = results.summary || {}; + + container.innerHTML = ` +
Test Summary:
+
+ Passed: ${summary.passed || 0}
+ Failed: ${summary.failed || 0}
+ Success Rate: ${Math.round((summary.successRate || 0) * 100)}%
+ Grade: ${summary.overallGrade || "N/A"} +
+
Details (click for more info):
+
+ ${(results.detailedResults || []) + .map( + (test, index) => ` +
+ + ${this.escapeHtml(test.name)} + + ${this.escapeHtml(test.status)} +
+ `, + ) + .join("")} +
+ `; + + // Store test results for detailed view + this.lastTestResults = results; + } + + /** + * Show detailed test information + */ + showTestDetails(testIndex) { + if (!this?.lastTestResults?.detailedResults) { + return; + } + + const test = this.lastTestResults.detailedResults[testIndex]; + if (!test) { + return; + } + + // Pause updates while showing details + this.pauseUpdates(5000); + + const detailsHtml = ` +
+

πŸ“‹ ${this.escapeHtml(test.name)} Details

+
Status: ${this.escapeHtml(test.status)}
+ ${test.duration ? `
Duration: ${Math.round(test.duration)}ms
` : ""} + ${test.error ? `
Error: ${this.escapeHtml(test.error)}
` : ""} + ${ + test.result + ? ` +
Results:
+
${this.escapeHtml(JSON.stringify(test.result, null, 2))}
+ ` + : "" +} + ${ + test.metrics + ? ` +
Metrics:
+
+ Queue Size: ${test.metrics.currentQueueSize}/${test.metrics.maxQueueSize}
+ Total Requests: ${test.metrics.totalRequests}
+ Success Rate: ${Math.round((test.metrics.successRate || 0) * 100)}%
+ Avg Response: ${Math.round(test.metrics.avgResponseTime || 0)}ms +
+ ` + : "" +} + +
+ `; + + // Add details after the test results + const testContainer = document.getElementById("test-results"); + const existingDetails = testContainer.querySelector(".test-details"); + if (existingDetails) { + existingDetails.remove(); + } + + const detailsDiv = document.createElement("div"); + detailsDiv.className = "test-details"; + detailsDiv.innerHTML = detailsHtml; + testContainer.appendChild(detailsDiv); + } + + /** + * Export comprehensive report + */ + exportReport() { + const report = mspQueueMonitor.generateReport(); + const blob = new Blob([JSON.stringify(report, null, 2)], { + type: "application/json", + }); + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `msp-report-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } +} + +// Create and export dashboard instance +export const mspDebugDashboard = new MSPDebugDashboard(); + +// Add global shortcut and console commands +window.MSPDebug = { + dashboard: mspDebugDashboard, + monitor: mspQueueMonitor, + stressTest: mspStressTest, + + // Convenience methods + show: () => mspDebugDashboard.show(), + hide: () => mspDebugDashboard.hide(), + startMonitoring: () => mspQueueMonitor.startMonitoring(), + stopMonitoring: () => mspQueueMonitor.stopMonitoring(), + runTests: () => mspStressTest.runStressTestSuite(), + runFullSuite: () => mspStressTest.runStressTestSuite(), + analyze: () => mspQueueMonitor.analyzeQueue(), + report: () => mspQueueMonitor.generateReport(), + showTestDetails: (index) => mspDebugDashboard.showTestDetails(index), + + // Individual test methods + runTest: (testName) => mspStressTest.runSpecificTest(testName), + quickHealthCheck: () => window.MSPTestRunner?.quickHealthCheck?.(), + stressScenario: (scenario) => window.MSPTestRunner?.stressScenario?.(scenario), + getStatus: () => mspQueueMonitor.getStatus(), + + // Alert testing methods + triggerTestAlerts: () => mspQueueMonitor.triggerTestAlerts(), + setTestThresholds: () => mspQueueMonitor.setTestThresholds(), + setNormalThresholds: () => mspQueueMonitor.setNormalThresholds(), + + // Quick test method + testAlerts: () => { + console.log("πŸ§ͺ Running alert test..."); + mspDebugDashboard.show(); + mspQueueMonitor.startMonitoring(500); + return mspQueueMonitor.triggerTestAlerts(); + }, +}; + +console.log("πŸ”§ MSP Debug Tools loaded! Use Ctrl+Shift+M to toggle dashboard or MSPDebug.show()"); diff --git a/src/js/msp/msp_debug_tools.js b/src/js/msp/msp_debug_tools.js new file mode 100644 index 00000000..fb602ee5 --- /dev/null +++ b/src/js/msp/msp_debug_tools.js @@ -0,0 +1,52 @@ +/** + * MSP Debug Tools - Integration file to load all debugging and testing tools + * Include this file to get comprehensive MSP monitoring and testing capabilities + */ + +// Import all debug tools +import "./msp_queue_monitor.js"; +import "./msp_stress_test.js"; +import "./msp_debug_dashboard.js"; +import "./msp_test_runner.js"; + +console.log(` +πŸ”§ MSP Debug Tools Loaded Successfully! + +Quick Start: + β€’ Press Ctrl+Shift+M to toggle the visual dashboard + β€’ Use MSPTestRunner.help() to see all available commands + β€’ Use MSPTestRunner.quickHealthCheck() for a quick test + +Example Usage: + MSPTestRunner.startQuickMonitor(); // Start monitoring + MSPTestRunner.runTest('queue-flooding'); // Run specific test + MSPTestRunner.showDashboard(); // Show visual dashboard + MSPTestRunner.runFullSuite(); // Run all stress tests + +The tools will help you: + βœ“ Monitor MSP queue health in real-time + βœ“ Detect memory leaks and performance issues + βœ“ Stress test the MSP implementation + βœ“ Analyze queue contents and response times + βœ“ Export detailed diagnostic reports + +Happy debugging! πŸš€ +`); + +// Auto-start basic monitoring in development +if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") { + console.log("πŸ”„ Development environment detected - auto-starting basic monitoring"); + + // Import the monitor and start it with minimal logging + import("./msp_queue_monitor.js").then(({ mspQueueMonitor }) => { + mspQueueMonitor.addListener((status) => { + // Only log alerts and significant events + const alerts = Object.values(status.alerts).filter((a) => a); + if (alerts.length > 0) { + console.warn("🚨 MSP Alert detected - check dashboard for details"); + } + }); + + mspQueueMonitor.startMonitoring(2000); // Monitor every 2 seconds + }); +} diff --git a/src/js/msp/msp_queue_monitor.js b/src/js/msp/msp_queue_monitor.js new file mode 100644 index 00000000..85a5578e --- /dev/null +++ b/src/js/msp/msp_queue_monitor.js @@ -0,0 +1,692 @@ +/** + * MSP Queue Monitor - Real-time monitoring of MSP message queue + * Provides insights into queue health, performance metrics, and potential issues + */ + +export class MSPQueueMonitor { + constructor(mspInstance) { + this.msp = mspInstance; + this.isMonitoring = false; + this.metrics = { + totalRequests: 0, + completedRequests: 0, + failedRequests: 0, + timeouts: 0, + duplicates: 0, + avgResponseTime: 0, + maxResponseTime: 0, + queuePeakSize: 0, + requestsByCode: new Map(), + responseTimes: [], + errorsByType: new Map(), + }; + + this.alerts = { + queueFull: false, + highTimeout: false, + slowResponses: false, + memoryLeak: false, + }; + + this.thresholds = { + maxQueueSize: Math.floor((this.msp.MAX_QUEUE_SIZE || 100) * 0.8), // Alert when queue > 80% of MAX_QUEUE_SIZE + maxAvgResponseTime: 2000, // Alert when avg response > 2s + maxTimeoutRate: 0.1, // Alert when timeout rate > 10% + memoryLeakThreshold: 100, // Alert when callbacks grow beyond expected + }; + + this.monitoringInterval = null; + this.listeners = []; + + // Hook into MSP methods to collect metrics + this._hookMSPMethods(); + } + + /** + * Hook into MSP methods to collect real-time metrics + */ + _hookMSPMethods() { + // Check if MSP instance is already instrumented to prevent double-patching + if (this.msp._mspQueueMonitorInstrumented) { + console.warn("MSP instance is already instrumented by MSPQueueMonitor"); + return; + } + + // Store original methods + this.originalSendMessage = this.msp.send_message.bind(this.msp); + this.originalDispatchMessage = this.msp._dispatch_message.bind(this.msp); + this.originalRemoveRequest = this.msp._removeRequestFromCallbacks?.bind(this.msp); + + // Override send_message to track requests + this.msp.send_message = (...args) => { + this._trackRequestStart(args[0], args[1]); + return this.originalSendMessage(...args); + }; + + // Override _dispatch_message to track responses + this.msp._dispatch_message = (...args) => { + this._trackResponse(); + return this.originalDispatchMessage(...args); + }; + + // Override _removeRequestFromCallbacks to track completions + if (this.originalRemoveRequest) { + this.msp._removeRequestFromCallbacks = (requestObj) => { + this._trackRequestCompletion(requestObj); + return this.originalRemoveRequest(requestObj); + }; + } + + // Mark MSP instance as instrumented + this.msp._mspQueueMonitorInstrumented = true; + } + + /** + * Track when a request starts + */ + _trackRequestStart(code, data) { + this.metrics.totalRequests++; + + // Track requests by code + const count = this.metrics.requestsByCode.get(code) || 0; + this.metrics.requestsByCode.set(code, count + 1); + + // Check for queue size peaks + const currentQueueSize = this.msp.callbacks?.length ?? 0; + if (currentQueueSize > this.metrics.queuePeakSize) { + this.metrics.queuePeakSize = currentQueueSize; + } + + this._checkAlerts(); + } + + /** + * Track when a response is received + */ + _trackResponse() { + // This will be called for both successful and failed responses + // More detailed tracking happens in _trackRequestCompletion + } + + /** + * Track when a request is completed (success or failure) + */ + _trackRequestCompletion(requestObj) { + if (!requestObj) { + return; + } + + const responseTime = performance.now() - requestObj.start; + this.metrics.responseTimes.push(responseTime); + + // Keep only last 100 response times for rolling average + if (this.metrics.responseTimes.length > 100) { + this.metrics.responseTimes.shift(); + } + + // Update max response time + if (responseTime > this.metrics.maxResponseTime) { + this.metrics.maxResponseTime = responseTime; + } + + // Calculate average response time + this.metrics.avgResponseTime = + this.metrics.responseTimes.reduce((a, b) => a + b, 0) / this.metrics.responseTimes.length; + + // Track completion type + if (requestObj.attempts > 1) { + this.metrics.timeouts += requestObj.attempts - 1; + } + + if (requestObj.success === false) { + this.metrics.failedRequests++; + + // Track error types + const errorType = requestObj.errorType || "unknown"; + const errorCount = this.metrics.errorsByType.get(errorType) || 0; + this.metrics.errorsByType.set(errorType, errorCount + 1); + } else { + this.metrics.completedRequests++; + } + + this._checkAlerts(); + } + + /** + * Check for alert conditions + */ + _checkAlerts() { + const queueSize = this.msp.callbacks?.length ?? 0; + + // Queue full alert + const wasQueueFull = this.alerts.queueFull; + this.alerts.queueFull = queueSize > this.thresholds.maxQueueSize; + + // High timeout rate alert + const timeoutRate = this.metrics.totalRequests > 0 ? this.metrics.timeouts / this.metrics.totalRequests : 0; + const wasHighTimeout = this.alerts.highTimeout; + this.alerts.highTimeout = timeoutRate > this.thresholds.maxTimeoutRate; + + // Slow responses alert + const wasSlowResponses = this.alerts.slowResponses; + this.alerts.slowResponses = this.metrics.avgResponseTime > this.thresholds.maxAvgResponseTime; + + // Memory leak detection (callbacks not being cleaned up) + const wasMemoryLeak = this.alerts.memoryLeak; + this.alerts.memoryLeak = queueSize > this.thresholds.memoryLeakThreshold; + + // Debug logging for alert changes (only when alerts become active) + if (this.alerts.queueFull !== wasQueueFull && this.alerts.queueFull) { + console.warn(`🚨 Queue Full Alert: size ${queueSize}/${this.thresholds.maxQueueSize}`); + } + if (this.alerts.highTimeout !== wasHighTimeout && this.alerts.highTimeout) { + console.warn(`⏱️ High Timeout Alert: rate ${(timeoutRate * 100).toFixed(1)}%`); + } + if (this.alerts.slowResponses !== wasSlowResponses && this.alerts.slowResponses) { + console.warn(`🐌 Slow Response Alert: avg ${this.metrics.avgResponseTime}ms`); + } + if (this.alerts.memoryLeak !== wasMemoryLeak && this.alerts.memoryLeak) { + console.warn(`πŸ’Ύ Memory Leak Alert: callbacks ${queueSize}`); + } + + // Notify listeners of alerts + this._notifyListeners(); + } + + /** + * Start monitoring + */ + startMonitoring(intervalMs = 1000) { + if (this.isMonitoring) { + return; + } + + this.isMonitoring = true; + this.monitoringInterval = setInterval(() => { + this._collectMetrics(); + this._notifyListeners(); + }, intervalMs); + + console.log("MSP Queue Monitor started"); + } + + /** + * Stop monitoring + */ + stopMonitoring() { + if (!this.isMonitoring) { + return; + } + + this.isMonitoring = false; + if (this.monitoringInterval) { + clearInterval(this.monitoringInterval); + this.monitoringInterval = null; + } + + console.log("MSP Queue Monitor stopped"); + } + + /** + * Collect current metrics snapshot + */ + _collectMetrics() { + // Update current queue size + this.currentQueueSize = this.msp.callbacks.length; + + // Calculate success rate + this.metrics.successRate = + this.metrics.totalRequests > 0 ? this.metrics.completedRequests / this.metrics.totalRequests : 0; + + // Calculate timeout rate + this.metrics.timeoutRate = + this.metrics.totalRequests > 0 ? this.metrics.timeouts / this.metrics.totalRequests : 0; + } + + /** + * Get current status report + */ + getStatus() { + return { + isMonitoring: this.isMonitoring, + currentQueueSize: this.msp.callbacks.length, + maxQueueSize: this.msp.MAX_QUEUE_SIZE || 100, + metrics: { ...this.metrics }, + alerts: { ...this.alerts }, + queueContents: this.msp.callbacks.map((req) => ({ + code: req.code, + attempts: req.attempts || 0, + age: performance.now() - req.start, + hasTimer: !!req.timer, + })), + }; + } + + /** + * Get detailed queue analysis + */ + analyzeQueue() { + const callbacks = this.msp.callbacks; + const now = performance.now(); + + const analysis = { + totalItems: callbacks.length, + byCode: {}, + ageDistribution: { + fresh: 0, // < 1s + recent: 0, // 1-5s + stale: 0, // 5-10s + ancient: 0, // > 10s + }, + retryDistribution: { + firstAttempt: 0, + retrying: 0, + multipleRetries: 0, + }, + potentialIssues: [], + }; + + callbacks.forEach((req) => { + // Group by code + if (!analysis.byCode[req.code]) { + analysis.byCode[req.code] = 0; + } + analysis.byCode[req.code]++; + + // Age analysis + const age = now - req.start; + if (age < 1000) { + analysis.ageDistribution.fresh++; + } else if (age < 5000) { + analysis.ageDistribution.recent++; + } else if (age < 10000) { + analysis.ageDistribution.stale++; + } else { + analysis.ageDistribution.ancient++; + } + + // Retry analysis + const attempts = req.attempts || 0; + if (attempts === 0) { + analysis.retryDistribution.firstAttempt++; + } else if (attempts === 1) { + analysis.retryDistribution.retrying++; + } else { + analysis.retryDistribution.multipleRetries++; + } + + // Identify potential issues + if (age > 10000) { + analysis.potentialIssues.push(`Ancient request: code ${req.code}, age ${Math.round(age / 1000)}s`); + } + if (attempts > 5) { + analysis.potentialIssues.push(`High retry count: code ${req.code}, attempts ${attempts}`); + } + if (!req.timer) { + analysis.potentialIssues.push(`Missing timer: code ${req.code}`); + } + }); + + return analysis; + } + + /** + * Add a listener for monitoring events + */ + addListener(callback) { + this.listeners.push(callback); + } + + /** + * Remove a listener + */ + removeListener(callback) { + const index = this.listeners.indexOf(callback); + if (index > -1) { + this.listeners.splice(index, 1); + } + } + + /** + * Notify all listeners + */ + _notifyListeners() { + const status = this.getStatus(); + this.listeners.forEach((listener) => { + try { + listener(status); + } catch (error) { + console.error("Error in MSP monitor listener:", error); + } + }); + } + + /** + * Reset metrics only (keep alerts intact) + */ + resetMetrics() { + this.metrics = { + totalRequests: 0, + completedRequests: 0, + failedRequests: 0, + timeouts: 0, + duplicates: 0, + avgResponseTime: 0, + maxResponseTime: 0, + queuePeakSize: 0, + requestsByCode: new Map(), + responseTimes: [], + errorsByType: new Map(), + }; + + // Note: Alerts are NOT reset here - they should only be cleared when conditions are no longer true + // or when explicitly requested via clearAlerts() method + } + + /** + * Clear alerts (separate from metrics) + */ + clearAlerts() { + console.log("πŸ”„ Clearing all alerts..."); + this.alerts = { + queueFull: false, + highTimeout: false, + slowResponses: false, + memoryLeak: false, + }; + this._notifyListeners(); + } + + /** + * Reset both metrics and alerts (complete reset) + */ + resetAll() { + this.resetMetrics(); + this.clearAlerts(); + } + + /** + * Generate a detailed report + */ + generateReport() { + const status = this.getStatus(); + const analysis = this.analyzeQueue(); + + return { + timestamp: new Date().toISOString(), + summary: { + queueHealth: this._assessQueueHealth(), + performanceGrade: this._calculatePerformanceGrade(), + recommendations: this._generateRecommendations(), + }, + status, + analysis, + rawMetrics: this.metrics, + }; + } + + /** + * Assess overall queue health + */ + _assessQueueHealth() { + const alerts = Object.values(this.alerts); + const activeAlerts = alerts.filter((alert) => alert).length; + + if (activeAlerts === 0) { + return "HEALTHY"; + } + if (activeAlerts <= 2) { + return "WARNING"; + } + return "CRITICAL"; + } + + /** + * Calculate performance grade + */ + _calculatePerformanceGrade() { + let score = 100; + + // Deduct for high timeout rate + if (this.metrics.timeoutRate > 0.1) { + score -= 30; + } else if (this.metrics.timeoutRate > 0.05) { + score -= 15; + } + + // Deduct for slow responses + if (this.metrics.avgResponseTime > 2000) { + score -= 25; + } else if (this.metrics.avgResponseTime > 1000) { + score -= 10; + } + + // Deduct for queue size issues + const currentQueueSize = this.currentQueueSize || (this.msp.callbacks?.length ?? 0); + const queueRatio = currentQueueSize / (this.msp.MAX_QUEUE_SIZE || 100); + if (queueRatio > 0.8) { + score -= 20; + } else if (queueRatio > 0.6) { + score -= 10; + } + + // Deduct for failed requests + const failureRate = + this.metrics.totalRequests > 0 ? this.metrics.failedRequests / this.metrics.totalRequests : 0; + if (failureRate > 0.05) { + score -= 15; + } + + if (score >= 90) { + return "A"; + } + if (score >= 80) { + return "B"; + } + if (score >= 70) { + return "C"; + } + if (score >= 60) { + return "D"; + } + return "F"; + } + + /** + * Generate recommendations + */ + _generateRecommendations() { + const recommendations = []; + + if (this.alerts.queueFull) { + recommendations.push( + "Queue is near capacity. Consider implementing request prioritization or increasing queue size.", + ); + } + + if (this.alerts.highTimeout) { + recommendations.push( + "High timeout rate detected. Check serial connection stability or increase timeout values.", + ); + } + + if (this.alerts.slowResponses) { + recommendations.push( + "Slow response times detected. Investigate flight controller performance or reduce request frequency.", + ); + } + + if (this.alerts.memoryLeak) { + recommendations.push( + "Potential memory leak detected. Check that all requests are being properly cleaned up.", + ); + } + + if (this.metrics.maxResponseTime > 5000) { + recommendations.push( + "Some requests are taking very long to complete. Consider implementing request timeouts.", + ); + } + + return recommendations; + } + + /** + * Test the alert system by manually triggering alerts + */ + triggerTestAlerts() { + console.log("πŸ§ͺ Triggering test alerts..."); + + // Store original alerts + const originalAlerts = { ...this.alerts }; + + // Trigger all alerts + this.alerts.queueFull = true; + this.alerts.highTimeout = true; + this.alerts.slowResponses = true; + this.alerts.memoryLeak = true; + + console.log("🚨 Test alerts triggered:", this.alerts); + + // Notify listeners immediately + this._notifyListeners(); + + // Reset after 10 seconds + setTimeout(() => { + this.alerts = originalAlerts; + console.log("βœ… Test alerts reset"); + this._notifyListeners(); + }, 10000); + + return this.alerts; + } + + /** + * Lower alert thresholds for testing + */ + setTestThresholds() { + console.log("🎯 Setting test thresholds for easier alert triggering..."); + this.thresholds = { + maxQueueSize: 1, // Alert when queue > 1 + maxAvgResponseTime: 100, // Alert when avg response > 100ms + maxTimeoutRate: 0.01, // Alert when timeout rate > 1% + memoryLeakThreshold: 5, // Alert when callbacks > 5 + }; + console.log("New thresholds:", this.thresholds); + } + + /** + * Reset to normal thresholds + */ + setNormalThresholds() { + console.log("πŸ”§ Resetting to normal thresholds..."); + this.thresholds = { + maxQueueSize: Math.floor((this.msp.MAX_QUEUE_SIZE || 100) * 0.8), // Alert when queue > 80% of MAX_QUEUE_SIZE + maxAvgResponseTime: 2000, // Alert when avg response > 2s + maxTimeoutRate: 0.1, // Alert when timeout rate > 10% + memoryLeakThreshold: 100, // Alert when callbacks grow beyond expected + }; + console.log("Normal thresholds restored:", this.thresholds); + } + + /** + * Cleanup and restore original MSP methods + */ + destroy() { + this.stopMonitoring(); + + // Restore original methods + if (this.originalSendMessage) { + this.msp.send_message = this.originalSendMessage; + } + if (this.originalDispatchMessage) { + this.msp._dispatch_message = this.originalDispatchMessage; + } + if (this.originalRemoveRequest) { + this.msp._removeRequestFromCallbacks = this.originalRemoveRequest; + } + + // Clear instrumentation flag + this.msp._mspQueueMonitorInstrumented = undefined; + + this.listeners = []; + + // Clear the singleton instance to allow creating a fresh monitor later + _mspQueueMonitorInstance = null; + } +} + +// Lazy initialization to avoid errors when window.MSP is not yet available +let _mspQueueMonitorInstance = null; + +export const mspQueueMonitor = { + get instance() { + if (!_mspQueueMonitorInstance) { + if (typeof window === "undefined" || !window.MSP) { + throw new Error( + "MSP Queue Monitor: window.MSP is not available. Make sure MSP is loaded before using the monitor.", + ); + } + _mspQueueMonitorInstance = new MSPQueueMonitor(window.MSP); + } + return _mspQueueMonitorInstance; + }, + + // Proxy all methods to the lazy-initialized instance + startMonitoring(...args) { + return this.instance.startMonitoring(...args); + }, + stopMonitoring(...args) { + return this.instance.stopMonitoring(...args); + }, + getStatus(...args) { + return this.instance.getStatus(...args); + }, + analyzeQueue(...args) { + return this.instance.analyzeQueue(...args); + }, + addListener(...args) { + return this.instance.addListener(...args); + }, + removeListener(...args) { + return this.instance.removeListener(...args); + }, + resetMetrics(...args) { + return this.instance.resetMetrics(...args); + }, + clearAlerts(...args) { + return this.instance.clearAlerts(...args); + }, + resetAll(...args) { + return this.instance.resetAll(...args); + }, + generateReport(...args) { + return this.instance.generateReport(...args); + }, + triggerTestAlerts(...args) { + return this.instance.triggerTestAlerts(...args); + }, + setTestThresholds(...args) { + return this.instance.setTestThresholds(...args); + }, + setNormalThresholds(...args) { + return this.instance.setNormalThresholds(...args); + }, + destroy(...args) { + return this.instance.destroy(...args); + }, + + // Getters for properties + get isMonitoring() { + return this.instance.isMonitoring; + }, + get metrics() { + return this.instance.metrics; + }, + get alerts() { + return this.instance.alerts; + }, + get thresholds() { + return this.instance.thresholds; + }, +}; diff --git a/src/js/msp/msp_stress_test.js b/src/js/msp/msp_stress_test.js new file mode 100644 index 00000000..679a7dc6 --- /dev/null +++ b/src/js/msp/msp_stress_test.js @@ -0,0 +1,756 @@ +/** + * MSP Stress Test Framework + * Comprehensive testing tool for MSP queue management, timeout handling, and performance + */ + +import { mspQueueMonitor } from "./msp_queue_monitor.js"; + +export class MSPStressTest { + constructor(mspInstance) { + this.msp = mspInstance; + this.monitor = mspQueueMonitor; // Reuse singleton to avoid duplicate method patching + this.isRunning = false; + this.testResults = []; + this.currentTest = null; + + // Common MSP codes for testing + this.testCodes = { + MSP_IDENT: 100, + MSP_STATUS: 101, + MSP_RAW_IMU: 102, + MSP_SERVO: 103, + MSP_MOTOR: 104, + MSP_RC: 105, + MSP_RAW_GPS: 106, + MSP_COMP_GPS: 107, + MSP_ATTITUDE: 108, + MSP_ALTITUDE: 109, + MSP_ANALOG: 110, + MSP_RC_TUNING: 111, + MSP_PID: 112, + MSP_PIDNAMES: 116, + MSP_BOXNAMES: 117, + MSP_MISC: 114, + MSP_MOTOR_PINS: 115, + }; + } + + /** + * Run a comprehensive stress test suite + */ + async runStressTestSuite() { + console.log("πŸš€ Starting MSP Stress Test Suite"); + this.isRunning = true; + this.monitor.startMonitoring(100); // High frequency monitoring during tests + + const tests = [ + { name: "Queue Flooding", test: () => this.testQueueFlooding() }, + { name: "Rapid Fire Requests", test: () => this.testRapidFireRequests() }, + { name: "Duplicate Request Handling", test: () => this.testDuplicateRequests() }, + { name: "Timeout Recovery", test: () => this.testTimeoutRecovery() }, + { name: "Memory Leak Detection", test: () => this.testMemoryLeaks() }, + { name: "Concurrent Mixed Requests", test: () => this.testConcurrentMixedRequests() }, + { name: "Queue Overflow Handling", test: () => this.testQueueOverflow() }, + { name: "Connection Disruption", test: () => this.testConnectionDisruption() }, + { name: "Performance Under Load", test: () => this.testPerformanceUnderLoad() }, + ]; + + const results = []; + + try { + for (const testDef of tests) { + try { + console.log(`\nπŸ“‹ Running: ${testDef.name}`); + this.currentTest = testDef.name; + this.monitor.resetAll(); // Reset both metrics and alerts for clean test start + + const startTime = performance.now(); + const result = await testDef.test(); + const duration = performance.now() - startTime; + + const testResult = { + name: testDef.name, + status: "PASSED", + duration, + result, + metrics: this.monitor.getStatus(), + timestamp: new Date().toISOString(), + }; + + results.push(testResult); + console.log(`βœ… ${testDef.name} completed in ${Math.round(duration)}ms`); + + // Wait between tests to let queue settle + await this.wait(1000); + } catch (error) { + console.error(`❌ ${testDef.name} failed:`, error); + results.push({ + name: testDef.name, + status: "FAILED", + error: error.message, + timestamp: new Date().toISOString(), + }); + } + } + + this.testResults = results; + const report = this.generateTestReport(results); + console.log("\nπŸ“Š Stress Test Suite Complete"); + console.log(report.summary); + + return report; + } finally { + // Ensure cleanup always happens regardless of errors + this.monitor.stopMonitoring(); + this.isRunning = false; + this.currentTest = null; + } + } + + /** + * Test 1: Queue Flooding - Send many requests quickly to test queue limits + */ + async testQueueFlooding() { + const requestCount = 110; // More than default MAX_QUEUE_SIZE + const promises = []; + + console.log(` Flooding queue with ${requestCount} requests...`); + + for (let i = 0; i < requestCount; i++) { + const code = Object.values(this.testCodes)[i % Object.keys(this.testCodes).length]; + const promise = this.msp.promise(code, null).catch((err) => ({ error: err.message })); + promises.push(promise); + } + + const results = await Promise.allSettled(promises); + const successful = results.filter((r) => r.status === "fulfilled" && !(r.value && r.value.error)).length; + const failed = results.length - successful; + + return { + requestsSent: requestCount, + successful, + failed, + successRate: successful / requestCount, + peakQueueSize: (this.monitor.getStatus().metrics || {}).queuePeakSize ?? 0, + }; + } + + /** + * Test 2: Rapid Fire Requests - Send requests in rapid succession + */ + async testRapidFireRequests() { + const requestCount = 20; + const interval = 10; // 10ms between request initiation + + console.log(` Sending ${requestCount} requests with ${interval}ms intervals...`); + + const promises = []; + const requestStartTimes = []; + const startTime = performance.now(); + + // Create all requests concurrently with timed intervals + for (let i = 0; i < requestCount; i++) { + const code = this.testCodes.MSP_STATUS; + const requestStart = performance.now(); + requestStartTimes.push(requestStart); + + // Create promise without awaiting to allow concurrency + const promise = this.msp + .promise(code, null) + .then(() => ({ + success: true, + responseTime: performance.now() - requestStart, + index: i, + })) + .catch((error) => ({ + success: false, + error: error.message, + responseTime: performance.now() - requestStart, + index: i, + })); + + promises.push(promise); + + // Wait interval before starting next request (except for last) + if (i < requestCount - 1) { + await this.wait(interval); + } + } + + // Wait for all requests to complete + const results = await Promise.allSettled(promises); + const totalTime = performance.now() - startTime; + + // Extract results from settled promises + const processedResults = results.map((settled) => { + if (settled.status === "fulfilled") { + return settled.value; + } else { + return { + success: false, + error: settled.reason?.message || "Unknown error", + responseTime: 0, + index: -1, + }; + } + }); + + const successful = processedResults.filter((r) => r.success).length; + const responseTimes = processedResults.map((r) => r.responseTime).filter((t) => t > 0); + const avgResponseTime = + responseTimes.length > 0 ? responseTimes.reduce((sum, r) => sum + r, 0) / responseTimes.length : 0; + + return { + requestCount, + successful, + failed: requestCount - successful, + totalTime, + avgResponseTime, + throughput: requestCount / (totalTime / 1000), // requests per second + concurrentRequests: true, + maxConcurrentRequests: requestCount, + }; + } + + /** + * Test 3: Duplicate Request Handling + */ + async testDuplicateRequests() { + const code = this.testCodes.MSP_IDENT; + const data = new Uint8Array([1, 2, 3]); // Same data for all requests + const duplicateCount = 5; + + console.log(` Sending ${duplicateCount} duplicate requests...`); + + const promises = []; + for (let i = 0; i < duplicateCount; i++) { + promises.push(this.msp.promise(code, data).catch((err) => ({ error: err.message }))); + } + + const results = await Promise.allSettled(promises); + const successful = results.filter((r) => r.status === "fulfilled" && !(r.value && r.value.error)).length; + const duplicateErrors = results.filter( + (r) => r.status === "rejected" || (r.value && r.value.error && r.value.error.includes("duplicate")), + ).length; + + return { + duplicatesSent: duplicateCount, + successful, + duplicateRejections: duplicateErrors, + queueSizeAfter: this.msp.callbacks.length, + }; + } + + /** + * Test 4: Timeout Recovery + */ + async testTimeoutRecovery() { + console.log(" Testing timeout recovery..."); + + // Save original timeout + const originalTimeout = this.msp.TIMEOUT; + this.msp.TIMEOUT = 100; // Very short timeout for testing + + try { + const code = this.testCodes.MSP_STATUS; + const startTime = performance.now(); + + try { + await this.msp.promise(code, null); + return { error: "Expected timeout but request succeeded" }; + } catch (error) { + const timeoutTime = performance.now() - startTime; + + // Test that new requests work after timeout + this.msp.TIMEOUT = originalTimeout; + await this.wait(200); + + const recoveryStart = performance.now(); + await this.msp.promise(this.testCodes.MSP_IDENT, null); + const recoveryTime = performance.now() - recoveryStart; + + return { + timeoutOccurred: true, + timeoutDuration: timeoutTime, + recoveryTime, + queueCleanedUp: this.msp.callbacks.length === 0, + }; + } + } finally { + this.msp.TIMEOUT = originalTimeout; + } + } + + /** + * Test 5: Memory Leak Detection + */ + async testMemoryLeaks() { + console.log(" Testing for memory leaks..."); + + const initialCallbackCount = this.msp.callbacks.length; + const requestCount = 10; + + // Send requests and let them complete + const promises = []; + for (let i = 0; i < requestCount; i++) { + promises.push(this.msp.promise(this.testCodes.MSP_STATUS, null).catch(() => {})); + } + + await Promise.allSettled(promises); + await this.wait(100); // Let cleanup complete + + const finalCallbackCount = this.msp.callbacks.length; + const leaked = finalCallbackCount - initialCallbackCount; + + return { + initialCallbacks: initialCallbackCount, + finalCallbacks: finalCallbackCount, + leaked, + memoryLeakDetected: leaked > 0, + requestsProcessed: requestCount, + }; + } + + /** + * Test 6: Concurrent Mixed Requests + */ + async testConcurrentMixedRequests() { + console.log(" Testing concurrent mixed requests..."); + + const promises = []; + const codes = Object.values(this.testCodes); + + // Mix of different request types + for (let i = 0; i < 15; i++) { + const code = codes[i % codes.length]; + const data = i % 3 === 0 ? new Uint8Array([i]) : null; + + promises.push(this.msp.promise(code, data).catch((err) => ({ error: err.message }))); + } + + const startTime = performance.now(); + const results = await Promise.allSettled(promises); + const totalTime = performance.now() - startTime; + + const successful = results.filter((r) => r.status === "fulfilled" && !(r.value && r.value.error)).length; + + return { + totalRequests: promises.length, + successful, + failed: promises.length - successful, + totalTime, + concurrentProcessing: true, + }; + } + + /** + * Test 7: Queue Overflow Handling + */ + async testQueueOverflow() { + console.log(" Testing queue overflow handling..."); + + const maxQueue = this.msp.MAX_QUEUE_SIZE || 100; + const overflowCount = maxQueue + 10; + + const promises = []; + for (let i = 0; i < overflowCount; i++) { + promises.push(this.msp.promise(this.testCodes.MSP_STATUS, null).catch((err) => ({ error: err.message }))); + } + + const results = await Promise.allSettled(promises); + const rejected = results.filter((r) => r.status === "rejected" || r.value?.error).length; + + return { + attemptedRequests: overflowCount, + maxQueueSize: maxQueue, + rejectedDueToOverflow: rejected, + overflowHandled: rejected > 0, + finalQueueSize: this.msp.callbacks.length, + }; + } + + /** + * Test 8: Connection Disruption Simulation + */ + async testConnectionDisruption() { + console.log(" Simulating connection disruption..."); + + // This test would need to work with the actual serial implementation + // For now, we'll simulate by temporarily breaking the connection + + const originalConnected = this.msp.serial?.connected; + + try { + // Simulate disconnection + if (this.msp.serial) { + this.msp.serial.connected = false; + } + + // Try to send requests while "disconnected" + const promises = []; + for (let i = 0; i < 5; i++) { + promises.push( + this.msp.promise(this.testCodes.MSP_STATUS, null).catch((err) => ({ error: err.message })), + ); + } + + const disconnectedResults = await Promise.allSettled(promises); + const failedWhileDisconnected = disconnectedResults.filter( + (r) => r.status === "rejected" || r.value?.error, + ).length; + + // Restore connection + if (this.msp.serial) { + this.msp.serial.connected = originalConnected; + } + + // Test recovery + await this.wait(100); + const recoveryResult = await this.msp + .promise(this.testCodes.MSP_IDENT, null) + .catch((err) => ({ error: err.message })); + + return { + failedWhileDisconnected, + recoverySuccessful: !recoveryResult.error, + connectionHandled: failedWhileDisconnected > 0, + }; + } finally { + // Ensure connection is restored + if (this.msp.serial) { + this.msp.serial.connected = originalConnected; + } + } + } + + /** + * Test 9: Performance Under Load + */ + async testPerformanceUnderLoad() { + console.log(" Testing performance under sustained load..."); + + const duration = 5000; // 5 seconds + const requestInterval = 20; // Request every 20ms for higher concurrency + const batchSize = 10; // Process batches of 10 concurrent requests + const batchInterval = 200; // Process batches every 200ms + + const startTime = performance.now(); + const results = []; + let requestCount = 0; + let pendingPromises = []; + let lastBatchTime = startTime; + + while (performance.now() - startTime < duration) { + const requestStart = performance.now(); + requestCount++; + + // Create promise without awaiting to allow concurrency + const promise = this.msp + .promise(this.testCodes.MSP_STATUS, null) + .then(() => ({ + success: true, + responseTime: performance.now() - requestStart, + })) + .catch((error) => ({ + success: false, + responseTime: performance.now() - requestStart, + error: error.message, + })); + + pendingPromises.push(promise); + + // Process batch when we hit batch size or time interval + const now = performance.now(); + if (pendingPromises.length >= batchSize || now - lastBatchTime >= batchInterval) { + const batchResults = await Promise.allSettled(pendingPromises); + + // Extract results from settled promises + batchResults.forEach((settled) => { + if (settled.status === "fulfilled") { + results.push(settled.value); + } else { + results.push({ + success: false, + responseTime: 0, + error: settled.reason?.message || "Unknown error", + }); + } + }); + + pendingPromises = []; + lastBatchTime = now; + } + + await this.wait(requestInterval); + } + + // Process any remaining pending promises + if (pendingPromises.length > 0) { + const finalBatchResults = await Promise.allSettled(pendingPromises); + finalBatchResults.forEach((settled) => { + if (settled.status === "fulfilled") { + results.push(settled.value); + } else { + results.push({ + success: false, + responseTime: 0, + error: settled.reason?.message || "Unknown error", + }); + } + }); + } + + const successful = results.filter((r) => r.success).length; + const responseTimes = results.map((r) => r.responseTime).filter((t) => t > 0); + const avgResponseTime = + responseTimes.length > 0 ? responseTimes.reduce((sum, r) => sum + r, 0) / responseTimes.length : 0; + const maxResponseTime = responseTimes.length > 0 ? Math.max(...responseTimes) : 0; + + return { + duration, + requestCount, + successful, + failed: requestCount - successful, + successRate: successful / requestCount, + avgResponseTime, + maxResponseTime, + throughput: requestCount / (duration / 1000), + concurrentRequests: true, + batchSize, + maxConcurrentRequests: batchSize, + }; + } + + /** + * Generate comprehensive test report + */ + generateTestReport(results) { + const totalTests = results.length; + const passedTests = results.filter((r) => r.status === "PASSED").length; + const failedTests = totalTests - passedTests; + + const summary = { + totalTests, + passed: passedTests, + failed: failedTests, + successRate: passedTests / totalTests, + overallGrade: this._calculateOverallGrade(results), + }; + + const recommendations = this._generateTestRecommendations(results); + + return { + timestamp: new Date().toISOString(), + summary, + recommendations, + detailedResults: results, + monitorReport: this.monitor.generateReport(), + }; + } + + /** + * Calculate overall test grade + */ + _calculateOverallGrade(results) { + const passRate = results.filter((r) => r.status === "PASSED").length / results.length; + + if (passRate >= 0.95) { + return "A+"; + } + if (passRate >= 0.9) { + return "A"; + } + if (passRate >= 0.85) { + return "B+"; + } + if (passRate >= 0.8) { + return "B"; + } + if (passRate >= 0.75) { + return "C+"; + } + if (passRate >= 0.7) { + return "C"; + } + if (passRate >= 0.6) { + return "D"; + } + return "F"; + } + + /** + * Generate recommendations based on test results + */ + _generateTestRecommendations(results) { + const recommendations = []; + + // Check for specific test failures + const failedTests = results.filter((r) => r.status === "FAILED"); + if (failedTests.length > 0) { + recommendations.push( + `${failedTests.length} tests failed. Review implementation for: ${failedTests.map((t) => t.name).join(", ")}`, + ); + } + + // Check performance issues + const perfTest = results.find((r) => r.name === "Performance Under Load"); + if (perfTest?.result?.avgResponseTime > 1000) { + recommendations.push("Average response time is high. Consider optimizing MSP request handling."); + } + + // Check memory leaks + const memTest = results.find((r) => r.name === "Memory Leak Detection"); + if (memTest?.result?.memoryLeakDetected) { + recommendations.push("Memory leak detected. Ensure all callbacks are properly cleaned up."); + } + + // Check queue overflow handling + const overflowTest = results.find((r) => r.name === "Queue Overflow Handling"); + if (!overflowTest?.result?.overflowHandled) { + recommendations.push("Queue overflow not properly handled. Implement proper queue management."); + } + + return recommendations; + } + + /** + * Utility: Wait for specified milliseconds + */ + wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Run a specific test by name + */ + async runSpecificTest(testName) { + const testMethods = { + "queue-flooding": () => this.testQueueFlooding(), + "rapid-fire": () => this.testRapidFireRequests(), + duplicates: () => this.testDuplicateRequests(), + "timeout-recovery": () => this.testTimeoutRecovery(), + "memory-leaks": () => this.testMemoryLeaks(), + "concurrent-mixed": () => this.testConcurrentMixedRequests(), + "queue-overflow": () => this.testQueueOverflow(), + "connection-disruption": () => this.testConnectionDisruption(), + "performance-load": () => this.testPerformanceUnderLoad(), + }; + + const testMethod = testMethods[testName]; + if (!testMethod) { + throw new Error(`Unknown test: ${testName}`); + } + + console.log(`πŸ§ͺ Running specific test: ${testName}`); + this.monitor.startMonitoring(100); + this.monitor.resetAll(); // Reset both metrics and alerts for clean test start + + try { + const result = await testMethod(); + return { + name: testName, + status: "PASSED", + result, + metrics: this.monitor.getStatus(), + }; + } catch (error) { + return { + name: testName, + status: "FAILED", + error: error.message, + }; + } finally { + this.monitor.stopMonitoring(); + } + } + + /** + * Cleanup + */ + destroy() { + // Only stop monitoring, don't destroy the shared singleton + if (this.monitor.isMonitoring) { + this.monitor.stopMonitoring(); + } + } +} + +// Lazy initialization to avoid errors when window.MSP is not yet available +let _mspStressTestInstance = null; + +export const mspStressTest = { + get instance() { + if (!_mspStressTestInstance) { + if (typeof window === "undefined" || !window.MSP) { + throw new Error( + "MSP Stress Test: window.MSP is not available. Make sure MSP is loaded before using the stress test.", + ); + } + _mspStressTestInstance = new MSPStressTest(window.MSP); + } + return _mspStressTestInstance; + }, + + // Proxy all methods to the lazy-initialized instance + runStressTestSuite(...args) { + return this.instance.runStressTestSuite(...args); + }, + runSpecificTest(...args) { + return this.instance.runSpecificTest(...args); + }, + generateTestReport(...args) { + return this.instance.generateTestReport(...args); + }, + wait(...args) { + return this.instance.wait(...args); + }, + destroy(...args) { + return this.instance.destroy(...args); + }, + + // Test methods + testQueueFlooding(...args) { + return this.instance.testQueueFlooding(...args); + }, + testRapidFireRequests(...args) { + return this.instance.testRapidFireRequests(...args); + }, + testDuplicateRequests(...args) { + return this.instance.testDuplicateRequests(...args); + }, + testTimeoutRecovery(...args) { + return this.instance.testTimeoutRecovery(...args); + }, + testMemoryLeaks(...args) { + return this.instance.testMemoryLeaks(...args); + }, + testConcurrentMixedRequests(...args) { + return this.instance.testConcurrentMixedRequests(...args); + }, + testQueueOverflow(...args) { + return this.instance.testQueueOverflow(...args); + }, + testConnectionDisruption(...args) { + return this.instance.testConnectionDisruption(...args); + }, + testPerformanceUnderLoad(...args) { + return this.instance.testPerformanceUnderLoad(...args); + }, + + // Getters for properties + get monitor() { + return this.instance.monitor; + }, + get isRunning() { + return this.instance.isRunning; + }, + get testResults() { + return this.instance.testResults; + }, + get currentTest() { + return this.instance.currentTest; + }, + get testCodes() { + return this.instance.testCodes; + }, +}; diff --git a/src/js/msp/msp_test_runner.js b/src/js/msp/msp_test_runner.js new file mode 100644 index 00000000..f54563af --- /dev/null +++ b/src/js/msp/msp_test_runner.js @@ -0,0 +1,437 @@ +/** + * MSP Test Runner - Simple script to run tests and monitoring + * Usage examples from browser console: + * + * // Quick start monitoring + * MSPTestRunner.startQuickMonitor(); + * + * // Run specific test + * MSPTestRunner.runTest('queue-flooding'); + * + * // Run full stress test suite + * MSPTestRunner.runFullSuite(); + * + * // Get current status + * MSPTestRunner.getStatus(); + */ + +import { mspQueueMonitor } from "./msp_queue_monitor.js"; +import { mspStressTest } from "./msp_stress_test.js"; +import { mspDebugDashboard } from "./msp_debug_dashboard.js"; + +export const MSPTestRunner = { + // Store the listener function so it can be removed later + _quickMonitorListener: null, + + /** + * Start quick monitoring with console output + */ + startQuickMonitor() { + console.log("πŸš€ Starting MSP Quick Monitor..."); + + // Remove any existing listener first + if (this._quickMonitorListener) { + mspQueueMonitor.removeListener(this._quickMonitorListener); + } + + // Define the listener function so it can be referenced for removal + this._quickMonitorListener = (status) => { + if (status.alerts && Object.values(status.alerts).some((alert) => alert)) { + console.warn("🚨 MSP Alert:", status.alerts); + } + + // Log every 10 seconds if monitoring + if (Date.now() % 10000 < 500) { + console.log( + `πŸ“Š MSP Status: Queue=${status.currentQueueSize}/${status.maxQueueSize}, Requests=${status.metrics.totalRequests}, AvgTime=${Math.round(status.metrics.avgResponseTime)}ms`, + ); + } + }; + + mspQueueMonitor.addListener(this._quickMonitorListener); + mspQueueMonitor.startMonitoring(1000); + console.log("βœ… Quick monitor started. Use MSPTestRunner.stopMonitor() to stop."); + + return { + stop: () => this.stopMonitor(), + status: () => this.getStatus(), + analyze: () => this.analyzeQueue(), + }; + }, + + /** + * Stop monitoring + */ + stopMonitor() { + mspQueueMonitor.stopMonitoring(); + + // Remove the listener to prevent duplicate logs + if (this._quickMonitorListener) { + mspQueueMonitor.removeListener(this._quickMonitorListener); + this._quickMonitorListener = null; + } + + console.log("⏹️ MSP Monitor stopped"); + }, + + /** + * Run a specific stress test + */ + async runTest(testName) { + console.log(`πŸ§ͺ Running MSP test: ${testName}`); + + try { + const result = await mspStressTest.runSpecificTest(testName); + + if (result.status === "PASSED") { + console.log(`βœ… Test ${testName} PASSED`); + console.table(result.result); + } else { + console.error(`❌ Test ${testName} FAILED:`, result.error); + } + + return result; + } catch (error) { + console.error(`πŸ’₯ Test ${testName} crashed:`, error); + return { status: "ERROR", error: error.message }; + } + }, + + /** + * Run the full stress test suite + */ + async runFullSuite() { + console.log("πŸš€ Running FULL MSP Stress Test Suite..."); + console.log("This may take several minutes and will stress the MSP system."); + + const startTime = Date.now(); + + try { + const results = await mspStressTest.runStressTestSuite(); + const duration = Date.now() - startTime; + + console.log(`\nπŸ“Š Test Suite Complete (${Math.round(duration / 1000)}s)`); + console.log(`βœ… Passed: ${results.summary.passed}`); + console.log(`❌ Failed: ${results.summary.failed}`); + console.log(`πŸ“ˆ Success Rate: ${Math.round(results.summary.successRate * 100)}%`); + console.log(`🎯 Overall Grade: ${results.summary.overallGrade}`); + + if (results.recommendations && results.recommendations.length > 0) { + console.log("\nπŸ’‘ Recommendations:"); + results.recommendations.forEach((rec) => console.log(` β€’ ${rec}`)); + } + + // Show detailed results table + console.log("\nπŸ“‹ Detailed Results:"); + console.table( + results.detailedResults.map((test) => ({ + Test: test.name, + Status: test.status, + Duration: test.duration ? `${Math.round(test.duration)}ms` : "N/A", + })), + ); + + return results; + } catch (error) { + console.error("πŸ’₯ Test Suite Failed:", error); + return { error: error.message }; + } + }, + + /** + * Get current MSP status + */ + getStatus() { + const status = mspQueueMonitor.getStatus(); + + console.log("πŸ“Š Current MSP Status:"); + console.log(` Queue: ${status.currentQueueSize}/${status.maxQueueSize}`); + console.log(` Total Requests: ${status.metrics.totalRequests}`); + console.log(` Success Rate: ${Math.round((status.metrics.successRate || 0) * 100)}%`); + console.log(` Avg Response Time: ${Math.round(status.metrics.avgResponseTime || 0)}ms`); + console.log(` Active Alerts: ${Object.values(status.alerts).filter((a) => a).length}`); + + if (status.queueContents.length > 0) { + console.log("\nπŸ“‹ Queue Contents:"); + console.table(status.queueContents); + } + + return status; + }, + + /** + * Analyze current queue + */ + analyzeQueue() { + const analysis = mspQueueMonitor.analyzeQueue(); + + console.log("πŸ” Queue Analysis:"); + console.log(` Total Items: ${analysis.totalItems}`); + console.log(" Age Distribution:", analysis.ageDistribution); + console.log(" By Code:", analysis.byCode); + + if (analysis.potentialIssues.length > 0) { + console.log("⚠️ Potential Issues:"); + analysis.potentialIssues.forEach((issue) => console.log(` β€’ ${issue}`)); + } + + return analysis; + }, + + /** + * Generate and download comprehensive report + */ + generateReport() { + const report = mspQueueMonitor.generateReport(); + + console.log("πŸ“„ Generating MSP Report..."); + console.log(" Queue Health:", report.summary.queueHealth); + console.log(" Performance Grade:", report.summary.performanceGrade); + + // Create downloadable report + const blob = new Blob([JSON.stringify(report, null, 2)], { + type: "application/json", + }); + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `msp-report-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.log("βœ… Report downloaded"); + return report; + }, + + /** + * Show the visual dashboard + */ + showDashboard() { + mspDebugDashboard.show(); + console.log("πŸ–₯️ Debug dashboard opened. Press Ctrl+Shift+M to toggle."); + }, + + /** + * Run a quick health check + */ + async quickHealthCheck() { + console.log("πŸ₯ Running Quick MSP Health Check..."); + + if (!window.MSP) { + console.error("MSP not available"); + return { status: "ERROR", error: "MSP not initialized" }; + } + + // Start monitoring briefly + mspQueueMonitor.startMonitoring(100); + + // Send a few test requests + const testPromises = [ + window.MSP.promise(100, null), // MSP_IDENT + window.MSP.promise(101, null), // MSP_STATUS + window.MSP.promise(108, null), // MSP_ATTITUDE + ]; + + try { + const startTime = Date.now(); + await Promise.all(testPromises); + const responseTime = Date.now() - startTime; + + // Get status after test + await new Promise((resolve) => setTimeout(resolve, 200)); + const status = mspQueueMonitor.getStatus(); + + mspQueueMonitor.stopMonitoring(); + + const health = { + status: "HEALTHY", + responseTime, + queueClearedAfterTest: status.currentQueueSize === 0, + successRate: status.metrics.successRate || 0, + }; + + if (responseTime > 2000) { + health.status = "SLOW"; + health.warning = "Response times are slow"; + } + + if (!health.queueClearedAfterTest) { + health.status = "WARNING"; + health.warning = "Queue not properly cleared after requests"; + } + + if (health.successRate < 1) { + health.status = "FAILING"; + health.warning = "Some requests are failing"; + } + + console.log(`πŸ₯ Health Check Result: ${health.status}`); + console.log(` Response Time: ${responseTime}ms`); + console.log(` Queue Cleared: ${health.queueClearedAfterTest ? "βœ…" : "❌"}`); + console.log(` Success Rate: ${Math.round(health.successRate * 100)}%`); + + if (health.warning) { + console.warn(`⚠️ ${health.warning}`); + } + + return health; + } catch (error) { + mspQueueMonitor.stopMonitoring(); + console.error("πŸ’₯ Health check failed:", error); + return { status: "ERROR", error: error.message }; + } + }, + + /** + * Stress test a specific scenario + */ + async stressScenario(scenario) { + const scenarios = { + "high-frequency": async () => { + console.log("πŸ”₯ High Frequency Scenario: Sending requests every 10ms for 5 seconds"); + const promises = []; + const startTime = Date.now(); + + while (Date.now() - startTime < 5000) { + promises.push( + window.MSP.promise(101, null).catch((err) => { + console.error("MSP request failed in sustained-load scenario:", err); + return { error: err.message || "Unknown error" }; + }), + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + const results = await Promise.allSettled(promises); + return { + totalRequests: promises.length, + successful: results.filter((r) => r.status === "fulfilled").length, + duration: Date.now() - startTime, + }; + }, + + "queue-overflow": async () => { + console.log("πŸ’₯ Queue Overflow Scenario: Flooding queue beyond capacity"); + const promises = []; + + // Send more requests than queue can handle + for (let i = 0; i < 100; i++) { + promises.push(window.MSP.promise(101, null).catch((err) => ({ error: err.message }))); + } + + const results = await Promise.allSettled(promises); + const successful = results.filter((r) => r.status === "fulfilled" && !r.value.error).length; + + return { + requestsSent: 100, + successful, + rejected: 100 - successful, + }; + }, + + "mixed-load": async () => { + console.log("🎭 Mixed Load Scenario: Various request types and sizes"); + const codes = [100, 101, 102, 104, 108, 110, 111, 112]; + const promises = []; + + for (let i = 0; i < 30; i++) { + const code = codes[i % codes.length]; + const data = i % 4 === 0 ? new Uint8Array([i, i + 1, i + 2]) : null; + promises.push( + window.MSP.promise(code, data).catch((err) => { + console.error(`MSP request failed in mixed-load scenario (code: ${code}):`, err); + return { error: err.message || "Unknown error" }; + }), + ); + } + + const startTime = Date.now(); + const results = await Promise.allSettled(promises); + const duration = Date.now() - startTime; + + return { + totalRequests: 30, + successful: results.filter((r) => r.status === "fulfilled").length, + duration, + avgResponseTime: duration / 30, + }; + }, + }; + + const scenarioFn = scenarios[scenario]; + if (!scenarioFn) { + console.error(`❌ Unknown scenario: ${scenario}`); + console.log("Available scenarios:", Object.keys(scenarios)); + return; + } + + mspQueueMonitor.startMonitoring(100); + + try { + const result = await scenarioFn(); + const status = mspQueueMonitor.getStatus(); + + console.log("πŸ“Š Scenario Results:"); + console.table(result); + console.log("πŸ“ˆ Final MSP Status:"); + console.table({ + "Queue Size": status.currentQueueSize, + "Total Requests": status.metrics.totalRequests, + "Success Rate": `${Math.round((status.metrics.successRate || 0) * 100)}%`, + "Avg Response": `${Math.round(status.metrics.avgResponseTime || 0)}ms`, + }); + + return { scenario: result, mspStatus: status }; + } catch (error) { + console.error("πŸ’₯ Scenario failed:", error); + return { error: error.message }; + } finally { + mspQueueMonitor.stopMonitoring(); + } + }, + + /** + * List available commands + */ + help() { + console.log(` +πŸ”§ MSP Test Runner Commands: + +Basic Monitoring: + MSPTestRunner.startQuickMonitor() - Start monitoring with console output + MSPTestRunner.stopMonitor() - Stop monitoring + MSPTestRunner.getStatus() - Get current status + MSPTestRunner.analyzeQueue() - Analyze current queue + +Testing: + MSPTestRunner.runTest('test-name') - Run specific test + MSPTestRunner.runFullSuite() - Run full stress test suite + MSPTestRunner.quickHealthCheck() - Quick health check + +Stress Scenarios: + MSPTestRunner.stressScenario('high-frequency') - High frequency requests + MSPTestRunner.stressScenario('queue-overflow') - Queue overflow test + MSPTestRunner.stressScenario('mixed-load') - Mixed request types + +Visual Tools: + MSPTestRunner.showDashboard() - Show visual dashboard + MSPTestRunner.generateReport() - Generate and download report + +Available Test Names: + 'queue-flooding', 'rapid-fire', 'duplicates', 'timeout-recovery', + 'memory-leaks', 'concurrent-mixed', 'queue-overflow', + 'connection-disruption', 'performance-load' + +Keyboard Shortcuts: + Ctrl+Shift+M - Toggle debug dashboard + `); + }, +}; + +// Make globally available +window.MSPTestRunner = MSPTestRunner; + +console.log("πŸ”§ MSP Test Runner loaded! Type MSPTestRunner.help() for commands."); diff --git a/src/js/utils/checkBrowserCompatibility.js b/src/js/utils/checkBrowserCompatibility.js index a1a34411..da3e0fdc 100644 --- a/src/js/utils/checkBrowserCompatibility.js +++ b/src/js/utils/checkBrowserCompatibility.js @@ -68,7 +68,11 @@ export function checkBrowserCompatibility() { const isNative = Capacitor.isNativePlatform(); - const compatible = isNative || (isChromium && (isWebSerial || isWebBluetooth || isWebUSB)); + // Check if running in a test environment + const isTestEnvironment = + typeof process !== "undefined" && (process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID !== undefined); + + const compatible = isTestEnvironment || isNative || (isChromium && (isWebSerial || isWebBluetooth || isWebUSB)); console.log("User Agent: ", navigator.userAgentData); console.log("Native: ", isNative); diff --git a/test/js/msp/MSPHelper.test.js b/test/js/msp/MSPHelper.test.js index ce439a47..01579bb4 100644 --- a/test/js/msp/MSPHelper.test.js +++ b/test/js/msp/MSPHelper.test.js @@ -3,7 +3,7 @@ import MspHelper from "../../../src/js/msp/MSPHelper"; import MSPCodes from "../../../src/js/msp/MSPCodes"; import "../../../src/js/injected_methods"; import FC from "../../../src/js/fc"; -import { API_VERSION_1_46 } from "../../../src/js/data_storage"; +import { API_VERSION_1_47 } from "../../../src/js/data_storage"; describe("MspHelper", () => { const mspHelper = new MspHelper(); @@ -79,7 +79,7 @@ describe("MspHelper", () => { expect(FC.MOTOR_DATA.slice(motorCount, 8)).toContain(undefined); }); it("handles MSP_BOARD_INFO correctly for API version", () => { - FC.CONFIG.apiVersion = API_VERSION_1_46; + FC.CONFIG.apiVersion = API_VERSION_1_47; let infoBuffer = []; const boardIdentifier = appendStringToArray(infoBuffer, generateRandomString(4)); // set board-identifier