Files
MeshCore/test/test_official_web_console.py
T
mikecarper 21194b9825 Fix compile and capacity blockers in the full release matrix
Resolve Serial macro and ESP-NOW wrapper compilation errors. Preserve queue, neighbor, advert-history and Room post capacities while using qualified RTC, PSRAM and word-only IRAM storage on constrained ESP32 targets. Reduce Full infrastructure flash through lossless Room web compression, compact crypto and unused exception metadata removal.

Add focused native regressions for storage ownership, exact protocol output, OTA proofs and build policy. Keep physical flash sizes, OTA slots and RAM guard formulas unchanged.
2026-10-24 05:38:23 -07:00

258 lines
14 KiB
Python

#!/usr/bin/env python3
"""Run the stock website console against production CLI and sleep code.
USB/RTOS peripherals and time are simulated; the website's Web Streams client,
repeater command pump, power-saving setters/getters, and ESP32Board methods are
real. Physical browser/HIL testing remains necessary for USB enumeration.
"""
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
import urllib.request
from test_esp32_usb_sleep import HARNESS, board_method, board_sleep_methods
from test_hwcdc_tx_backport import body
ROOT = Path(__file__).resolve().parents[1]
COMMIT = "45a5257bf792b11a83f4c51c18d4f312addfb677"
URL = f"https://raw.githubusercontent.com/meshcore-dev/flasher.meshcore.io/{COMMIT}/lib/console.js"
SHA256 = "107d3ad867915aea8b2454c324327397a0f9e2f0469656046054011bc64475f9"
SIZE = 2455
def official_console():
explicit = os.environ.get("MESHCORE_OFFICIAL_WEB_CONSOLE")
path = Path(explicit) if explicit else Path.home() / ".cache/meshcore-tests" / SHA256 / "console.js"
if not path.exists():
if explicit:
raise AssertionError(f"Missing explicit web console source: {path}")
with urllib.request.urlopen(URL, timeout=45) as response:
data = response.read(SIZE + 1)
if len(data) != SIZE or hashlib.sha256(data).hexdigest() != SHA256:
raise AssertionError("Pinned official console download failed its source gate")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
data = path.read_bytes()
if len(data) != SIZE or hashlib.sha256(data).hexdigest() != SHA256:
raise AssertionError(f"Official web console SHA/size mismatch: {path}")
return path.resolve()
def firmware_harness(*, lose_sleep_guard=False, lose_crlf=False,
lose_raw_uart_guard=False, lose_dual_radio_guard=False):
# Retain the existing host peripheral boundary; use the production methods
# and a virtual clock to exercise minutes/hours without slowing CI.
source = HARNESS.split("int main()", 1)[0]
sleep_held = board_method("bool isUsbSleepHeld()")
if lose_sleep_guard:
# Remove the actual native USB veto while retaining host observation.
# The stock client must lose its reply after the real sleep branch runs.
guard = ("return host_connected || usb_host_sleep_policy.shouldKeepAwake(\n"
" millis(), MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS);")
assert guard in sleep_held, "Missing native USB negative-control boundary"
sleep_held = sleep_held.replace(guard, "return false;")
methods = board_sleep_methods() + "\n" + "\n".join(board_method(signature) for signature in (
"bool isUsbDataConnected() override",
"bool isUsbHostConnected() override")) + "\n" + sleep_held
if lose_raw_uart_guard:
expression = "keep_awake = keep_awake || hold(static_cast<bool>(Serial), Reason::SerialIO);"
assert expression in methods, "Missing raw UART negative-control boundary"
methods = methods.replace(expression, "keep_awake = keep_awake || false;")
source = source.replace("@METHODS@", methods)
source = source.replace(" uint32_t irq = 48;", " void loop() {}\n uint32_t irq = 48;")
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
setter = body(cli, 'if (strcmp(config, "powersaving on") == 0')
getter = body(cli, 'if (strcmp(config, "powersaving") == 0)')
main = (ROOT / "examples/simple_repeater/main.cpp").read_text()
pump = body(main, "static void __attribute__((noinline)) serviceCommandInterfaces()")
if lose_crlf:
pump = pump.replace('console.printf(" -> %s\\r\\n", reply);',
'console.printf(" -> %s\\n", reply);')
start = main.index(" const auto sleep_guard =")
end = main.index(" if (the_mesh.getNodePrefs()->reboot_interval", start)
morse_declaration = " const bool morse_timing = mesh::morseTimingActive(&radio_driver);"
assert morse_declaration in main[:start], "Missing live Morse ownership snapshot"
power_loop = morse_declaration + "\n" + main[start:end]
if lose_dual_radio_guard:
guard = " && !the_mesh.isDualRadioActive()"
assert power_loop.count(guard) == 1, "Missing radio2 idle-delay negative-control boundary"
power_loop = power_loop.replace(guard, "")
morse_helper = body((ROOT / "src/helpers/MorseLoopPolicy.h").read_text(),
"inline bool morseTimingActive(const Radio* radio)")
# Model only RF ownership at the peripheral boundary; use the real helper
# and loop guards rather than making every invocation permanently idle.
morse_radio = r'''
namespace mesh {
struct Radio {
bool tx = false, rx = false, cw = false;
bool isMorseTxActive() const { return tx; }
bool isMorseRxActive() const { return rx; }
};
@MORSE_HELPER@
}
static mesh::Radio radio_driver;
'''.replace("@MORSE_HELPER@", morse_helper)
fixture = (ROOT / "test/fixtures/official_web_console/firmware.cpp").read_text()
sleep_getter = " unsigned getPowerSaveSleepMilliseconds(unsigned limit) { return limit; }"
assert fixture.count(sleep_getter) == 1, "Missing sleep-deadline peripheral boundary"
fixture = fixture.replace(sleep_getter, sleep_getter + "\n" +
" bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; }\n" +
# This RF boundary has no settled scanner proof, so a live radio2
# must keep polling. Execute the production deadline helper below.
" bool getDualRadioIdleDelay(uint32_t& ms) const { ms = 0; return false; }")
source += "\n#include <helpers/PowerManagementUtils.h>\n"
profile_helper = body((ROOT / "src/helpers/esp32/ProfileIdleSleep.h").read_text(),
"template <typename Mesh, typename Board>")
source += ("\nstatic int64_t esp_timer_get_time() { return micros(); }\n"
"namespace mesh { namespace esp32 {\n" + profile_helper + "\n} }\n")
return source + morse_radio + (fixture.replace("@SETTER@", setter).replace("@GETTER@", getter)
.replace("@COMMAND_PUMP@", pump).replace("@POWER_LOOP@", power_loop))
class OfficialWebConsoleTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.node = shutil.which("node")
cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if not cls.node or not cls.compiler:
raise unittest.SkipTest("Node 18+ and a host C++17 compiler are required")
cls.console = official_console()
def run_contract(self, *, lose_sleep_guard=False, lose_crlf=False,
raw_uart=False, lose_raw_uart_guard=False):
with tempfile.TemporaryDirectory(prefix="official-web-console-") as tmp:
tmp = Path(tmp)
source = tmp / "firmware.cpp"
source.write_text(firmware_harness(lose_sleep_guard=lose_sleep_guard,
lose_crlf=lose_crlf,
lose_raw_uart_guard=lose_raw_uart_guard), encoding="ascii")
binary = tmp / "firmware"
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
result = subprocess.run([self.compiler, "-std=c++17", "-Wall", "-Wextra",
"-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=" + str(int(not raw_uart)),
"-DESP32_PLATFORM=1",
"-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=" + str(int(not raw_uart)),
"-DMESH_USB_CONSOLE_COOPERATIVE=" + str(int(not raw_uart)),
"-DMESH_USB_LOGGING_AVAILABLE=1",
"-DCONFIG_TINYUSB_ENABLED=0", "-I", str(ROOT / "src"),
*sanitizers, str(source), "-o", str(binary)],
text=True, capture_output=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([self.node,
str(ROOT / "test/fixtures/official_web_console/contract.mjs"),
str(self.console), str(binary), str(int(raw_uart))],
text=True, capture_output=True, timeout=20)
return result
def test_stock_console_crlf_idle_power_saving_and_reopen(self):
result = self.run_contract()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_raw_uart_stock_console_idle_power_saving_and_reopen(self):
result = self.run_contract(raw_uart=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_missing_raw_uart_sleep_guard_breaks_stock_console(self):
result = self.run_contract(raw_uart=True, lose_raw_uart_guard=True)
self.assertNotEqual(result.returncode, 0, "Negative control did not lose the console")
self.assertIn("idle query", result.stderr)
def test_missing_usb_sleep_guard_breaks_stock_console(self):
result = self.run_contract(lose_sleep_guard=True)
self.assertNotEqual(result.returncode, 0, "Negative control did not lose the console")
self.assertIn("idle query", result.stderr)
def test_lf_only_reply_is_not_visible_in_stock_console(self):
result = self.run_contract(lose_crlf=True)
self.assertNotEqual(result.returncode, 0, "Negative control did not detect buffered replies")
self.assertIn("power-saving setter", result.stderr)
def run_dual_radio_trace(self, *, lose_dual_radio_guard=False):
source = firmware_harness(lose_dual_radio_guard=lose_dual_radio_guard)
source = source.split("int main() {", 1)[0] + r'''
int main() {
attachHost(true); Serial.terminal_open = true;
prefs.powersaving_enabled = 1; mock_millis = 300001;
const unsigned before = yield_ms;
the_mesh.dual_radio_active = true;
power_loop();
require(sleep_calls == 0 && yield_ms == before, "Active radio2 inserted an idle delay");
the_mesh.dual_radio_active = false;
power_loop();
require(sleep_calls == 0 && yield_ms == before + 1, "Idle delay did not resume after radio2");
prefs.powersaving_enabled = 0;
power_loop();
require(sleep_calls == 0 && yield_ms == before + 1, "PS-off idle behavior changed");
}
'''
with tempfile.TemporaryDirectory(prefix="official-console-radio2-ownership-") as temporary:
cpp = Path(temporary) / "ownership.cpp"
binary = Path(temporary) / "ownership"
cpp.write_text(source, encoding="ascii")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
result = subprocess.run([self.compiler, "-std=c++17", "-Wall", "-Wextra",
"-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", "-DESP32_PLATFORM=1",
"-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1", "-DMESH_USB_CONSOLE_COOPERATIVE=1",
"-DMESH_USB_LOGGING_AVAILABLE=1", "-DCONFIG_TINYUSB_ENABLED=0",
"-I", str(ROOT / "src"), *sanitizers, str(cpp), "-o", str(binary)],
text=True, capture_output=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return subprocess.run([str(binary)], text=True, capture_output=True, timeout=20)
def test_actual_radio2_ownership_skips_idle_delay_then_resumes(self):
result = self.run_dual_radio_trace()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_missing_radio2_idle_guard_is_detected(self):
result = self.run_dual_radio_trace(lose_dual_radio_guard=True)
self.assertNotEqual(result.returncode, 0, "Negative control did not detect radio2 idle delay")
self.assertIn("Active radio2 inserted an idle delay", result.stderr)
def test_actual_morse_ownership_blocks_sleep_and_idle_delay_then_resumes(self):
source = firmware_harness().split("int main() {", 1)[0] + r'''
int main() {
attachHost(false); Serial.terminal_open = false;
prefs.powersaving_enabled = 1; mock_millis = 300001;
const unsigned before = yield_ms;
radio_driver.tx = true;
require(mesh::morseTimingActive(&radio_driver), "Morse TX ownership was lost");
power_loop();
require(sleep_calls == 0 && yield_ms == before, "Morse TX slept or inserted an idle delay");
radio_driver.tx = false; radio_driver.rx = true;
require(mesh::morseTimingActive(&radio_driver), "Morse RX ownership was lost");
power_loop();
require(sleep_calls == 0 && yield_ms == before, "Morse RX slept or inserted an idle delay");
radio_driver.rx = false; radio_driver.cw = true;
require(!mesh::morseTimingActive(&radio_driver), "Unkeyed CW changed the Morse loop policy");
radio_driver.cw = false;
power_loop();
require(sleep_calls == 1, "Power saving did not resume when Morse released RF ownership");
}
'''
with tempfile.TemporaryDirectory(prefix="official-console-morse-ownership-") as temporary:
cpp = Path(temporary) / "ownership.cpp"
binary = Path(temporary) / "ownership"
cpp.write_text(source, encoding="ascii")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
result = subprocess.run([self.compiler, "-std=c++17", "-Wall", "-Wextra",
"-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", "-DESP32_PLATFORM=1",
"-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1", "-DMESH_USB_CONSOLE_COOPERATIVE=1",
"-DMESH_USB_LOGGING_AVAILABLE=1", "-DCONFIG_TINYUSB_ENABLED=0",
"-I", str(ROOT / "src"), *sanitizers, str(cpp), "-o", str(binary)],
text=True, capture_output=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(binary)], text=True, capture_output=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()