velxio/frontend/src/__tests__/esp32-wifi-bluetooth.test.ts

432 lines
15 KiB
TypeScript
Raw Normal View History

/**
* ESP32 WiFi & Bluetooth emulation tests
*
* Covers:
* 1. Esp32Bridge — wifi_enabled flag in start_esp32 payload
* 2. Esp32Bridge — wifi_status / ble_status event dispatch
* 3. WiFi auto-detection from sketch #include directives
* 4. Store updates on WiFi/BLE status events
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
fix(micropython): stop shadowing the real WiFi driver when a NIC exists Reported in #262: on an emulated ESP32, MicroPython's WLAN.scan() always returned [], ifconfig() reported 10.0.2.15/10.0.2.2/10.0.2.3, and outbound sockets raised EHOSTUNREACH — while Arduino WiFi.h on the same board and container scanned, connected on 192.168.4.x and completed real HTTP requests. Two independent faults, both needed for the failure. 1. The MicroPython prelude injected a stub `network` module into sys.modules unconditionally, so user code never reached the firmware driver. The stub's hardcoded ifconfig() happens to be QEMU's SLIRP defaults, which is why the symptom read so convincingly as a routing bug. 2. hasWifi survived a language switch. It is a property of the compiled sketch, set only by arduino-cli, but setBoardLanguageMode kept it — so a board compiled once as Arduino carried that verdict into a later MicroPython run. The source-scanning fallback only fires when hasWifi is undefined, so it never got a chance, and the backend attaches the `-nic` device only when wifi_enabled is true. The stub is NOT removed, which is where this departs from the patch in the issue. It exists for a reason the report didn't have: without a NIC, `network.WLAN(STA_IF)` hangs forever waiting on peripheral status bits QEMU never sets, tripping the FreeRTOS watchdog after ~26s. Deleting it would trade a fake-but-working network for a hung board. It is now conditional — real driver when a NIC is attached, stub when none is — so both paths are honest. Also kept: the ntptime and urequests stubs, which the issue's diff would have dropped along with `network` (it removed the whole prelude block). Those back 29 call sites in the 100-days gallery examples, which have no emulated internet to reach regardless of the NIC. The detector is now one shared function instead of two copies that could disagree — the stub decision and the -nic decision MUST match. Folded in `from network import WLAN`, which matched neither previous pattern: such a sketch was judged WiFi-less and hung on the missing peripheral. The existing tests re-implemented the detection inline, so they only ever proved that copy self-consistent; the new ones call the shipped function.
2026-08-11 02:40:37 +07:00
import { sketchUsesWifi } from '../store/useSimulatorStore';
// ── 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();
feat(opencore): extract Pico W WiFi to a pluggable PIO peripheral seam Move the CYW43439 (Pico W) WiFi emulation out of the open-source tree so it can ship as a paid feature in a private overlay. OSS keeps a plain Pico W (no WiFi); the overlay registers the cyw43 protocol + backend network stack at runtime via generic seams. Frontend: - Add simulation/PioPeripheral.ts: a generic "PIO bus peripheral" seam (feedWord / inDiscardableWriteData / resetFraming / hostWakeLevel / onHostWake / onSimulationStart). No factory is installed in OSS, so createPioPeripheral() returns null and a Pico W simulates as a plain Pico. - RP2040Simulator: keep the fragile PIO-FIFO plumbing (it must re-run after loadMicroPython swaps the chip) but drive it through PioPeripheral instead of an inlined cyw43 import (attachCyw43 -> attachPioPeripheral, etc.). - useSimulatorStore: generic attach/detach + setBoardWifiStatus; drop the cyw43 bridge map. - MicroPythonLoader: add registerFirmwareVariant() so an overlay can add the RPI_PICO_W build; remove the OSS pico-w config + bundled .uf2. - Delete simulation/cyw43/ (moved to the overlay). Backend: - core/hooks.py: add generic register_ws_sim_handler / dispatch_ws_sim_message and register_gateway_proxy / dispatch_gateway_proxy seams. - simulation.py: route start_picow / stop_picow / picow_packet_out through the ws_sim_handler hook (the overlay handles + gates them). - iot_gateway.py: resolve the Pico W gateway through the gateway_proxy hook. - Delete services/picow_net/ + picow_net_bridge.py (moved to the overlay). Tests: move the cyw43/picow suites to the overlay; update RP2040Simulator mock stubs to attachPioPeripheral.
2026-06-15 13:33:28 +07:00
this.attachPioPeripheral = vi.fn();
fix(tests): restore Frontend Tests CI — patch stale RP2040 mocks + install-libraries CI's Frontend Tests workflow had been failing on master for ~15 runs. Two pre-existing issues, neither related to the SPI refactor in 8b1433d or the ESP32-CAM work: 1. RP2040Simulator mock missing attachCyw43 method (23 test files) PR #126 (8e769f8 "feat(multi-board): add wire-aware cross-board interconnect router", merged 2026-04-25) added a Pico-W-specific `sim.attachCyw43(bridge)` call inside addBoard(). The 23 test files that mock RP2040Simulator with vi.fn weren't updated; whenever a test path created a Pico W board the mock threw "TypeError: sim.attachCyw43 is not a function" and aborted addBoard. Fix: add `this.attachCyw43 = vi.fn()` to every affected mock. Also pre-populate `this.spi = { onByte: null, completeTransfer: vi.fn() }` so any future SPI-part tests don't trip on the new generic .spi adapter from 8b1433d. 2. install-libraries.test.ts payload mismatch PR #135 (b1026ec7 "library-version-uninstall", merged 2026-04-29) extended `installLibrary(name)` to `installLibrary(name, version?)` and now sends `{name, version: version ?? null}` over the wire. The test still asserted `{name}` only and failed. Fix: assert `{name, version: null}` for the no-version call. Verified locally: 1161 passed | 1 skipped (was 1117 passed | 44 failed). Backend E2E "Run HC-SR04 e2e test" is a separate failure that needs its own investigation — it downloads QEMU binaries from a release and runs real firmware compilation, which I can't reproduce on Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:46:32 +07:00
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<typeof import('../simulation/I2CBusManager')>(
'../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, BleStatus } from '../simulation/Esp32Bridge';
// ─────────────────────────────────────────────────────────────────────────────
// 1. Esp32Bridge — wifi_enabled in start_esp32 payload
// ─────────────────────────────────────────────────────────────────────────────
describe('Esp32Bridge — WiFi flag', () => {
let bridge: Esp32Bridge;
let ws: MockWebSocket;
beforeEach(() => {
bridge = new Esp32Bridge('board-1', 'esp32');
});
afterEach(() => {
bridge.disconnect();
});
it('sends wifi_enabled=false by default in start_esp32', () => {
bridge.connect();
ws = (bridge as any).socket as MockWebSocket;
ws.open();
expect(ws.sent.length).toBeGreaterThan(0);
const msg = JSON.parse(ws.sent[0]);
expect(msg.type).toBe('start_esp32');
expect(msg.data.wifi_enabled).toBe(false);
});
it('sends wifi_enabled=true when wifiEnabled is set before connect', () => {
bridge.wifiEnabled = true;
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);
});
it('includes board type in start_esp32', () => {
bridge.wifiEnabled = true;
bridge.connect();
ws = (bridge as any).socket as MockWebSocket;
ws.open();
const msg = JSON.parse(ws.sent[0]);
expect(msg.data.board).toBe('esp32');
});
it('maps esp32-s3 board kind to correct QEMU type', () => {
const s3Bridge = new Esp32Bridge('board-s3', 'esp32-s3');
s3Bridge.wifiEnabled = true;
s3Bridge.connect();
const s3ws = (s3Bridge as any).socket as MockWebSocket;
s3ws.open();
const msg = JSON.parse(s3ws.sent[0]);
expect(msg.data.board).toBe('esp32-s3');
s3Bridge.disconnect();
});
it('maps esp32-c3 board kind to correct QEMU type', () => {
const c3Bridge = new Esp32Bridge('board-c3', 'esp32-c3');
c3Bridge.wifiEnabled = true;
c3Bridge.connect();
const c3ws = (c3Bridge as any).socket as MockWebSocket;
c3ws.open();
const msg = JSON.parse(c3ws.sent[0]);
expect(msg.data.board).toBe('esp32-c3');
c3Bridge.disconnect();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Esp32Bridge — WiFi/BLE status event dispatch
// ─────────────────────────────────────────────────────────────────────────────
describe('Esp32Bridge — WiFi/BLE status events', () => {
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('dispatches wifi_status event to onWifiStatus callback', () => {
const received: WifiStatus[] = [];
bridge.onWifiStatus = (status) => received.push(status);
ws.receive({
type: 'wifi_status',
data: { status: 'got_ip', ssid: 'Velxio-GUEST', ip: '192.168.4.2' },
});
expect(received).toHaveLength(1);
expect(received[0].status).toBe('got_ip');
expect(received[0].ssid).toBe('Velxio-GUEST');
expect(received[0].ip).toBe('192.168.4.2');
});
it('dispatches wifi_status initializing event', () => {
const received: WifiStatus[] = [];
bridge.onWifiStatus = (status) => received.push(status);
ws.receive({
type: 'wifi_status',
data: { status: 'initializing' },
});
expect(received).toHaveLength(1);
expect(received[0].status).toBe('initializing');
});
it('dispatches wifi_status disconnected event', () => {
const received: WifiStatus[] = [];
bridge.onWifiStatus = (status) => received.push(status);
ws.receive({
type: 'wifi_status',
data: { status: 'disconnected' },
});
expect(received).toHaveLength(1);
expect(received[0].status).toBe('disconnected');
});
it('dispatches ble_status event to onBleStatus callback', () => {
const received: BleStatus[] = [];
bridge.onBleStatus = (status) => received.push(status);
ws.receive({
type: 'ble_status',
data: { status: 'initialized' },
});
expect(received).toHaveLength(1);
expect(received[0].status).toBe('initialized');
});
it('dispatches ble_status advertising event', () => {
const received: BleStatus[] = [];
bridge.onBleStatus = (status) => received.push(status);
ws.receive({
type: 'ble_status',
data: { status: 'advertising' },
});
expect(received).toHaveLength(1);
expect(received[0].status).toBe('advertising');
});
it('does not crash when callbacks are not set', () => {
bridge.onWifiStatus = null;
bridge.onBleStatus = null;
// Should not throw
ws.receive({ type: 'wifi_status', data: { status: 'got_ip', ip: '10.0.0.1' } });
ws.receive({ type: 'ble_status', data: { status: 'initialized' } });
});
it('handles multiple sequential WiFi status changes', () => {
const received: WifiStatus[] = [];
bridge.onWifiStatus = (status) => received.push(status);
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(received).toHaveLength(3);
expect(received[0].status).toBe('initializing');
expect(received[1].status).toBe('connected');
expect(received[2].status).toBe('got_ip');
expect(received[2].ip).toBe('192.168.4.2');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. WiFi auto-detection from sketch files
// ─────────────────────────────────────────────────────────────────────────────
describe('WiFi auto-detection', () => {
it('detects #include <WiFi.h> in sketch content', () => {
const content = `
#include <WiFi.h>
void setup() { WiFi.begin("Velxio-GUEST", ""); }
void loop() {}
`;
const hasWifi =
content.includes('#include <WiFi.h>') ||
content.includes('#include <esp_wifi.h>') ||
content.includes('#include "WiFi.h"') ||
content.includes('WiFi.begin(');
expect(hasWifi).toBe(true);
});
it('detects #include <esp_wifi.h> in sketch content', () => {
const content = `
#include <esp_wifi.h>
void setup() { esp_wifi_init(); }
void loop() {}
`;
const hasWifi =
content.includes('#include <WiFi.h>') ||
content.includes('#include <esp_wifi.h>') ||
content.includes('#include "WiFi.h"') ||
content.includes('WiFi.begin(');
expect(hasWifi).toBe(true);
});
it('detects WiFi.begin() call in sketch content', () => {
const content = `
void setup() { WiFi.begin("ssid", "pass"); }
void loop() {}
`;
const hasWifi =
content.includes('#include <WiFi.h>') ||
content.includes('#include <esp_wifi.h>') ||
content.includes('#include "WiFi.h"') ||
content.includes('WiFi.begin(');
expect(hasWifi).toBe(true);
});
it('returns false for non-WiFi sketches', () => {
const content = `
void setup() { Serial.begin(115200); }
void loop() { delay(1000); }
`;
const hasWifi =
content.includes('#include <WiFi.h>') ||
content.includes('#include <esp_wifi.h>') ||
content.includes('#include "WiFi.h"') ||
content.includes('WiFi.begin(');
expect(hasWifi).toBe(false);
});
it('detects #include "WiFi.h" (quotes) in sketch', () => {
const content = '#include "WiFi.h"\nvoid setup() {}\nvoid loop() {}';
const hasWifi =
content.includes('#include <WiFi.h>') ||
content.includes('#include <esp_wifi.h>') ||
content.includes('#include "WiFi.h"') ||
content.includes('WiFi.begin(');
expect(hasWifi).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
fix(micropython): stop shadowing the real WiFi driver when a NIC exists Reported in #262: on an emulated ESP32, MicroPython's WLAN.scan() always returned [], ifconfig() reported 10.0.2.15/10.0.2.2/10.0.2.3, and outbound sockets raised EHOSTUNREACH — while Arduino WiFi.h on the same board and container scanned, connected on 192.168.4.x and completed real HTTP requests. Two independent faults, both needed for the failure. 1. The MicroPython prelude injected a stub `network` module into sys.modules unconditionally, so user code never reached the firmware driver. The stub's hardcoded ifconfig() happens to be QEMU's SLIRP defaults, which is why the symptom read so convincingly as a routing bug. 2. hasWifi survived a language switch. It is a property of the compiled sketch, set only by arduino-cli, but setBoardLanguageMode kept it — so a board compiled once as Arduino carried that verdict into a later MicroPython run. The source-scanning fallback only fires when hasWifi is undefined, so it never got a chance, and the backend attaches the `-nic` device only when wifi_enabled is true. The stub is NOT removed, which is where this departs from the patch in the issue. It exists for a reason the report didn't have: without a NIC, `network.WLAN(STA_IF)` hangs forever waiting on peripheral status bits QEMU never sets, tripping the FreeRTOS watchdog after ~26s. Deleting it would trade a fake-but-working network for a hung board. It is now conditional — real driver when a NIC is attached, stub when none is — so both paths are honest. Also kept: the ntptime and urequests stubs, which the issue's diff would have dropped along with `network` (it removed the whole prelude block). Those back 29 call sites in the 100-days gallery examples, which have no emulated internet to reach regardless of the NIC. The detector is now one shared function instead of two copies that could disagree — the stub decision and the -nic decision MUST match. Folded in `from network import WLAN`, which matched neither previous pattern: such a sketch was judged WiFi-less and hung on the missing peripheral. The existing tests re-implemented the detection inline, so they only ever proved that copy self-consistent; the new ones call the shipped function.
2026-08-11 02:40:37 +07:00
// 3b. The REAL detector (issue #262)
//
// The block above re-implements the detection inline, so it only ever
// proves the copy in this file is self-consistent — which is how the
// MicroPython gap below survived. These call the shipped function.
//
// Getting this wrong is not cosmetic: a false verdict means QEMU is
// started without `-nic`, and MicroPython's `network.WLAN(STA_IF)` then
// hangs ~26s on a peripheral that was never attached until the FreeRTOS
// watchdog resets the board.
// ─────────────────────────────────────────────────────────────────────────────
describe('sketchUsesWifi — the shipped detector', () => {
const uses = (content: string) => sketchUsesWifi([{ content }]);
it('detects the Arduino forms', () => {
expect(uses('#include <WiFi.h>')).toBe(true);
expect(uses('#include "WiFi.h"')).toBe(true);
expect(uses('#include <esp_wifi.h>')).toBe(true);
expect(uses('void setup(){ WiFi.begin("s",""); }')).toBe(true);
});
it('detects the MicroPython import forms', () => {
expect(uses('import network')).toBe(true);
expect(uses('import network as net')).toBe(true);
expect(uses('wlan = network.WLAN(network.STA_IF)')).toBe(true);
});
it('detects `from network import WLAN` (the #262 gap)', () => {
// Reported as MicroPython WiFi being "broken": this form matched
// neither /import\s+network\b/ nor /network\.WLAN/, so the sketch was
// judged WiFi-less and never got a NIC.
expect(uses('from network import WLAN\nw = WLAN(0)')).toBe(true);
});
it('stays false for sketches that touch no network', () => {
expect(uses('void setup(){ Serial.begin(115200); }')).toBe(false);
expect(uses('from machine import Pin\nPin(2).on()')).toBe(false);
});
it('scans every file, not just the first', () => {
expect(
sketchUsesWifi([{ content: 'from machine import Pin' }, { content: 'import network' }]),
).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. BLE detection from sketch files
// ─────────────────────────────────────────────────────────────────────────────
describe('BLE detection in sketches', () => {
it('detects BLEDevice.h include', () => {
const content = '#include <BLEDevice.h>\nvoid setup() { BLEDevice::init("test"); }';
const hasBLE =
content.includes('#include <BLEDevice.h>') ||
content.includes('#include <esp_bt.h>') ||
content.includes('BLEDevice::init(');
expect(hasBLE).toBe(true);
});
it('returns false for non-BLE sketches', () => {
const content = 'void setup() { Serial.begin(115200); }\nvoid loop() {}';
const hasBLE =
content.includes('#include <BLEDevice.h>') ||
content.includes('#include <esp_bt.h>') ||
content.includes('BLEDevice::init(');
expect(hasBLE).toBe(false);
});
});