mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 09:52:23 +00:00
test: cover stock config GUI startup handshake deadline
This commit is contained in:
1 parent
a8b39daafb
commit
b259b10553
5 files changed
+398
No files matched your search
+21
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user