/** * ESP32 WiFi + WebServer integration test * * Tests the full pipeline for the user's specific sketch: * 1. Sketch structure validation (includes, SSID, server setup) * 2. WiFi auto-detection triggers wifi_enabled flag * 3. Esp32Bridge sends correct start_esp32 payload * 4. WiFi status events flow through correctly * 5. Serial output parsing for connection messages * 6. HTTP server detection from serial output */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // ── Mocks ──────────────────────────────────────────────────────────────────── vi.mock('../simulation/AVRSimulator', () => ({ AVRSimulator: vi.fn(function (this: any) { this.onSerialData = null; this.onBaudRateChange = null; this.onPinChangeWithTime = null; this.start = vi.fn(); this.stop = vi.fn(); this.reset = vi.fn(); this.loadHex = vi.fn(); this.addI2CDevice = vi.fn(); this.setPinState = vi.fn(); }), })); vi.mock('../simulation/RP2040Simulator', () => ({ RP2040Simulator: vi.fn(function (this: any) { this.onSerialData = null; this.onPinChangeWithTime = null; this.start = vi.fn(); this.stop = vi.fn(); this.reset = vi.fn(); this.loadBinary = vi.fn(); this.addI2CDevice = vi.fn(); this.attachCyw43 = vi.fn(); this.spi = { onByte: null, completeTransfer: vi.fn() }; }), })); vi.mock('../simulation/PinManager', () => ({ PinManager: vi.fn(function (this: any) { this.updatePort = vi.fn(); this.onPinChange = vi.fn().mockReturnValue(() => {}); this.getListenersCount = vi.fn().mockReturnValue(0); this.hardResetPinStates = vi.fn(); }), })); vi.mock('../simulation/I2CBusManager', async () => { const actual = await vi.importActual( '../simulation/I2CBusManager', ); return actual; }); vi.mock('../store/useOscilloscopeStore', () => ({ useOscilloscopeStore: { getState: vi.fn().mockReturnValue({ channels: [], pushSample: vi.fn() }), }, })); // WebSocket mock class MockWebSocket { static OPEN = 1; static CLOSING = 2; static CLOSED = 3; readyState = MockWebSocket.OPEN; onopen: (() => void) | null = null; onmessage: ((e: { data: string }) => void) | null = null; onclose: (() => void) | null = null; onerror: (() => void) | null = null; sent: string[] = []; send(data: string) { this.sent.push(data); } close() { this.readyState = MockWebSocket.CLOSED; this.onclose?.(); } open() { this.readyState = MockWebSocket.OPEN; this.onopen?.(); } receive(payload: object) { this.onmessage?.({ data: JSON.stringify(payload) }); } } vi.stubGlobal('WebSocket', MockWebSocket); vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1); vi.stubGlobal('cancelAnimationFrame', vi.fn()); // ── Imports (after mocks) ──────────────────────────────────────────────────── import { Esp32Bridge } from '../simulation/Esp32Bridge'; import type { WifiStatus } from '../simulation/Esp32Bridge'; // ── The user's exact sketch ────────────────────────────────────────────────── const WEBSERVER_SKETCH = `#include #include const char* ssid = "Velxio-GUEST"; const char* password = ""; WebServer server(80); void handleRoot() { server.send(200, "text/html", "

Hola desde ESP32 🚀

"); } void setup() { Serial.begin(115200); WiFi.begin(ssid, password); Serial.print("Conectando"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\\nConectado!"); Serial.print("IP: "); Serial.println(WiFi.localIP()); server.on("/", handleRoot); server.begin(); Serial.println("Servidor HTTP iniciado"); } void loop() { server.handleClient(); } `; // ── Simulated serial output (what QEMU would produce) ──────────────────────── const EXPECTED_SERIAL_OUTPUT = [ 'I (432) wifi:wifi sta start', 'I (500) wifi:new:Velxio-GUEST, old: , ASSOC', 'I (800) wifi:connected with Velxio-GUEST, aid = 1, channel 6', 'I (1200) esp_netif_handlers: sta ip: 192.168.4.2, mask: 255.255.255.0', 'Conectando', '...', 'Conectado!', 'IP: 192.168.4.2', 'Servidor HTTP iniciado', ]; // ───────────────────────────────────────────────────────────────────────────── // 1. Sketch structure validation // ───────────────────────────────────────────────────────────────────────────── describe('WebServer sketch — structure validation', () => { it('includes WiFi.h and WebServer.h headers', () => { expect(WEBSERVER_SKETCH).toContain('#include '); expect(WEBSERVER_SKETCH).toContain('#include '); }); it('uses Velxio-GUEST SSID with empty password', () => { expect(WEBSERVER_SKETCH).toContain('"Velxio-GUEST"'); expect(WEBSERVER_SKETCH).toMatch(/password\s*=\s*""/); }); it('creates WebServer on port 80', () => { expect(WEBSERVER_SKETCH).toContain('WebServer server(80)'); }); it('calls WiFi.begin() with ssid and password', () => { expect(WEBSERVER_SKETCH).toContain('WiFi.begin(ssid, password)'); }); it('registers root handler and starts server', () => { expect(WEBSERVER_SKETCH).toContain('server.on("/", handleRoot)'); expect(WEBSERVER_SKETCH).toContain('server.begin()'); }); it('calls server.handleClient() in loop', () => { expect(WEBSERVER_SKETCH).toContain('server.handleClient()'); }); it('has handleRoot function sending HTML response', () => { expect(WEBSERVER_SKETCH).toContain('void handleRoot()'); expect(WEBSERVER_SKETCH).toContain('server.send(200, "text/html"'); expect(WEBSERVER_SKETCH).toContain('Hola desde ESP32'); }); it('uses Serial.begin(115200) for debugging', () => { expect(WEBSERVER_SKETCH).toContain('Serial.begin(115200)'); }); it('prints connection status messages in Spanish', () => { expect(WEBSERVER_SKETCH).toContain('Conectando'); expect(WEBSERVER_SKETCH).toContain('Conectado!'); expect(WEBSERVER_SKETCH).toContain('Servidor HTTP iniciado'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 2. WiFi auto-detection from sketch content // ───────────────────────────────────────────────────────────────────────────── describe('WebServer sketch — WiFi auto-detection', () => { const detectWifi = (content: string) => content.includes('#include ') || content.includes('#include ') || content.includes('#include "WiFi.h"') || content.includes('WiFi.begin('); it('auto-detects WiFi usage in the WebServer sketch', () => { expect(detectWifi(WEBSERVER_SKETCH)).toBe(true); }); it('detects via #include ', () => { expect(WEBSERVER_SKETCH).toContain('#include '); }); it('detects via WiFi.begin() call', () => { expect(WEBSERVER_SKETCH).toContain('WiFi.begin('); }); }); // ───────────────────────────────────────────────────────────────────────────── // 3. Esp32Bridge — sends wifi_enabled=true for this sketch // ───────────────────────────────────────────────────────────────────────────── describe('WebServer sketch — Esp32Bridge WiFi payload', () => { let bridge: Esp32Bridge; let ws: MockWebSocket; afterEach(() => { bridge.disconnect(); }); it('sends wifi_enabled=true when sketch contains WiFi includes', () => { bridge = new Esp32Bridge('board-1', 'esp32'); bridge.wifiEnabled = true; // auto-detected from sketch bridge.connect(); ws = (bridge as any).socket as MockWebSocket; ws.open(); const msg = JSON.parse(ws.sent[0]); expect(msg.type).toBe('start_esp32'); expect(msg.data.wifi_enabled).toBe(true); expect(msg.data.board).toBe('esp32'); }); it('uses esp32:esp32:esp32 FQBN for compilation', () => { const FQBN_MAP: Record = { esp32: 'esp32:esp32:esp32', 'esp32-s3': 'esp32:esp32:esp32s3', 'esp32-c3': 'esp32:esp32:esp32c3', }; expect(FQBN_MAP['esp32']).toBe('esp32:esp32:esp32'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 4. WiFi status event flow — simulated QEMU serial output // ───────────────────────────────────────────────────────────────────────────── describe('WebServer sketch — WiFi connection lifecycle', () => { let bridge: Esp32Bridge; let ws: MockWebSocket; beforeEach(() => { bridge = new Esp32Bridge('board-1', 'esp32'); bridge.wifiEnabled = true; bridge.connect(); ws = (bridge as any).socket as MockWebSocket; ws.open(); }); afterEach(() => { bridge.disconnect(); }); it('receives initializing → connected → got_ip status sequence', () => { const statuses: WifiStatus[] = []; bridge.onWifiStatus = (s) => statuses.push(s); // Simulate QEMU WiFi status events from serial parsing ws.receive({ type: 'wifi_status', data: { status: 'initializing' } }); ws.receive({ type: 'wifi_status', data: { status: 'connected', ssid: 'Velxio-GUEST' } }); ws.receive({ type: 'wifi_status', data: { status: 'got_ip', ssid: 'Velxio-GUEST', ip: '192.168.4.2' }, }); expect(statuses).toHaveLength(3); expect(statuses[0].status).toBe('initializing'); expect(statuses[1].status).toBe('connected'); expect(statuses[1].ssid).toBe('Velxio-GUEST'); expect(statuses[2].status).toBe('got_ip'); expect(statuses[2].ip).toBe('192.168.4.2'); }); it('assigns IP in 192.168.4.x range (QEMU slirp default)', () => { const statuses: WifiStatus[] = []; bridge.onWifiStatus = (s) => statuses.push(s); ws.receive({ type: 'wifi_status', data: { status: 'got_ip', ssid: 'Velxio-GUEST', ip: '192.168.4.2' }, }); expect(statuses[0].ip).toMatch(/^192\.168\.4\.\d+$/); }); it('connects to Velxio-GUEST SSID (channel 6, open, no password)', () => { const statuses: WifiStatus[] = []; bridge.onWifiStatus = (s) => statuses.push(s); ws.receive({ type: 'wifi_status', data: { status: 'connected', ssid: 'Velxio-GUEST' }, }); expect(statuses[0].ssid).toBe('Velxio-GUEST'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 5. Serial output parsing — expected messages from the sketch // ───────────────────────────────────────────────────────────────────────────── describe('WebServer sketch — serial output verification', () => { it('serial output contains WiFi STA start log', () => { const output = EXPECTED_SERIAL_OUTPUT.join('\n'); expect(output).toContain('wifi sta start'); }); it('serial output contains connection to Velxio-GUEST', () => { const output = EXPECTED_SERIAL_OUTPUT.join('\n'); expect(output).toContain('Velxio-GUEST'); }); it('serial output contains assigned IP address', () => { const output = EXPECTED_SERIAL_OUTPUT.join('\n'); expect(output).toMatch(/sta ip: 192\.168\.4\.\d+/); }); it('serial output contains "Conectado!" message from sketch', () => { const output = EXPECTED_SERIAL_OUTPUT.join('\n'); expect(output).toContain('Conectado!'); }); it('serial output contains "Servidor HTTP iniciado"', () => { const output = EXPECTED_SERIAL_OUTPUT.join('\n'); expect(output).toContain('Servidor HTTP iniciado'); }); it('serial output contains IP address line', () => { const output = EXPECTED_SERIAL_OUTPUT.join('\n'); expect(output).toContain('IP: 192.168.4.2'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 6. HTTP server detection from serial output // ───────────────────────────────────────────────────────────────────────────── describe('WebServer sketch — HTTP server detection', () => { const detectHttpServer = (serialOutput: string): { detected: boolean; ip?: string } => { // Detect HTTP server from serial patterns like: // "Server at: X.X.X.X" or "Servidor HTTP iniciado" + "IP: X.X.X.X" const serverPatterns = [ /[Ss]erver\s+at:\s*([\d.]+)/, /[Ss]ervidor\s+HTTP\s+iniciado/, /server\.begin\(\)/, ]; const ipPattern = /IP:\s*([\d.]+)/; const hasServer = serverPatterns.some((p) => p.test(serialOutput)); const ipMatch = serialOutput.match(ipPattern); return { detected: hasServer, ip: ipMatch ? ipMatch[1] : undefined, }; }; it('detects HTTP server from serial output', () => { const output = EXPECTED_SERIAL_OUTPUT.join('\n'); const result = detectHttpServer(output); expect(result.detected).toBe(true); }); it('extracts ESP32 IP address from serial output', () => { const output = EXPECTED_SERIAL_OUTPUT.join('\n'); const result = detectHttpServer(output); expect(result.ip).toBe('192.168.4.2'); }); it('does not detect server in non-server output', () => { const output = 'Hello World!\nJust blinking an LED\n'; const result = detectHttpServer(output); expect(result.detected).toBe(false); }); }); // ───────────────────────────────────────────────────────────────────────────── // 7. IoT Gateway URL construction // ───────────────────────────────────────────────────────────────────────────── describe('WebServer sketch — IoT Gateway URL', () => { it('constructs correct gateway URL for client', () => { const clientId = 'board-1'; const backendUrl = 'http://localhost:8001'; const gatewayUrl = `${backendUrl}/api/gateway/${clientId}/`; expect(gatewayUrl).toBe('http://localhost:8001/api/gateway/board-1/'); }); it('constructs gateway URL with subpath', () => { const clientId = 'board-1'; const backendUrl = 'http://localhost:8001'; const path = 'api/data'; const gatewayUrl = `${backendUrl}/api/gateway/${clientId}/${path}`; expect(gatewayUrl).toBe('http://localhost:8001/api/gateway/board-1/api/data'); }); it('root path maps to ESP32 handleRoot handler', () => { // The sketch registers: server.on("/", handleRoot) // Gateway URL /api/gateway/{client_id}/ → ESP32 port 80 / const clientId = 'board-1'; const hostfwdPort = 12345; const espUrl = `http://127.0.0.1:${hostfwdPort}/`; expect(espUrl).toContain(':12345/'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 8. QEMU NIC args for this sketch // ───────────────────────────────────────────────────────────────────────────── describe('WebServer sketch — QEMU NIC configuration', () => { it('builds correct -nic arg for ESP32 with WiFi', () => { const machine = 'esp32-picsimlab'; const wifiEnabled = true; const hostfwdPort = 54321; const nicModel = machine.includes('c3') ? 'esp32c3_wifi' : 'esp32_wifi'; let nicArg = `user,model=${nicModel},net=192.168.4.0/24`; if (hostfwdPort) { nicArg += `,hostfwd=tcp::${hostfwdPort}-192.168.4.2:80`; } expect(nicArg).toContain('model=esp32_wifi'); expect(nicArg).toContain('net=192.168.4.0/24'); expect(nicArg).toContain('hostfwd=tcp::54321-192.168.4.2:80'); }); it('uses port 80 for hostfwd (WebServer default)', () => { const hostfwdPort = 12345; const nicArg = `user,model=esp32_wifi,net=192.168.4.0/24,hostfwd=tcp::${hostfwdPort}-192.168.4.2:80`; // ESP32 WebServer listens on port 80, hostfwd maps external port to internal 80 expect(nicArg).toMatch(/hostfwd=tcp::\d+-192\.168\.4\.2:80/); }); });