upleb.uk

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

summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/e2e/wifi-setup.spec.mjs440
1 files changed, 440 insertions, 0 deletions
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});