test: cover stock config GUI startup handshake deadline

This commit is contained in:
mikecarper committed 2026-10-05 14:08:24 -07:00
1 parent a8b39daafb
commit b259b10553
5 files changed
+398

No files matched your search

+21
View File
@@ -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.
+64
View File
@@ -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.
+24
View File
@@ -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;
};
+95
View File
@@ -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();
}
+194
View File
@@ -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)