#!/usr/bin/env python3
"""Optional real-browser checks for WebConfig's asynchronous setup races."""
import brotli
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
HEADER = ROOT / "src" / "helpers" / "esp32" / "WebConfigHtml.h"
def chromium_path():
candidates = [
shutil.which(name)
for name in ("google-chrome", "chromium", "chromium-browser", "chrome", "msedge")
]
for base, rest in (
(os.environ.get("ProgramFiles"), ("Google", "Chrome", "Application", "chrome.exe")),
(os.environ.get("ProgramFiles(x86)"), ("Microsoft", "Edge", "Application", "msedge.exe")),
(os.environ.get("LocalAppData"), ("Google", "Chrome", "Application", "chrome.exe")),
):
if base:
candidates.append(str(Path(base).joinpath(*rest)))
return next((path for path in candidates if path and Path(path).is_file()), None)
BROWSER = chromium_path()
def run_browser(args):
options = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
if os.name == "nt":
options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
options["start_new_session"] = True
process = subprocess.Popen(args, **options)
try:
stdout, stderr = process.communicate(timeout=30)
except subprocess.TimeoutExpired:
# Chrome can leave renderer processes writing to its temporary profile.
# Kill the whole tree before TemporaryDirectory tries to remove it.
if os.name == "nt":
subprocess.run(["taskkill", "/T", "/F", "/PID", str(process.pid)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False)
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
if process.poll() is None:
process.kill()
process.communicate()
raise
return subprocess.CompletedProcess(args, process.returncode, stdout, stderr)
def embedded_page():
header = HEADER.read_text(encoding="utf-8")
length = int(re.search(r"WEBCONFIG_HTML_BR_LEN = (\d+);", header).group(1))
array = header.split("const uint8_t WEBCONFIG_HTML_BR[] PROGMEM = {", 1)[1]
blob = bytes(
int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", array)
)[:length]
return brotli.decompress(blob).decode("utf-8")
class WebConfigUiRuntimeTest(unittest.TestCase):
def test_four_display_controls_and_pairing_capability(self):
status, config = self.setup_values()
config["display"] = {
"mode": "button", "timeout": 23,
"usb_mode": "on", "usb_timeout": 55, "pairing": False,
}
prelude = r'''''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
for attribute, value in (
("display-visible", "true"), ("display-count", "4"),
("battery-timeout", "23"), ("usb-timeout", "55"),
("usb-disabled", "true"), ("pairing-disabled", "true"),
("battery-disabled", "true"), ("dirty-display", "off"),
):
self.assertIn('data-test-%s="%s"' % (attribute, value), dom)
def setup_values(self):
sys.path.insert(0, str(ROOT / "scripts"))
try:
import webconfig_mock_server as mock
finally:
sys.path.pop(0)
config = mock.default_config(True)
config["wifi"]["ssid"] = "StoredNet"
config["wifi"]["pwd"] = "********"
status = {
"mode": "setup",
"auth": True,
"mqtt": False,
"cli": False,
"wifi_psk64": True,
"password_supported": True,
"needs_password": False,
"radio_optional": True,
"needs_setup": False,
"capabilities": 0,
"max_cmds": 16,
"name": "runtime-test",
"role": "Companion",
"board": "mock",
"fw": "v1.17.1-test",
"build_date": "runtime",
}
return status, config
def run_page(self, prelude, virtual_time=1500):
if not BROWSER:
self.skipTest("Chromium-family browser is unavailable")
source = embedded_page()
markers = ('
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-cli-tab="yes"', dom)
self.assertIn('data-test-submitted="ver"', dom)
self.assertIn("companion-console-test-version", dom)
self.assertIn('data-test-busy="false"', dom)
self.assertIn('data-test-enum="on,off"', dom)
self.assertIn('data-test-repeater-erase="true"', dom)
self.assertIn('data-test-mqtt-enabled="false"', dom)
self.assertIn('data-test-mqtt-enum="on,off"', dom)
import html
help_text = html.unescape(re.search(
r'data-test-companion-help="([^"]*)"', dom).group(1))
self.assertIn("set mqtt.enabled on|off", help_text)
self.assertNotIn("neighbors", help_text)
self.assertNotIn("stats-core", help_text)
suggestions = json.loads(html.unescape(re.search(
r'data-test-suggestions="([^"]*)"', dom).group(1)))
for command in ("set powersaving off", "get usb.logging", "set wifi.cli ",
"set mqtt1.preset ", "get name", "get radio", "erase",
"get prv.key", "get mqtt1.password", "get wifi.pwd",
"stats-core", "stats-radio", "stats-packets", "set freq "):
self.assertIn(command, suggestions)
for command in ("password ", "setperm ", "get acl",
"set bridge.enabled ", "set mqtt2.preset "):
self.assertNotIn(command, suggestions)
def test_stream_terminal_imports_long_cards_pages_lists_and_receives_late_replies(self):
status, config = self.setup_values()
status.update(mode="lan", cli=True, terminal_stream=True,
terminal_max_command=541, password_supported=False)
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude, virtual_time=1200)
import html
commands = json.loads(html.unescape(re.search(
r'data-test-commands="([^"]*)"', dom).group(1)))
self.assertEqual(commands, ["import meshcore://" + "ab" * 255,
"list", "to test", "help"])
output = html.unescape(re.search(
r'data-test-stream-output="([^"]*)"', dom).group(1))
self.assertIn("contact-0 (Repeater)", output)
self.assertIn("contact-349 (Repeater)", output)
self.assertEqual(output.count("Late RF reply and ACK"), 1)
self.assertIn("firmware help: import, list, to, send", output)
self.assertIn('data-test-stream-busy="false"', dom)
def test_streamed_raw_log_keeps_entire_file_and_allows_slow_progress(self):
status, config = self.setup_values()
status.update(mode="lan", role="Repeater", cli=True, terminal_stream=True,
terminal_max_command=159, password_supported=False)
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude, virtual_time=7000)
self.assertIn('data-test-complete-log="true"', dom)
self.assertIn('data-test-later-output="true"', dom)
self.assertIn('data-test-log-busy="false"', dom)
self.assertIn('data-test-log-timeout="false"', dom)
def test_console_stays_hidden_when_disabled_or_on_setup_ap(self):
for mode in ("lan", "setup"):
with self.subTest(mode=mode):
status, config = self.setup_values()
status.update(mode=mode, cli=False)
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-cli-hidden="true"', dom)
def run_early_selection_case(self, explicit_password=None):
status, config = self.setup_values()
prelude = """
""" % (json.dumps(status), json.dumps(config), json.dumps(explicit_password))
return self.run_page(prelude)
def test_early_new_ssid_clears_password_loaded_later(self):
dom = self.run_early_selection_case()
self.assertIn('data-test-ssid="EarlyNet"', dom)
self.assertIn('data-test-orig-pwd="********"', dom)
self.assertIn('data-test-wz-pwd=""', dom)
self.assertIn('data-test-app-pwd=""', dom)
self.assertIn('data-test-dirty-ssid="EarlyNet"', dom)
self.assertIn('data-test-dirty-pwd-present="yes"', dom)
self.assertIn('data-test-dirty-pwd=""', dom)
def test_early_explicit_password_is_preserved(self):
dom = self.run_early_selection_case("new-secret")
self.assertIn('data-test-ssid="EarlyNet"', dom)
self.assertIn('data-test-wz-pwd="new-secret"', dom)
self.assertIn('data-test-app-pwd="new-secret"', dom)
self.assertIn('data-test-dirty-pwd-present="yes"', dom)
self.assertIn('data-test-dirty-pwd="new-secret"', dom)
def test_setup_automatically_opens_embedded_scan_picker(self):
status, config = self.setup_values()
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-scan-open="yes"', dom)
self.assertIn('data-test-scan-hidden="false"', dom)
self.assertIn('data-test-scan-expanded="true"', dom)
self.assertIn('data-test-scan-busy="false"', dom)
self.assertIn('data-test-network-count="1"', dom)
self.assertIn('data-test-ssid="StoredNet"', dom)
self.assertIn('data-test-pwd="********"', dom)
self.assertIn('data-test-dirty-count="0"', dom)
def test_typed_new_ssid_clears_inherited_password_and_review(self):
status, config = self.setup_values()
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-ssid="TypedNet"', dom)
self.assertIn('data-test-pwd=""', dom)
self.assertIn('data-test-dirty-pwd="yes"', dom)
self.assertIn('data-test-review-open="yes"', dom)
def test_returning_to_original_ssid_restores_masked_password(self):
status, config = self.setup_values()
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-pwd="********"', dom)
self.assertIn('data-test-dirty-ssid="no"', dom)
self.assertIn('data-test-dirty-pwd="no"', dom)
self.assertIn('data-test-auto-cleared="no"', dom)
def test_advanced_editor_edit_during_config_load_is_preserved(self):
status, config = self.setup_values()
status["mode"] = "lan"
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-name="Early App Edit"', dom)
self.assertIn('data-test-dirty-name="Early App Edit"', dom)
self.assertIn('data-test-capture="clear"', dom)
def test_bluetooth_stealth_toggle_preserves_custom_mac(self):
status, config = self.setup_values()
status["mode"] = "lan"
status["capabilities"] = (1 << 16) | (1 << 17)
config["radio"]["bluetooth_mac"] = "C2:11:22:33:44:55"
config["radio"]["bluetooth_stealth"] = False
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-initial-flag="off"', dom)
self.assertIn('data-test-dirty-flag="on"', dom)
self.assertIn('data-test-dirty-keys="bluetooth.stealth"', dom)
self.assertIn('data-test-mac="C2:11:22:33:44:55"', dom)
self.assertIn('data-test-config-mac="C2:11:22:33:44:55"', dom)
self.assertIn('data-test-restored-dirty-count="0"', dom)
def test_second_load_auto_password_clear_remains_restorable(self):
status, config = self.setup_values()
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-restored-pwd="********"', dom)
self.assertIn('data-test-restored-dirty-ssid="no"', dom)
self.assertIn('data-test-restored-dirty-pwd="no"', dom)
self.assertIn('data-test-restored-auto="no"', dom)
self.assertIn('data-test-manual-pwd="manual-secret"', dom)
self.assertIn('data-test-manual-dirty-ssid="no"', dom)
self.assertIn('data-test-manual-dirty-pwd="yes"', dom)
self.assertIn('data-test-manual-auto="no"', dom)
self.assertIn('data-test-config-calls="2"', dom)
self.assertIn('data-test-capture="clear"', dom)
def test_delayed_app_load_preserves_only_edited_radio_field(self):
status, config = self.setup_values()
status["mode"] = "lan"
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-radio-freq="910.525"', dom)
self.assertIn('data-test-radio-bw="62.5"', dom)
self.assertIn('data-test-radio-sf="9"', dom)
self.assertIn('data-test-radio-cr="5"', dom)
self.assertIn('data-test-radio-orig="910.525,62.5,7,5"', dom)
self.assertIn('data-test-radio-dirty="910.525,62.5,9,5"', dom)
self.assertIn('data-test-capture="clear"', dom)
def test_closed_picker_ignores_late_scan_response(self):
status, config = self.setup_values()
prelude = """
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-scan-hidden="true"', dom)
self.assertIn('data-test-scan-expanded="false"', dom)
self.assertIn('data-test-network-count="0"', dom)
self.assertIn('data-test-scan-text="closed"', dom)
def test_newer_config_load_wins_when_older_reply_finishes_last(self):
status, old_config = self.setup_values()
new_config = json.loads(json.dumps(old_config))
new_config["wifi"]["ssid"] = "AppNet"
prelude = """
""" % (json.dumps(status), json.dumps(old_config), json.dumps(new_config))
dom = self.run_page(prelude)
self.assertIn('data-test-config-calls="2"', dom)
self.assertIn('data-test-orig-ssid="AppNet"', dom)
self.assertIn('data-test-field-ssid="AppNet"', dom)
self.assertIn('data-test-scan-hidden="true"', dom)
self.assertIn('data-test-capture="clear"', dom)
if __name__ == "__main__":
unittest.main()