velxio/test/backend/e2e/test_micropython_pico.mjs

372 lines
15 KiB
JavaScript
Raw Normal View History

/**
* test_micropython_pico.mjs
*
* End-to-end test for MicroPython on Raspberry Pi Pico (RP2040) using rp2040js.
*
* What it tests:
* Part 1 Backend compile:
* POST /api/compile/ with a simple Arduino serial sketch for rp2040:rp2040:rpipico.
* Verifies the backend returns success + a valid .bin binary.
* (The backend auto-installs rp2040:rp2040 core on first run.)
*
* Part 2 MicroPython simulation:
* Downloads MicroPython v1.20.0 UF2 firmware for Pico from micropython.org.
* Loads it into a rp2040js RP2040 emulator (pure Node.js, no QEMU/backend).
* Boots MicroPython to REPL, sends a Python snippet via raw REPL,
* and verifies the expected output appears on USBCDC serial.
*
* Run:
* node test/backend/e2e/test_micropython_pico.mjs [--timeout=180] [--backend=http://localhost:8001]
*
* Prerequisites:
* - Backend running on http://localhost:8001 (for Part 1 only)
* - npm install --prefix test/backend/e2e (installs rp2040js)
*/
import { Simulator, USBCDC, ConsoleLogger, LogLevel } from 'rp2040js';
import { bootromB1 } from './rp2040-bootrom.mjs';
// ─── Config ───────────────────────────────────────────────────────────────────
const BACKEND = process.env.BACKEND_URL
?? process.argv.find(a => a.startsWith('--backend='))?.slice(10)
?? 'http://localhost:8001';
const TIMEOUT_S = parseInt(
process.argv.find(a => a.startsWith('--timeout='))?.slice(10) ?? '180'
);
// MicroPython v1.20.0 UF2 for Raspberry Pi Pico (same version used by the frontend)
const MICROPYTHON_UF2_URL =
'https://micropython.org/resources/firmware/RPI_PICO-20230426-v1.20.0.uf2';
// UF2 format constants
const UF2_MAGIC0 = 0x0a324655;
const UF2_MAGIC1 = 0x9e5d5157;
const FLASH_START_ADDR = 0x10000000;
// Python code injected once ">>>" is seen
const INJECT_CODE = [
'import sys',
'print("velxio_pico_ok")',
'print("py_version:" + sys.version.split(" ")[0])',
'print("math_check:" + str(6 * 7))',
].join('\n');
// ─── Logging ──────────────────────────────────────────────────────────────────
const T0 = Date.now();
const ts = () => `[+${((Date.now() - T0) / 1000).toFixed(3)}s]`;
const C = {
INFO: '\x1b[36m', WARN: '\x1b[33m', ERROR: '\x1b[31m',
OK: '\x1b[32m', SERIAL: '\x1b[35m', DIAG: '\x1b[33m', RESET: '\x1b[0m',
};
const log = (lvl, ...a) => console.log(`${C[lvl] ?? ''}${ts()} [${lvl}]${C.RESET}`, ...a);
const info = (...a) => log('INFO', ...a);
const ok = (...a) => log('OK', ...a);
const warn = (...a) => log('WARN', ...a);
const err = (...a) => log('ERROR', ...a);
const serial = (...a) => log('SERIAL', ...a);
const diag = (...a) => log('DIAG', ...a);
// ─── UF2 Parser ───────────────────────────────────────────────────────────────
/**
* Parse a UF2 binary into a flash Uint8Array at the correct offsets.
* UF2 blocks are 512 bytes; payload is 256 bytes at offset 32; flash address at offset 12.
*/
function parseUF2(uf2Bytes, flash) {
const view = new DataView(uf2Bytes.buffer, uf2Bytes.byteOffset, uf2Bytes.byteLength);
let blocks = 0;
for (let off = 0; off + 512 <= uf2Bytes.length; off += 512) {
if (view.getUint32(off, true) !== UF2_MAGIC0) continue;
if (view.getUint32(off + 4, true) !== UF2_MAGIC1) continue;
const flashAddr = view.getUint32(off + 12, true);
const payload = uf2Bytes.subarray(off + 32, off + 32 + 256);
const flashOff = flashAddr - FLASH_START_ADDR;
if (flashOff >= 0 && flashOff + 256 <= flash.length) {
flash.set(payload, flashOff);
blocks++;
}
}
info(`UF2 parsed: ${blocks} blocks written to flash`);
return blocks;
}
// ─── Part 1: Backend compile check ────────────────────────────────────────────
/**
* Compile a trivial Arduino serial sketch for rp2040:rp2040:rpipico via the backend.
* The backend will auto-install the rp2040:rp2040 core if it is not present.
* Returns true on success, false if the compile fails or the backend is unreachable.
*/
async function checkBackendCompile() {
info('─── Part 1: Backend compile check (rp2040:rp2040:rpipico) ───');
const SKETCH = `
void setup() {
Serial.begin(115200);
Serial.println("velxio_pico_compile_ok");
}
void loop() {}
`.trim();
try {
const res = await fetch(`${BACKEND}/api/compile/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
files: [{ name: 'sketch.ino', content: SKETCH }],
board_fqbn: 'rp2040:rp2040:rpipico',
}),
signal: AbortSignal.timeout(120_000), // 2 min — first run installs the core
});
if (!res.ok) {
warn(`Backend returned HTTP ${res.status} — compile step skipped`);
return false;
}
const body = await res.json();
if (!body.success) {
warn(`Compile failed: ${body.error ?? body.stderr?.slice(0, 200)}`);
return false;
}
const binaryB64 = body.binary_content ?? body.hex_content;
if (!binaryB64) {
warn('Backend returned success but no binary — compile check inconclusive');
return false;
}
const binaryBytes = Math.floor(binaryB64.length * 0.75);
ok(`Compile succeeded: binary ≈ ${(binaryBytes / 1024).toFixed(0)} KB`);
if (body.core_install_log) {
info(`Core auto-install log:\n${body.core_install_log.slice(0, 400)}`);
}
return true;
} catch (e) {
warn(`Backend unreachable or timed out (${e.message}) — compile step skipped`);
return false;
}
}
// ─── Part 2: MicroPython UF2 download ─────────────────────────────────────────
async function downloadUF2() {
info(`─── Part 2: Download MicroPython UF2 ───`);
info(` URL: ${MICROPYTHON_UF2_URL}`);
const controller = new AbortController();
const dlTimeout = setTimeout(() => controller.abort(), 90_000);
try {
const res = await fetch(MICROPYTHON_UF2_URL, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = await res.arrayBuffer();
clearTimeout(dlTimeout);
const bytes = new Uint8Array(buf);
ok(`UF2 downloaded: ${bytes.length} bytes (${(bytes.length / 1024).toFixed(0)} KB)`);
return bytes;
} catch (e) {
clearTimeout(dlTimeout);
throw new Error(`UF2 download failed: ${e.message}`);
}
}
// ─── Part 3: rp2040js simulation ──────────────────────────────────────────────
/**
* Load MicroPython UF2 into rp2040js and run until REPL appears,
* then inject a Python snippet and verify the output.
*/
function runPicoSimulation(uf2Bytes) {
return new Promise((resolve) => {
info('─── Part 3: rp2040js simulation ───');
// ── Build RP2040 instance ──────────────────────────────────────────
const sim = new Simulator();
sim.rp2040.loadBootrom(bootromB1);
sim.rp2040.logger = new ConsoleLogger(LogLevel.Error);
// ── Load UF2 into flash ────────────────────────────────────────────
const flash = sim.rp2040.flash;
const blocks = parseUF2(uf2Bytes, flash);
if (blocks === 0) {
sim.stop();
return resolve({ timedOut: false, error: 'UF2 parse produced 0 blocks — bad firmware?' });
}
// ── Set up USBCDC (MicroPython serial REPL) ─────────────────────────
const cdc = new USBCDC(sim.rp2040.usbCtrl);
// Collected state
let serialBuf = '';
let replState = 'idle'; // idle → prompt_seen → raw_repl_entered → done
let replReady = false;
let codeInjected = false;
let outputOk = false;
let mathCheck = false;
// Global timeout — stop sim, resolve with what we have
const globalTimer = setTimeout(() => {
sim.stop();
resolve({ timedOut: true, replReady, codeInjected, outputOk, mathCheck });
}, TIMEOUT_S * 1000);
// ── Send code in the raw REPL (mirrors Esp32Bridge._sendCodeInRawRepl) ──
function sendCodeInRawRepl() {
if (codeInjected) return;
codeInjected = true;
info('Stage 3: raw REPL confirmed → sending code in 64-byte chunks');
diag(`Code:\n${INJECT_CODE}`);
const codeBytes = Array.from(new TextEncoder().encode(INJECT_CODE));
const CHUNK_SIZE = 64;
const CHUNK_DELAY = 150;
let offset = 0;
const sendChunk = () => {
if (offset >= codeBytes.length) {
// All bytes sent — send Ctrl+D to execute
setTimeout(() => {
for (const b of [0x04]) cdc.sendSerialByte(b);
replState = 'done';
info('Ctrl+D sent — code executing');
}, 300);
return;
}
const chunk = codeBytes.slice(offset, offset + CHUNK_SIZE);
for (const b of chunk) cdc.sendSerialByte(b);
offset += CHUNK_SIZE;
setTimeout(sendChunk, CHUNK_DELAY);
};
sendChunk();
}
// ── USBCDC connected — send \r\n to wake the REPL ──────────────────
cdc.onDeviceConnected = () => {
info('USBCDC connected — sending \\r\\n to wake REPL');
cdc.sendSerialByte('\r'.charCodeAt(0));
cdc.sendSerialByte('\n'.charCodeAt(0));
};
// ── USBCDC serial data (incoming from MicroPython) ─────────────────
cdc.onSerialData = (buffer) => {
for (const byte of buffer) {
const ch = String.fromCharCode(byte);
process.stdout.write(ch);
serialBuf += ch;
}
// Stage 1: ">>>" → send Ctrl+A to enter raw REPL
if (replState === 'idle' && serialBuf.includes('>>>')) {
replState = 'prompt_seen';
replReady = true;
serialBuf = '';
ok('Stage 1: >>> seen → sending Ctrl+A (raw REPL)');
setTimeout(() => cdc.sendSerialByte(0x01), 200);
}
// Stage 2: "raw REPL" → now safe to send code
if (replState === 'prompt_seen' && serialBuf.includes('raw REPL')) {
replState = 'raw_repl_entered';
serialBuf = '';
setTimeout(sendCodeInRawRepl, 200);
}
// Parse output lines for markers
let nl;
while ((nl = serialBuf.indexOf('\n')) !== -1) {
const line = serialBuf.slice(0, nl).replace(/\r$/, '');
serialBuf = serialBuf.slice(nl + 1);
if (!line.trim()) continue;
serial(`USBCDC: ${line}`);
if (line.startsWith('MicroPython')) {
ok(`MicroPython booted: ${line}`);
}
if (line.includes('velxio_pico_ok')) {
outputOk = true;
ok('"velxio_pico_ok" received ✓');
}
if (line.includes('math_check:42')) {
mathCheck = true;
ok('"math_check:42" (6×7=42) confirmed ✓');
}
if (outputOk && mathCheck) {
clearTimeout(globalTimer);
sim.stop();
resolve({ timedOut: false, replReady, codeInjected, outputOk, mathCheck });
}
}
// Cap buffer to avoid runaway memory
if (serialBuf.length > 4096) serialBuf = serialBuf.slice(-512);
};
// ── Start simulation ────────────────────────────────────────────────
sim.rp2040.core.PC = 0x10000000;
sim.execute();
info('RP2040 simulation started (125 MHz, MicroPython UF2)');
info(`Waiting up to ${TIMEOUT_S}s for REPL...`);
});
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
console.log('\n' + '='.repeat(60));
console.log(' MicroPython Raspberry Pi Pico — E2E Test');
console.log('='.repeat(60) + '\n');
info(`Backend : ${BACKEND}`);
info(`Timeout : ${TIMEOUT_S}s`);
let exitCode = 0;
try {
// ── Part 1: backend compile ────────────────────────────────────────
const compileOk = await checkBackendCompile();
if (!compileOk) {
warn('Backend compile check skipped or failed — continuing with simulation');
}
// ── Part 2: download firmware ──────────────────────────────────────
const uf2Bytes = await downloadUF2();
// ── Part 3: run simulation ─────────────────────────────────────────
const result = await runPicoSimulation(uf2Bytes);
// ── Report ─────────────────────────────────────────────────────────
console.log('\n' + '─'.repeat(60));
console.log(' Results');
console.log('─'.repeat(60));
console.log(` Backend compile: ${compileOk}`);
console.log(` Timed out: ${result.timedOut}`);
console.log(` REPL appeared: ${result.replReady}`);
console.log(` Code injected: ${result.codeInjected}`);
console.log(` Output received: ${result.outputOk}`);
console.log(` Math check (42): ${result.mathCheck}`);
console.log('─'.repeat(60) + '\n');
// ── Assertions ─────────────────────────────────────────────────────
const FAIL = (msg) => { err(`FAIL: ${msg}`); exitCode = 1; };
if (!compileOk) {
// Not a hard failure — rp2040 core may not be installed in this CI run
warn('Backend compile check did not pass (rp2040 core may not be installed yet)');
}
if (result.error) FAIL(result.error);
if (!result.replReady) FAIL('MicroPython REPL ">>>" never appeared within timeout');
if (!result.outputOk) FAIL('"velxio_pico_ok" not found in USBCDC output (REPL injection failed?)');
if (!result.mathCheck) FAIL('"math_check:42" not found (6×7 computation did not execute)');
if (exitCode === 0) {
ok('ALL SIMULATION CHECKS PASSED ✓');
}
} catch (e) {
err(`Fatal: ${e.message}`);
console.error(e);
exitCode = 1;
}
process.exit(exitCode);
}
main();