#!/usr/bin/env python3 """Optional real-browser checks for WebConfig's asynchronous setup races.""" import gzip import json import os from pathlib import Path import re import shutil import subprocess import sys import tempfile import unittest 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 embedded_page(): header = HEADER.read_text(encoding="utf-8") length = int(re.search(r"WEBCONFIG_HTML_GZ_LEN = (\d+);", header).group(1)) array = header.split("const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {", 1)[1] blob = bytes( int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", array) )[:length] return gzip.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()