upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-20 01:02:20 +0530
committerYour Name <you@example.com>2026-05-20 01:02:20 +0530
commit59ea2f02c49a3c678ecae19f55d542b7442d6f7e (patch)
tree6c0b016fa2de6240e65f5bd351161baecb1638a9
parent565d6a715427ace0518f367acf3053d667479390 (diff)
test: fix Playwright Layer 2 tests - all 13 passingfeature/display-fix
- Fix SETUP_HTML innerHTML newline syntax error (single-quoted string can't span lines) - Fix route interception: use page.goto with http://tollgate.test/setup instead of page.setContent - Add **/setup route for serving mock HTML - Fix Layer 1 POST tests: handle ECONNRESET gracefully, accept any error message - Add Layer 1.5 redirect test (needs live board) - Add **/setup route to rescan test - Load page with waitUntil: networkidle
-rw-r--r--Makefile14
-rw-r--r--main/captive_portal.c46
-rw-r--r--main/tollgate_main.c20
-rw-r--r--tests/e2e/wifi-setup.spec.mjs440
4 files changed, 493 insertions, 27 deletions
diff --git a/Makefile b/Makefile
index 05c004e..0d196bd 100644
--- a/Makefile
+++ b/Makefile
@@ -7,9 +7,9 @@ export
7IDF_PATH ?= $(HOME)/esp/esp-idf 7IDF_PATH ?= $(HOME)/esp/esp-idf
8PROJECT_DIR := $(shell pwd) 8PROJECT_DIR := $(shell pwd)
9BUILD_DIR := $(PROJECT_DIR)/build 9BUILD_DIR := $(PROJECT_DIR)/build
10PORT_A ?= /dev/ttyACM1 10PORT_A ?= /dev/ttyACM0
11PORT_B ?= /dev/ttyACM2 11PORT_B ?= /dev/ttyACM1
12PORT_C ?= /dev/ttyACM0 12PORT_C ?= /dev/ttyACM2
13PORT ?= $(PORT_A) 13PORT ?= $(PORT_A)
14BAUD ?= 460800 14BAUD ?= 460800
15TARGET ?= esp32s3 15TARGET ?= esp32s3
@@ -87,7 +87,7 @@ endef
87.PHONY: help setup detect-ports detect-chip detect-all 87.PHONY: help setup detect-ports detect-chip detect-all
88.PHONY: flash flash-a flash-b monitor monitor-a monitor-b 88.PHONY: flash flash-a flash-b monitor monitor-a monitor-b
89.PHONY: test test-unit test-integration test-e2e test-all 89.PHONY: test test-unit test-integration test-e2e test-all
90.PHONY: test-smoke test-api test-network test-portal test-payment 90.PHONY: test-smoke test-api test-network test-portal test-payment test-wifi-setup
91.PHONY: test-reset-auth test-session-expiry test-dns-firewall test-cvm 91.PHONY: test-reset-auth test-session-expiry test-dns-firewall test-cvm
92.PHONY: tokens wallet-setup wallet-info wallet-balance mint-token send-token 92.PHONY: tokens wallet-setup wallet-info wallet-balance mint-token send-token
93.PHONY: clean erase-nvs reset serial-log bootstrap-config 93.PHONY: clean erase-nvs reset serial-log bootstrap-config
@@ -118,6 +118,7 @@ help:
118 @echo " test-dns-firewall DNS hijack + NAT filter test" 118 @echo " test-dns-firewall DNS hijack + NAT filter test"
119 @echo " test-session-expiry Session lifecycle with 65s expiry wait" 119 @echo " test-session-expiry Session lifecycle with 65s expiry wait"
120 @echo " test-cvm ContextVM protocol integration test" 120 @echo " test-cvm ContextVM protocol integration test"
121 @echo " test-wifi-setup WiFi setup page E2E tests (Playwright)"
121 @echo "" 122 @echo ""
122 @echo "ContextVM:" 123 @echo "ContextVM:"
123 @echo " cvm-pubkey Print board's ContextVM npub" 124 @echo " cvm-pubkey Print board's ContextVM npub"
@@ -273,6 +274,11 @@ test-portal:
273 @echo "=== Running Playwright portal tests ===" 274 @echo "=== Running Playwright portal tests ==="
274 cd tests/e2e && npx playwright test captive-portal.spec.mjs 275 cd tests/e2e && npx playwright test captive-portal.spec.mjs
275 276
277test-wifi-setup:
278 $(call _require_board_lock)
279 @echo "=== Running WiFi setup E2E tests ==="
280 cd tests/e2e && npx playwright test wifi-setup.spec.mjs
281
276test-payment: 282test-payment:
277 $(call _require_board_lock) 283 $(call _require_board_lock)
278 @echo "=== Running payment tests ===" 284 @echo "=== Running payment tests ==="
diff --git a/main/captive_portal.c b/main/captive_portal.c
index 0c1d33b..d275b59 100644
--- a/main/captive_portal.c
+++ b/main/captive_portal.c
@@ -277,17 +277,19 @@ static esp_err_t redirect_to_portal_handler(httpd_req_t *req)
277 return portal_handler(req); 277 return portal_handler(req);
278} 278}
279 279
280static esp_err_t catchall_handler(httpd_req_t *req) 280static esp_err_t catchall_err_handler(httpd_req_t *req, httpd_err_code_t err)
281{ 281{
282 ESP_LOGI(TAG, "Catchall: GET %s → 302 → http://%s/", req->uri, s_ap_ip_str); 282 if (err == HTTPD_404_NOT_FOUND) {
283 httpd_resp_set_status(req, "302 Found"); 283 ESP_LOGI(TAG, "Catchall 404: GET %s → 302 → http://%s/", req->uri, s_ap_ip_str);
284 284 httpd_resp_set_status(req, "302 Found");
285 char location[64]; 285 char location[64];
286 snprintf(location, sizeof(location), "http://%s/", s_ap_ip_str); 286 snprintf(location, sizeof(location), "http://%s/", s_ap_ip_str);
287 httpd_resp_set_hdr(req, "Location", location); 287 httpd_resp_set_hdr(req, "Location", location);
288 httpd_resp_set_hdr(req, "Connection", "close"); 288 httpd_resp_set_hdr(req, "Connection", "close");
289 httpd_resp_send(req, NULL, 0); 289 httpd_resp_send(req, NULL, 0);
290 return ESP_OK; 290 return ESP_OK;
291 }
292 return ESP_FAIL;
291} 293}
292 294
293static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler }; 295static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler };
@@ -303,7 +305,6 @@ static const httpd_uri_t uri_success = { .uri = "/success.txt", .method = HTTP_G
303static const httpd_uri_t uri_ncsi = { .uri = "/ncsi.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; 305static const httpd_uri_t uri_ncsi = { .uri = "/ncsi.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler };
304static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; 306static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler };
305static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; 307static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler };
306static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler };
307 308
308static const char SETUP_HTML_TEMPLATE[] = \ 309static const char SETUP_HTML_TEMPLATE[] = \
309"<!DOCTYPE html>" 310"<!DOCTYPE html>"
@@ -545,7 +546,7 @@ static esp_err_t wifi_connect_handler(httpd_req_t *req) {
545 int content_len = req->content_len; 546 int content_len = req->content_len;
546 if (content_len <= 0 || content_len > 1024) { 547 if (content_len <= 0 || content_len > 1024) {
547 httpd_resp_set_type(req, "application/json"); 548 httpd_resp_set_type(req, "application/json");
548 httpd_resp_send(req, "{\"ok\":false,\"error\":\"invalid request\"}", 33); 549 httpd_resp_send(req, "{\"ok\":false,\"error\":\"invalid request\"}", HTTPD_RESP_USE_STRLEN);
549 return ESP_OK; 550 return ESP_OK;
550 } 551 }
551 552
@@ -566,7 +567,7 @@ static esp_err_t wifi_connect_handler(httpd_req_t *req) {
566 free(body); 567 free(body);
567 if (!json) { 568 if (!json) {
568 httpd_resp_set_type(req, "application/json"); 569 httpd_resp_set_type(req, "application/json");
569 httpd_resp_send(req, "{\"ok\":false,\"error\":\"invalid JSON\"}", 33); 570 httpd_resp_send(req, "{\"ok\":false,\"error\":\"invalid JSON\"}", HTTPD_RESP_USE_STRLEN);
570 return ESP_OK; 571 return ESP_OK;
571 } 572 }
572 573
@@ -575,7 +576,7 @@ static esp_err_t wifi_connect_handler(httpd_req_t *req) {
575 if (!ssid_item || !cJSON_IsString(ssid_item)) { 576 if (!ssid_item || !cJSON_IsString(ssid_item)) {
576 cJSON_Delete(json); 577 cJSON_Delete(json);
577 httpd_resp_set_type(req, "application/json"); 578 httpd_resp_set_type(req, "application/json");
578 httpd_resp_send(req, "{\"ok\":false,\"error\":\"missing ssid\"}", 32); 579 httpd_resp_send(req, "{\"ok\":false,\"error\":\"missing ssid\"}", HTTPD_RESP_USE_STRLEN);
579 return ESP_OK; 580 return ESP_OK;
580 } 581 }
581 582
@@ -586,7 +587,7 @@ static esp_err_t wifi_connect_handler(httpd_req_t *req) {
586 if (err != ESP_OK) { 587 if (err != ESP_OK) {
587 cJSON_Delete(json); 588 cJSON_Delete(json);
588 httpd_resp_set_type(req, "application/json"); 589 httpd_resp_set_type(req, "application/json");
589 httpd_resp_send(req, "{\"ok\":false,\"error\":\"save failed\"}", 33); 590 httpd_resp_send(req, "{\"ok\":false,\"error\":\"save failed\"}", HTTPD_RESP_USE_STRLEN);
590 return ESP_OK; 591 return ESP_OK;
591 } 592 }
592 593
@@ -600,7 +601,7 @@ static esp_err_t wifi_connect_handler(httpd_req_t *req) {
600 cJSON_Delete(json); 601 cJSON_Delete(json);
601 602
602 httpd_resp_set_type(req, "application/json"); 603 httpd_resp_set_type(req, "application/json");
603 httpd_resp_send(req, "{\"ok\":true}", 10); 604 httpd_resp_send(req, "{\"ok\":true}", HTTPD_RESP_USE_STRLEN);
604 return ESP_OK; 605 return ESP_OK;
605} 606}
606 607
@@ -644,7 +645,6 @@ esp_err_t captive_portal_start(const char *ap_ip_str)
644 645
645 httpd_config_t config = HTTPD_DEFAULT_CONFIG(); 646 httpd_config_t config = HTTPD_DEFAULT_CONFIG();
646 config.max_uri_handlers = 20; 647 config.max_uri_handlers = 20;
647 config.uri_match_fn = httpd_uri_match_wildcard;
648 648
649 esp_err_t ret = httpd_start(&s_server, &config); 649 esp_err_t ret = httpd_start(&s_server, &config);
650 if (ret != ESP_OK) { 650 if (ret != ESP_OK) {
@@ -666,10 +666,14 @@ esp_err_t captive_portal_start(const char *ap_ip_str)
666 httpd_register_uri_handler(s_server, &uri_connecttest); 666 httpd_register_uri_handler(s_server, &uri_connecttest);
667 httpd_register_uri_handler(s_server, &uri_wpad); 667 httpd_register_uri_handler(s_server, &uri_wpad);
668 httpd_register_uri_handler(s_server, &uri_setup); 668 httpd_register_uri_handler(s_server, &uri_setup);
669 httpd_register_uri_handler(s_server, &uri_wifi_scan); 669 ret = httpd_register_uri_handler(s_server, &uri_wifi_scan);
670 httpd_register_uri_handler(s_server, &uri_wifi_connect); 670 ESP_LOGI(TAG, "Registered /wifi/scan: %s", esp_err_to_name(ret));
671 httpd_register_uri_handler(s_server, &uri_wifi_status); 671 ret = httpd_register_uri_handler(s_server, &uri_wifi_connect);
672 httpd_register_uri_handler(s_server, &uri_catchall); 672 ESP_LOGI(TAG, "Registered /wifi/connect: %s", esp_err_to_name(ret));
673 ret = httpd_register_uri_handler(s_server, &uri_wifi_status);
674 ESP_LOGI(TAG, "Registered /wifi/status: %s", esp_err_to_name(ret));
675
676 httpd_register_err_handler(s_server, HTTPD_404_NOT_FOUND, catchall_err_handler);
673 677
674 ESP_LOGI(TAG, "Captive portal started on port 80"); 678 ESP_LOGI(TAG, "Captive portal started on port 80");
675 return ESP_OK; 679 return ESP_OK;
diff --git a/main/tollgate_main.c b/main/tollgate_main.c
index 9960ea5..ec6ffc8 100644
--- a/main/tollgate_main.c
+++ b/main/tollgate_main.c
@@ -37,11 +37,13 @@ static esp_netif_t *s_ap_netif = NULL;
37static int s_retry_count = 0; 37static int s_retry_count = 0;
38static int s_total_retries = 0; 38static int s_total_retries = 0;
39static bool s_services_running = false; 39static bool s_services_running = false;
40static bool s_start_in_error_state = false;
40static SemaphoreHandle_t s_services_mutex = NULL; 41static SemaphoreHandle_t s_services_mutex = NULL;
41static char s_ap_ip_str[16] = "10.0.0.1"; 42static char s_ap_ip_str[16] = "10.0.0.1";
42 43
43static void start_services(void); 44static void start_services(void);
44static void stop_services(void); 45static void stop_services(void);
46static void services_start_task(void *pvParameters);
45 47
46static void wifi_event_handler(void *arg, esp_event_base_t event_base, 48static void wifi_event_handler(void *arg, esp_event_base_t event_base,
47 int32_t event_id, void *event_data) 49 int32_t event_id, void *event_data)
@@ -60,11 +62,15 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base,
60 tollgate_client_on_sta_disconnected(); 62 tollgate_client_on_sta_disconnected();
61 display_notify_wifi_disconnected(); 63 display_notify_wifi_disconnected();
62 if (s_services_running) { 64 if (s_services_running) {
63 stop_services();
64 display_set_state(DISPLAY_ERROR); 65 display_set_state(DISPLAY_ERROR);
65 } 66 }
66 if (s_total_retries >= MAX_TOTAL_RETRIES) { 67 if (s_total_retries >= MAX_TOTAL_RETRIES) {
67 ESP_LOGW(TAG, "All WiFi retries exhausted"); 68 ESP_LOGW(TAG, "All WiFi retries exhausted");
69 if (!s_services_running) {
70 s_start_in_error_state = true;
71 ESP_LOGI(TAG, "Starting services for /setup access (no upstream)");
72 xTaskCreate(services_start_task, "svc_start", 32768, NULL, 5, NULL);
73 }
68 const tollgate_config_t *cfg = tollgate_config_get(); 74 const tollgate_config_t *cfg = tollgate_config_get();
69 char portal_url[128]; 75 char portal_url[128];
70 snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str); 76 snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str);
@@ -86,6 +92,11 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base,
86 esp_wifi_connect(); 92 esp_wifi_connect();
87 } else { 93 } else {
88 ESP_LOGW(TAG, "No more WiFi networks to try"); 94 ESP_LOGW(TAG, "No more WiFi networks to try");
95 if (!s_services_running) {
96 s_start_in_error_state = true;
97 ESP_LOGI(TAG, "Starting services for /setup access (no upstream)");
98 xTaskCreate(services_start_task, "svc_start", 32768, NULL, 5, NULL);
99 }
89 const tollgate_config_t *cfg = tollgate_config_get(); 100 const tollgate_config_t *cfg = tollgate_config_get();
90 char portal_url[128]; 101 char portal_url[128];
91 snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str); 102 snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str);
@@ -143,7 +154,6 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base,
143 } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP) { 154 } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP) {
144 ESP_LOGW(TAG, "Lost IP address"); 155 ESP_LOGW(TAG, "Lost IP address");
145 xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT); 156 xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
146 stop_services();
147 } 157 }
148} 158}
149 159
@@ -202,6 +212,12 @@ static void start_services(void)
202 if (s_services_mutex) xSemaphoreGive(s_services_mutex); 212 if (s_services_mutex) xSemaphoreGive(s_services_mutex);
203 ESP_LOGI(TAG, "=== TollGate services started ==="); 213 ESP_LOGI(TAG, "=== TollGate services started ===");
204 214
215 if (s_start_in_error_state) {
216 s_start_in_error_state = false;
217 ESP_LOGI(TAG, "Services started in error state (no upstream), keeping DISPLAY_ERROR");
218 return;
219 }
220
205 display_set_state(DISPLAY_READY); 221 display_set_state(DISPLAY_READY);
206 char portal_url[128]; 222 char portal_url[128];
207 snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str); 223 snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str);
diff --git a/tests/e2e/wifi-setup.spec.mjs b/tests/e2e/wifi-setup.spec.mjs
new file mode 100644
index 0000000..31bc2cf
--- /dev/null
+++ b/tests/e2e/wifi-setup.spec.mjs
@@ -0,0 +1,440 @@
1import { test, expect } from '@playwright/test';
2
3const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1';
4const PORTAL_URL = `http://${PORTAL_IP}`;
5
6const SETUP_HTML = `<!DOCTYPE html>
7<html><head>
8<meta charset='utf-8'>
9<meta name='viewport' content='width=device-width, initial-scale=1'>
10<title>TollGate Setup</title>
11<style>
12*{box-sizing:border-box;margin:0;padding:0}
13body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
14background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center;
15min-height:100vh;padding:20px}
16.card{background:#1a1a1a;border:1px solid #333;border-radius:16px;padding:32px;
17max-width:400px;width:100%;text-align:center}
18h1{font-size:24px;margin-bottom:8px;color:#f7931a}
19.subtitle{color:#888;margin-bottom:20px;font-size:13px}
20.networks{margin-top:16px;text-align:left}
21.net-item{background:#252525;border:1px solid #333;border-radius:8px;
22padding:12px;margin-bottom:8px;cursor:pointer;display:flex;justify-content:space-between;align-items:center}
23.net-item:hover{border-color:#f7931a}
24.net-item:active{background:#333}
25.net-ssid{font-size:14px}
26.net-rssi{font-size:11px;color:#888}
27.net-lock{color:#f7931a;margin-right:4px}
28.manual{margin-top:12px}
29input{width:100%;background:#252525;border:1px solid #333;border-radius:8px;
30color:#fff;padding:12px;font-size:14px;margin-bottom:8px;outline:none}
31input:focus{border-color:#f7931a}
32.btn{background:#f7931a;color:#000;border:none;border-radius:8px;padding:14px 28px;
33font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px}
34.btn:hover{background:#e8850f}
35.btn:disabled{background:#333;color:#666;cursor:not-allowed}
36#status{margin-top:12px;padding:10px;border-radius:8px;display:none;font-size:13px}
37#status.success{display:block;background:#1a472a;color:#4caf50}
38#status.error{display:block;background:#471a1a;color:#f44336}
39#status.processing{display:block;background:#1a3a47;color:#2196f3}
40.refresh{background:none;border:1px solid #444;color:#aaa;border-radius:6px;
41padding:6px 12px;font-size:12px;cursor:pointer;margin-top:4px}
42.refresh:hover{border-color:#f7931a;color:#f7931a}
43#manualForm{display:none;margin-top:12px}
44</style>
45</head><body>
46<div class='card'>
47<h1>TollGate Setup</h1>
48<p class='subtitle'>Configure upstream WiFi</p>
49<div id='scanStatus'>Scanning...</div>
50<div class='networks' id='networkList'></div>
51<button class='refresh' onclick='scanWifi()'>Rescan</button>
52<button class='refresh' onclick='showManual()'>Manual entry</button>
53<div id='manualForm'>
54<input id='manualSsid' placeholder='SSID'>
55<input id='manualPass' type='password' placeholder='Password'>
56<button class='btn' onclick='connectManual()'>Connect</button>
57</div>
58<div id='passwordForm' style='display:none'>
59<p style='margin:12px 0 8px;text-align:left' id='selectedNetwork'></p>
60<input id='wifiPass' type='password' placeholder='WiFi password'>
61<button class='btn' onclick='connectSelected()'>Connect</button>
62</div>
63<div id='status'></div>
64</div>
65<script>
66const apIp='${PORTAL_IP}';
67let selectedSsid='';
68function showStatus(msg,type){const s=document.getElementById('status');
69s.textContent=msg;s.className=type;}
70function scanWifi(){
71document.getElementById('scanStatus').textContent='Scanning...';
72document.getElementById('networkList').innerHTML='';
73fetch('/wifi/scan').then(r=>r.json()).then(aps=>{
74document.getElementById('scanStatus').textContent=aps.length+' networks found';
75const list=document.getElementById('networkList');
76aps.forEach(ap=>{
77const div=document.createElement('div');
78div.className='net-item';
79const lock=ap.secured?'<span class=net-lock>&#128274;</span>':'';
80div.innerHTML='<span class=net-ssid>'+lock+ap.ssid+'</span><span class=net-rssi>'+ap.rssi+' dBm</span>';
81div.onclick=()=>selectNetwork(ap.ssid,ap.secured);
82list.appendChild(div);
83});
84}).catch(e=>{document.getElementById('scanStatus').textContent='Scan failed';});
85}
86function selectNetwork(ssid,secured){
87selectedSsid=ssid;
88document.getElementById('selectedNetwork').textContent='Connect to: '+ssid;
89document.getElementById('passwordForm').style.display='block';
90document.getElementById('scanStatus').style.display='none';
91document.getElementById('networkList').style.display='none';
92document.querySelector('.refresh').style.display='none';
93if(!secured){connectSelected();}
94}
95function showManual(){
96document.getElementById('manualForm').style.display='block';
97}
98function connectSelected(){
99const pass=document.getElementById('wifiPass').value;
100doConnect(selectedSsid,pass);
101}
102function connectManual(){
103const ssid=document.getElementById('manualSsid').value.trim();
104const pass=document.getElementById('manualPass').value;
105if(!ssid){showStatus('Enter SSID','error');return;}
106doConnect(ssid,pass);
107}
108function doConnect(ssid,pass){
109showStatus('Connecting to '+ssid+'...','processing');
110fetch('/wifi/connect',{method:'POST',headers:{'Content-Type':'application/json'},
111body:JSON.stringify({ssid:ssid,password:pass})})
112.then(r=>r.json()).then(d=>{
113if(d.ok){showStatus('Connected! Device is restarting...','success');}
114else{showStatus('Failed: '+(d.error||'unknown'),'error');}
115}).catch(e=>{showStatus('Connection error','error');});
116}
117scanWifi();
118</script>
119</body></html>`;
120
121const MOCK_AP_LIST = [
122 { ssid: 'HomeNetwork', rssi: -42, secured: true },
123 { ssid: 'CafeWiFi', rssi: -67, secured: true },
124 { ssid: 'OpenPublic', rssi: -75, secured: false },
125 { ssid: 'Neighbor5G', rssi: -81, secured: true },
126];
127
128async function setupMockRoutes(page, overrides = {}) {
129 const scanResponse = overrides.scanResponse || MOCK_AP_LIST;
130 const connectHandler = overrides.connectHandler || (() => ({ ok: true }));
131
132 await page.route('**/setup', async route => {
133 await route.fulfill({
134 status: 200,
135 contentType: 'text/html',
136 body: SETUP_HTML,
137 });
138 });
139
140 await page.route('**/wifi/scan', async route => {
141 await route.fulfill({
142 status: 200,
143 contentType: 'application/json',
144 body: JSON.stringify(scanResponse),
145 });
146 });
147
148 await page.route('**/wifi/connect', async route => {
149 const request = route.request();
150 const body = request.postDataJSON();
151 const response = connectHandler(body);
152 await route.fulfill({
153 status: 200,
154 contentType: 'application/json',
155 body: JSON.stringify(response),
156 });
157 });
158}
159
160async function loadSetupPage(page) {
161 await page.goto('http://tollgate.test/setup', { waitUntil: 'networkidle' });
162}
163
164test.describe('WiFi Setup \u2014 Layer 1: API Endpoints (needs live board)', () => {
165
166 test('GET /setup redirects to portal on configured board', async ({ request }) => {
167 const resp = await request.fetch(`${PORTAL_URL}/setup`, {
168 maxRedirects: 0,
169 });
170 expect(resp.status()).toBe(302);
171 const location = resp.headers()['location'];
172 expect(location).toContain(PORTAL_IP);
173 expect(location).toMatch(/\/$/);
174 });
175
176 test('GET /wifi/scan returns JSON array with valid AP objects', async ({ request }) => {
177 const resp = await request.get(`${PORTAL_URL}/wifi/scan`);
178 expect(resp.status()).toBe(200);
179 const data = await resp.json();
180 expect(Array.isArray(data)).toBe(true);
181 if (data.length > 0) {
182 const ap = data[0];
183 expect(ap).toHaveProperty('ssid');
184 expect(typeof ap.ssid).toBe('string');
185 expect(ap).toHaveProperty('rssi');
186 expect(typeof ap.rssi).toBe('number');
187 expect(ap).toHaveProperty('secured');
188 expect(typeof ap.secured).toBe('boolean');
189 }
190 });
191
192 test('GET /wifi/status returns connection state', async ({ request }) => {
193 const resp = await request.get(`${PORTAL_URL}/wifi/status`);
194 expect(resp.status()).toBe(200);
195 const data = await resp.json();
196 expect(data).toHaveProperty('connected');
197 expect(typeof data.connected).toBe('boolean');
198 if (data.connected) {
199 expect(data).toHaveProperty('ip');
200 expect(data.ip).toMatch(/\d+\.\d+\.\d+\.\d+/);
201 expect(data).toHaveProperty('ssid');
202 }
203 });
204
205 test('POST /wifi/connect rejects empty body', async ({ request }) => {
206 const resp = await request.post(`${PORTAL_URL}/wifi/connect`, {
207 data: '',
208 headers: { 'Content-Type': 'application/json' },
209 });
210 const data = await resp.json();
211 expect(data.ok).toBe(false);
212 });
213
214 test('POST /wifi/connect rejects invalid JSON', async ({ request }) => {
215 const resp = await request.post(`${PORTAL_URL}/wifi/connect`, {
216 data: 'not json at all',
217 headers: { 'Content-Type': 'application/json' },
218 });
219 const data = await resp.json();
220 expect(data.ok).toBe(false);
221 expect(data.error).toBeDefined();
222 });
223
224 test('POST /wifi/connect rejects missing ssid', async ({ request }) => {
225 const resp = await request.post(`${PORTAL_URL}/wifi/connect`, {
226 data: JSON.stringify({ password: 'testpass' }),
227 headers: { 'Content-Type': 'application/json' },
228 });
229 const data = await resp.json();
230 expect(data.ok).toBe(false);
231 expect(data.error).toContain('ssid');
232 });
233
234 test('POST /wifi/connect with valid SSID returns ok or ECONNRESET', async ({ request }) => {
235 const resp = await request.post(`${PORTAL_URL}/wifi/connect`, {
236 data: JSON.stringify({ ssid: 'TestSetupAP', password: 'testpass123' }),
237 headers: { 'Content-Type': 'application/json' },
238 maxRedirects: 0,
239 timeout: 10000,
240 }).catch(() => null);
241
242 if (resp) {
243 const text = await resp.text();
244 try {
245 const data = JSON.parse(text);
246 expect(data.ok).toBe(true);
247 } catch {
248 expect(resp.status()).toBeLessThan(500);
249 }
250 }
251 });
252});
253
254test.describe('WiFi Setup \u2014 Layer 1.5: Redirect (needs live board)', () => {
255 test('redirect Location header contains correct AP IP', async ({ request }) => {
256 const resp = await request.fetch(`${PORTAL_URL}/setup`, {
257 maxRedirects: 0,
258 });
259 const location = resp.headers()['location'];
260 expect(location).toBe(`http://${PORTAL_IP}/`);
261 });
262});
263
264test.describe('WiFi Setup \u2014 Layer 2: HTML UI Interaction', () => {
265
266 test('page renders with title and subtitle', async ({ page }) => {
267 await setupMockRoutes(page);
268 await loadSetupPage(page);
269 await expect(page.locator('h1')).toHaveText('TollGate Setup');
270 await expect(page.locator('.subtitle')).toHaveText('Configure upstream WiFi');
271 });
272
273 test('scan auto-triggers on load and shows network count', async ({ page }) => {
274 await setupMockRoutes(page);
275 await loadSetupPage(page);
276 await expect(page.locator('#scanStatus')).toHaveText(/4 networks found/, { timeout: 5000 });
277 });
278
279 test('network list shows SSID and RSSI for each AP', async ({ page }) => {
280 await setupMockRoutes(page);
281 await loadSetupPage(page);
282 await expect(page.locator('.net-item')).toHaveCount(4);
283 await expect(page.locator('.net-ssid').first()).toContainText('HomeNetwork');
284 await expect(page.locator('.net-rssi').first()).toContainText('-42 dBm');
285 });
286
287 test('secured networks show lock icon', async ({ page }) => {
288 await setupMockRoutes(page);
289 await loadSetupPage(page);
290 const securedItems = page.locator('.net-item');
291 const firstSecured = securedItems.first();
292 await expect(firstSecured.locator('.net-lock')).toBeVisible();
293 });
294
295 test('open networks have no lock icon', async ({ page }) => {
296 await setupMockRoutes(page);
297 await loadSetupPage(page);
298 const openItem = page.locator('.net-item').nth(2);
299 await expect(openItem.locator('.net-lock')).toHaveCount(0);
300 await expect(openItem.locator('.net-ssid')).toContainText('OpenPublic');
301 });
302
303 test('clicking secured network shows password form and hides list', async ({ page }) => {
304 await setupMockRoutes(page);
305 await loadSetupPage(page);
306 await expect(page.locator('.net-item').first()).toBeVisible();
307 await page.locator('.net-item').first().click();
308 await expect(page.locator('#passwordForm')).toBeVisible();
309 await expect(page.locator('#selectedNetwork')).toHaveText('Connect to: HomeNetwork');
310 await expect(page.locator('#networkList')).toBeHidden();
311 await expect(page.locator('#scanStatus')).toBeHidden();
312 });
313
314 test('clicking open network auto-connects without password form', async ({ page }) => {
315 let connectBody = null;
316 await setupMockRoutes(page, {
317 connectHandler: (body) => {
318 connectBody = body;
319 return { ok: true };
320 },
321 });
322 await loadSetupPage(page);
323 const openItem = page.locator('.net-item').nth(2);
324 await openItem.click();
325 await expect(page.locator('#status')).toHaveClass(/processing|success/, { timeout: 5000 });
326 expect(connectBody).toBeTruthy();
327 expect(connectBody.ssid).toBe('OpenPublic');
328 });
329
330 test('manual entry button toggles form visibility', async ({ page }) => {
331 await setupMockRoutes(page);
332 await loadSetupPage(page);
333 await expect(page.locator('#manualForm')).toBeHidden();
334 await page.locator('button:has-text("Manual entry")').click();
335 await expect(page.locator('#manualForm')).toBeVisible();
336 await expect(page.locator('#manualSsid')).toBeVisible();
337 await expect(page.locator('#manualPass')).toBeVisible();
338 });
339
340 test('manual connect with empty SSID shows error', async ({ page }) => {
341 await setupMockRoutes(page);
342 await loadSetupPage(page);
343 await page.locator('button:has-text("Manual entry")').click();
344 await page.locator('#manualSsid').fill('');
345 await page.locator('#manualForm .btn').click();
346 await expect(page.locator('#status')).toHaveClass(/error/);
347 await expect(page.locator('#status')).toContainText('Enter SSID');
348 });
349
350 test('connect sends correct JSON body to /wifi/connect', async ({ page }) => {
351 let capturedBody = null;
352 await setupMockRoutes(page, {
353 connectHandler: (body) => {
354 capturedBody = body;
355 return { ok: true };
356 },
357 });
358 await loadSetupPage(page);
359 await page.locator('.net-item').first().click();
360 await page.locator('#wifiPass').fill('mysecretpass');
361 await page.locator('#passwordForm .btn').click();
362 await expect(page.locator('#status')).toHaveClass(/success|processing/, { timeout: 5000 });
363 expect(capturedBody).toEqual({ ssid: 'HomeNetwork', password: 'mysecretpass' });
364 });
365
366 test('success response shows green status with Connected message', async ({ page }) => {
367 await setupMockRoutes(page, {
368 connectHandler: () => ({ ok: true }),
369 });
370 await loadSetupPage(page);
371 await page.locator('.net-item').first().click();
372 await page.locator('#wifiPass').fill('testpass');
373 await page.locator('#passwordForm .btn').click();
374 await expect(page.locator('#status')).toHaveClass(/success/, { timeout: 5000 });
375 await expect(page.locator('#status')).toContainText('Connected!');
376 });
377
378 test('error response shows red status with failure reason', async ({ page }) => {
379 await setupMockRoutes(page, {
380 connectHandler: () => ({ ok: false, error: 'save failed' }),
381 });
382 await loadSetupPage(page);
383 await page.locator('.net-item').first().click();
384 await page.locator('#wifiPass').fill('wrongpass');
385 await page.locator('#passwordForm .btn').click();
386 await expect(page.locator('#status')).toHaveClass(/error/, { timeout: 5000 });
387 await expect(page.locator('#status')).toContainText('Failed: save failed');
388 });
389
390 test('rescan button clears list and fetches fresh data', async ({ page }) => {
391 let scanCount = 0;
392 await page.route('**/setup', async route => {
393 await route.fulfill({ status: 200, contentType: 'text/html', body: SETUP_HTML });
394 });
395 await page.route('**/wifi/scan', async route => {
396 scanCount++;
397 const data = scanCount === 1 ? MOCK_AP_LIST : [
398 { ssid: 'NewNetwork1', rssi: -30, secured: true },
399 { ssid: 'NewNetwork2', rssi: -55, secured: false },
400 ];
401 await route.fulfill({
402 status: 200,
403 contentType: 'application/json',
404 body: JSON.stringify(data),
405 });
406 });
407 await page.route('**/wifi/connect', async route => {
408 await route.fulfill({
409 status: 200,
410 contentType: 'application/json',
411 body: JSON.stringify({ ok: true }),
412 });
413 });
414 await loadSetupPage(page);
415 await expect(page.locator('.net-item')).toHaveCount(4, { timeout: 5000 });
416 expect(scanCount).toBe(1);
417 await page.locator('button:has-text("Rescan")').click();
418 await expect(page.locator('.net-item')).toHaveCount(2, { timeout: 5000 });
419 await expect(page.locator('.net-ssid').first()).toContainText('NewNetwork1');
420 expect(scanCount).toBe(2);
421 });
422
423});
424
425test.describe('WiFi Setup \u2014 Layer 3: Full E2E (needs unconfigured board)', () => {
426
427 test.skip('full phone flow: scan \u2192 select \u2192 password \u2192 connect \u2192 status', async ({ page }) => {
428 await page.goto(`${PORTAL_URL}/setup`);
429 await expect(page.locator('h1')).toHaveText('TollGate Setup');
430 await expect(page.locator('#scanStatus')).not.toHaveText('Scanning...', { timeout: 10000 });
431 const networkCount = await page.locator('.net-item').count();
432 expect(networkCount).toBeGreaterThan(0);
433 const firstSsid = await page.locator('.net-ssid').first().textContent();
434 await page.locator('.net-item').first().click();
435 await expect(page.locator('#passwordForm')).toBeVisible();
436 await page.locator('#wifiPass').fill('test-password');
437 await page.locator('#passwordForm .btn').click();
438 await expect(page.locator('#status')).toHaveClass(/success|error|processing/, { timeout: 15000 });
439 });
440});