From b259b10553be18545501e88293fc52af456959ee Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 5 Oct 2026 14:08:24 -0700 Subject: [PATCH] test: cover stock config GUI startup handshake deadline --- test/fixtures/official_config_gui/LICENSE.txt | 21 ++ test/fixtures/official_config_gui/README.txt | 64 ++++++ .../official_config_gui/bootstrap-witness.js | 24 +++ .../fixtures/official_config_gui/contract.mjs | 95 +++++++++ test/test_official_config_gui.py | 194 ++++++++++++++++++ 5 files changed, 398 insertions(+) create mode 100644 test/fixtures/official_config_gui/LICENSE.txt create mode 100644 test/fixtures/official_config_gui/README.txt create mode 100644 test/fixtures/official_config_gui/bootstrap-witness.js create mode 100644 test/fixtures/official_config_gui/contract.mjs create mode 100644 test/test_official_config_gui.py diff --git a/test/fixtures/official_config_gui/LICENSE.txt b/test/fixtures/official_config_gui/LICENSE.txt new file mode 100644 index 000000000..4efdc7c2f --- /dev/null +++ b/test/fixtures/official_config_gui/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rastislav Vysoky + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/test/fixtures/official_config_gui/README.txt b/test/fixtures/official_config_gui/README.txt new file mode 100644 index 000000000..ba03ee5df --- /dev/null +++ b/test/fixtures/official_config_gui/README.txt @@ -0,0 +1,64 @@ +This fixture qualifies the initial config.meshcore.io GUI connect/time path. +It does not claim a complete Vue/DOM/browser or physical USB qualification. + +Official upstream: https://github.com/meshcore-dev/config.meshcore.io +Pinned revision: aff42ad8ef2b332f24d644ff6a783f8272a88ff2 +The index, gui.js and serial-cli.js at this revision match the observed live +config.meshcore.io sources on 2026-10-05. bootstrap-witness.js contains only +the actual connect/disconnect definitions, preserved verbatim. LICENSE.txt +retains the upstream MIT license. No complete third-party bundle is vendored. +The full SerialCLI and GUI source are SHA/size checked in the local test cache +or downloaded from the immutable upstream revision. An explicit source folder +may be selected with MESHCORE_OFFICIAL_CONFIG_GUI for fully offline runs. + +Node 18+ runs the actual SerialCLI class and extracted GUI bootstrap. Real Web +Streams are used; only the peripheral and host clock are mocked. A native write +is accepted before the application is ready, and command delivery uses elapsed +time measured from production GPS init. The real production Repeater command +pump and CommonCLI time handler generate the byte-fragmented CRLF reply. +Inventory coverage additionally runs the actual Esp32BootFileSystem and +production recovery/presence paths against peripheral doubles. Its counted +native missing-name scans are charged 129 ms each plus 1000 ms setup settle; +one complete inventory scan costs 129 ms. This deterministic deadline cost +model uses the observed missing-path overhead, not a host benchmark or a +prediction of full physical boot timing. The same workload with inventory +omitted must miss the actual unchanged GUI's 5000 ms first-time deadline. +getData is a callback witness: this test verifies that it is reached after time +sync, without reading or storing private keys/passwords or imitating all GUI +configuration fields. Physical full-Vue testing remains necessary. + +The negative controls reintroduce a blocking 5000 ms GPS init, replace the +production CRLF response with LF only, or regress the production CR command +delimiter. They fail at the unchanged stock GUI's client-visible time deadline. +The inventory-off negative retains native probes and the same firmware +workload. These deterministic peripheral models prove those contracts; full +physical first-connect and idle qualification still require native testing. + +Run from the repository root: +python3 -B -m unittest discover -s test -p test_official_config_gui.py -v + +Native hardware qualification must use the full unchanged official GUI: +1. Use one USB owner and a Chromium profile granting only the exact target to + both config.meshcore.io and flasher.meshcore.io. Load the GUI first and + verify the source hashes above before starting the flash in another tab. +2. Verify the actual partition table, OTA sequence CRC, selected application + slot and image size before application-only flashing. Keep the stock native + loader/reset/disconnect sequence. Do not erase filesystem preferences to + make the test easier or assume every board uses the same application slot. +3. Immediately after the stock flash backend returns, click GUI Connect on + the already loaded page. Keep its 5000 ms timer, initial time command, + CR-only delimiter and native streams unchanged. Do not insert a startup + sleep, extra reset, command retry or unplug/replug. Record the first result. +4. A failed first connection remains FAIL. Explicit stock cli.disconnect and + a later GUI Connect may be recorded separately as recovery. A settled + no-flash control is also separate from fresh-flash qualification. +5. For idle coverage, save read-back power-saving/logging preferences, enable + power saving and disable logging, read safe numeric core statistics, then + transmit no command for 600 seconds. Verify uptime/errors afterward, use + visible GUI Disconnect/Connect for a full reread, and restore/read back + original preferences. Do not infer an idle PASS from a fresh-connect retry. + +Retain only source/image hashes, lifecycle times, booleans, byte counts, +validated boot phase tokens and whitelisted numeric statistics. The actual +GUI reads private keys and passwords: never persist ordinary debug console +output, complete command responses, or app.device in test artifacts. diff --git a/test/fixtures/official_config_gui/bootstrap-witness.js b/test/fixtures/official_config_gui/bootstrap-witness.js new file mode 100644 index 000000000..f753d3c5e --- /dev/null +++ b/test/fixtures/official_config_gui/bootstrap-witness.js @@ -0,0 +1,24 @@ +const connect = async() => { + app.connecting = true; + try { + await cli.connect(); + await cli.setTime((Date.now() / 1000) | 0); + showMessage('Time sync: OK'); + await getData(); + app.connected = true; + app.locked = false; + } + catch(err) { + alert(`Cannot connect: ${err.message}`); + } + finally { + app.connecting = false; + } + await getPresets(); + console.log(app); + }; +const disconnect = async() => { + await cli.disconnect(); + app.connecting = app.connected = false; + app.locked = true; + }; diff --git a/test/fixtures/official_config_gui/contract.mjs b/test/fixtures/official_config_gui/contract.mjs new file mode 100644 index 000000000..deb63979b --- /dev/null +++ b/test/fixtures/official_config_gui/contract.mjs @@ -0,0 +1,95 @@ +// Execute the unchanged GUI bootstrap and full pinned SerialCLI class. +// Only USB delivery and host/application time are simulated. Production GPS +// timing and optional boot-inventory metadata operation costs control readyMs. +// The response uses the actual production CR parser and CommonCLI time branch. +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {readFileSync} from 'node:fs'; +import {createInterface} from 'node:readline'; +import {setImmediate as turn} from 'node:timers/promises'; + +const firmware=spawn(process.argv[4],[],{stdio:['pipe','pipe','inherit']}); +const readyMs=Number(process.argv[5]); +let output, pending; +createInterface({input:firmware.stdout}).on('line',line=>{ + if(line.startsWith('DATA ')){ + const bytes=Buffer.from(line.slice(5),'hex'); + for(const byte of bytes)output.enqueue(new Uint8Array([byte])); + }else if(line==='DONE'){ + const operation=pending;pending=null;operation.resolve(); + } +}); +firmware.on('exit',code=>{if(pending)pending.reject(Error('Firmware exited '+code));}); +function device(command){ + assert(!pending,'Peripheral operations must be serialized'); + return new Promise((resolve,reject)=>{pending={resolve,reject};firmware.stdin.write(command+'\n');}); +} + +// A deterministic host clock preserves timer registration order. A USB OUT +// write can complete before application setup is ready; its queued CR command +// is admitted after the measured production initialization plus an optional +// modeled inventory workload, then one loop tick. This is not a full boot model. +let now=0,nextId=0; +const jobs=new Map(); +globalThis.setTimeout=(callback,delay=0)=>{ + const id=++nextId;jobs.set(id,{at:now+Number(delay),callback,id});return id; +}; +globalThis.clearTimeout=id=>jobs.delete(id); +Date.now=()=>1791217040000+now; +let writes=0; +class Port{ + async open(options){ + assert.deepEqual(options,{baudRate:115200}); + this.readable=new ReadableStream({start(controller){output=controller;},cancel(){output=null;}}); + this.writable=new WritableStream({write:bytes=>{ + writes++; + assert.equal(Buffer.from(bytes).toString(),'time 1791217040\r','Actual GUI first command must use CR only'); + setTimeout(()=>device('WRITE '+Buffer.from(bytes).toString('hex')),Math.max(readyMs+1-now,1)); + }}); + } + async close(){ + assert(!this.readable.locked&&!this.writable.locked,'Stock cleanup must release the native locks'); + await device('CLOSE'); + } + async setSignals(){assert.fail('No reset or explicit DTR may be added to satisfy bootstrap');} +} +const port=new Port(); +Object.defineProperty(globalThis,'navigator',{configurable:true, + value:{userAgent:'Linux Node host fixture',serial:{requestPort:async()=>port}}}); +const {SerialCLI}=await import('data:text/javascript;base64,'+readFileSync(process.argv[2]).toString('base64')); +const cli=new SerialCLI(false); +assert.equal(cli.responseTimeout,5000,'Do not widen the stock GUI deadline'); +const app={connecting:false,connected:false,locked:true}; +const alerts=[];let dataReads=0; +const bootstrap=Function('cli','app','showMessage','getData','getPresets','alert', + readFileSync(process.argv[3],'utf8')+'\nreturn {connect,disconnect};'); +const gui=bootstrap(cli,app,()=>{},async()=>{dataReads++;},async()=>{},message=>alerts.push(message)); +let finished=false; +const connection=gui.connect().finally(()=>{finished=true;}); +try{ + for(let step=0;step<20&&!finished;step++){ + await turn(); + if(finished)break; + const next=[...jobs.values()].sort((a,b)=>a.at-b.at||a.id-b.id)[0]; + assert(next,'Bootstrap stalled without a pending host deadline'); + jobs.delete(next.id);now=next.at; + await next.callback(); + await turn(); + } + await connection; + assert.equal(writes,1,'Do not retry the first command to turn failure into PASS'); + assert.equal(app.connected,true,'initial time missed the stock 5000 ms deadline'); + assert.equal(app.locked,false); + assert.equal(dataReads,1,'Configuration reads must follow successful initial time'); + assert.deepEqual(alerts,[]); + assert(cli.reader&&cli.writer&&port.readable.locked&&port.writable.locked, + 'The actual GUI retains reader and writer throughout the connected session'); + await gui.disconnect(); + assert.equal(app.connected,false); + assert.equal(app.locked,true); + assert.equal(cli.port,null); + console.log('Actual config GUI: stock 5000 ms time bootstrap, production startup timing, CR-only command and CRLF response passed'); +}finally{ + if(cli.port)await cli.disconnect(); + firmware.stdin.end(); +} diff --git a/test/test_official_config_gui.py b/test/test_official_config_gui.py new file mode 100644 index 000000000..e0de0d6f7 --- /dev/null +++ b/test/test_official_config_gui.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Run stock config GUI bootstrap against production startup and CR CLI code. + +Node executes the actual pinned SerialCLI class and GUI connect/disconnect +functions. Host clocks and USB peripherals are simulated; GPS discovery, the +boot absence inventory, command framing and the time handler come from +production C++. Inventory scan latency is an explicit cost model, not a host +benchmark or predicted hardware timing. No browser, physical device or secret +configuration reads are required by this CI test. +""" +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import urllib.request + +import test_esp32_gps_discovery as gps +import test_official_web_console as console +from test_hwcdc_tx_backport import body +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/official_config_gui" +COMMIT = "aff42ad8ef2b332f24d644ff6a783f8272a88ff2" +SOURCES = { + "src/gui.js": (44387, "919034806e903fbc41e29a367a7d854837f6520f47aa34e5d0618f5dd3d29800"), + "lib/serial-cli.js": (36909, "fbfbdcfa8bf1a372a0396d342e018ba8ee83d33b7b98c3a546fd37cd7e8ce870"), +} + + +def official_sources(): + explicit = os.environ.get("MESHCORE_OFFICIAL_CONFIG_GUI") + base = Path(explicit) if explicit else Path.home() / ".cache/meshcore-tests/config-gui" / COMMIT + result = {} + for relative, (size, digest) in SOURCES.items(): + path = base / relative + if not path.exists(): + if explicit: + raise AssertionError("Missing explicit config GUI source: " + str(path)) + url = f"https://raw.githubusercontent.com/meshcore-dev/config.meshcore.io/{COMMIT}/{relative}" + with urllib.request.urlopen(url, timeout=45) as response: + data = response.read(size + 1) + if len(data) != size or hashlib.sha256(data).hexdigest() != digest: + raise AssertionError("Pinned config GUI 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() != digest: + raise AssertionError("Config GUI source SHA/size mismatch: " + str(path)) + result[relative] = path.resolve() + return result + + +def gps_boot_source(*, blocking=False): + source = gps.harness(negative="blocking" if blocking else None).split("int main(", 1)[0] + return source + r''' +int main() { + fresh(); TestLocation location; EnvironmentSensorManager sensors(location); + const uint32_t started = millis(); + sensors.initBasicGPS(); + std::printf("%u\n", unsigned(millis() - started)); +} +''' + + +def cli_source(*, lf_reply=False, reject_cr=False): + source = console.firmware_harness(lose_crlf=lf_reply) + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + time_handler = body(common, 'if (memcmp(command, "time ", 5) == 0)') + support = r''' +struct RTC { uint32_t utc=1715770351; uint32_t getCurrentTime(){return utc;} + void setCurrentTime(uint32_t value){utc=value;} } rtc; +struct ClockCallbacks { void onManualClockSet(){} } clock_callbacks; +struct DateTime { explicit DateTime(uint32_t){} int hour(){return 20;} + int minute(){return 50;} int day(){return 5;} int month(){return 10;} + int year(){return 2026;} }; +static uint32_t _atoi(const char* value){return std::strtoul(value,nullptr,10);} +''' + source = source.replace("struct MyMesh {", support + "\nstruct MyMesh {\n" + " RTC* getRTCClock(){return &rtc;}\n" + " ClockCallbacks* _callbacks=&clock_callbacks;\n") + old = extract_braced(source, " void handleUsbCommand(") + source = source.replace(old, " void handleUsbCommand(const char* command, char* reply) {\n" + + time_handler + '\nelse strcpy(reply, "Unknown");\n}') + if reject_cr: + # Regress firmware framing, not the pinned client's actual CR write. + pump = body(source, "static void __attribute__((noinline)) serviceCommandInterfaces()") + changed = pump.replace("if (c == '\\r')", "if (c == '\\n')") + assert changed != pump + source = source.replace(pump, changed) + return source + + +class OfficialConfigGuiTests(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.sources = official_sources() + + def run_contract(self, *, blocking=False, lf_reply=False, reject_cr=False, + extra_boot_ms=0): + with tempfile.TemporaryDirectory(prefix="official-config-gui-") as directory: + work = Path(directory) + gui = self.sources["src/gui.js"].read_text() + bootstrap = "\n".join(extract_braced(gui, signature) + ";" for signature in + ("const connect = async() =>", "const disconnect = async() =>")) + witness = (FIXTURE / "bootstrap-witness.js").read_text() + self.assertEqual(bootstrap, witness.rstrip("\n"), "Pinned GUI bootstrap witness changed") + (work / "bootstrap.js").write_text(bootstrap, encoding="ascii") + for name, text in {"Arduino.h": gps.ARDUINO, "Mesh.h": gps.MESH, + "CayenneLPP.h": gps.CAYENNE, + "Wire.h": "#pragma once\nclass TwoWire {};\n"}.items(): + (work / name).write_text(text, encoding="ascii") + sanitize = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + boot_cpp, boot = work / "gps.cpp", work / "gps" + boot_cpp.write_text(gps_boot_source(blocking=blocking), encoding="ascii") + command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Wno-unused-parameter", + "-Wno-unused-function", "-DENV_INCLUDE_GPS=1", "-DESP32_PLATFORM=1", + *sanitize, "-I" + str(work), "-I" + str(ROOT / "src"), str(boot_cpp), + str(ROOT / "src/helpers/SensorManager.cpp"), "-o", str(boot)] + built = subprocess.run(command, text=True, capture_output=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + measured = subprocess.run([str(boot)], text=True, capture_output=True, timeout=10) + self.assertEqual(measured.returncode, 0, measured.stdout + measured.stderr) + self.assertGreaterEqual(extra_boot_ms, 0) + boot_ms = int(measured.stdout.strip()) + extra_boot_ms + cli_cpp, cli = work / "cli.cpp", work / "cli" + cli_cpp.write_text(cli_source(lf_reply=lf_reply, reject_cr=reject_cr), encoding="ascii") + command = [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", *sanitize, "-I", str(ROOT / "src"), + str(cli_cpp), "-o", str(cli)] + built = subprocess.run(command, text=True, capture_output=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + result = subprocess.run([self.node, str(FIXTURE / "contract.mjs"), + str(self.sources["lib/serial-cli.js"]), str(work / "bootstrap.js"), + str(cli), str(boot_ms)], text=True, capture_output=True, timeout=20) + return result + + def test_stock_gui_time_handshake_with_cooperative_gps_and_cr_only(self): + result = self.run_contract() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_reverted_blocking_gps_breaks_stock_gui_deadline(self): + result = self.run_contract(blocking=True) + self.assertNotEqual(result.returncode, 0, "Negative control did not fail") + self.assertIn("initial time missed the stock 5000 ms deadline", result.stderr) + + def test_lf_only_firmware_reply_breaks_actual_gui_parser(self): + result = self.run_contract(lf_reply=True) + self.assertNotEqual(result.returncode, 0, "Negative control did not fail") + self.assertIn("initial time missed the stock 5000 ms deadline", result.stderr) + + def test_firmware_rejecting_cr_breaks_actual_gui_bootstrap(self): + result = self.run_contract(reject_cr=True) + self.assertNotEqual(result.returncode, 0, "Negative control did not fail") + self.assertIn("initial time missed the stock 5000 ms deadline", result.stderr) + + def test_boot_inventory_keeps_stock_gui_initial_time_within_deadline(self): + from test_esp32_boot_file_inventory import run_boot_inventory_timing + # The real inventory/recovery/probe code supplies operation counts. + # Peripheral timing charges 129 ms per absent native name scan plus + # 1000 ms setup settle; it does not imitate the full physical boot. + timing = run_boot_inventory_timing() + self.assertGreater(timing["snapshot_reads"], 0) + self.assertGreaterEqual(timing["elapsed_ms"], 1000) + self.assertLess(timing["elapsed_ms"], 5000) + result = self.run_contract(extra_boot_ms=timing["elapsed_ms"]) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_removing_boot_inventory_breaks_stock_gui_deadline(self): + from test_esp32_boot_file_inventory import run_boot_inventory_timing + # Same actual startup probes and native backend, with beginInventory + # omitted. The client still receives one CR command with no retry. + timing = run_boot_inventory_timing(disable_inventory=True) + self.assertEqual(timing["snapshot_reads"], 0) + self.assertGreaterEqual(timing["elapsed_ms"], 5000) + result = self.run_contract(extra_boot_ms=timing["elapsed_ms"]) + self.assertNotEqual(result.returncode, 0, "Negative control did not fail") + self.assertIn("initial time missed the stock 5000 ms deadline", result.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2)