/* Browser regression check. Run: node tests/ui_windows.cjs [path-to-playwright] Uses a simulated Python bridge; never opens a COM port or sends MAVLink. */ const assert=require('node:assert/strict'); const fs=require('node:fs'); const path=require('node:path'); const http=require('node:http'); const {chromium}=require(process.argv[2]||'playwright'); const root=path.resolve(__dirname,'..'),output=path.join(root,'runtime','ui-check'); fs.mkdirSync(output,{recursive:true}); const server=http.createServer((req,res)=>{ const url=decodeURIComponent(new URL(req.url,'http://localhost').pathname); let file=path.resolve(root,'.'+url); if(url==='/model.stl'||url==='/model')file=path.join(root,'runtime','vehicle_model.stl'); if(!file.startsWith(root+path.sep)||!fs.existsSync(file)||!fs.statSync(file).isFile()){ res.writeHead(404);res.end();return; } const types={'.html':'text/html','.js':'text/javascript','.css':'text/css','.stl':'model/stl'}; res.setHeader('Content-Type',types[path.extname(file)]||'application/octet-stream'); fs.createReadStream(file).pipe(res); }); async function main(){ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); const browser=await chromium.launch({headless:true}); try{ const page=await browser.newPage({viewport:{width:1080,height:720}}); const errors=[];page.on('pageerror',error=>errors.push(error.message)); const setArmed=value=>page.evaluate(value=>{ Object.assign(testBridge.state,{connected:true,armed:value,armed_known:true,app_state:value?'armed':'connected_disarmed'});render(testBridge.state); },value); await page.addInitScript(()=>{ const clone=value=>JSON.parse(JSON.stringify(value)); const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null}; const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null}; const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20}; const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}; const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[],axes:[],axisVisual:[],stops:0}; const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false}; window.testPads=[];Object.defineProperty(navigator,'getGamepads',{value:()=>window.testPads}); window.pywebview={api:{ initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}), refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state), get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>{calls.stops++;return true},set_axes:async(...axes)=>{calls.axes.push(axes);calls.axisVisual.push({joy:joystick3D.getState(),vertical:verticalControl.getState().value});return true}, save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)}, save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)}, connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)}, disconnect:async()=>{Object.assign(state,{connected:false,armed_known:false,app_state:'disconnected'});return true}, reverse_motor:async servo=>{calls.reverse++;if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>state.servos[servo].direction*=-1,650);return true}, set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true}, start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)}, cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)}, read_console:async()=>{calls.console++;return {rx:['2026-10-01T10:11:12.123 [RX] {"mavpackettype":"COMMAND_ACK","command":400,"result":0,"sample":1}', '2026-10-01T10:11:12.124 [RX_BAD_DATA] corrupt_frame='+calls.console, '2026-10-01T10:11:12.125 [RX] {"mavpackettype":"HEARTBEAT"}'],tx:['2026-10-01T10:11:12.124 [TX_COMMAND] ARM '+calls.console]}}, minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true }}; }); await page.goto('http://127.0.0.1:'+server.address().port+'/web/index.html'); await page.waitForFunction(()=>bridgeReady); const openLink=()=>page.locator('[data-open="link-modal"]').click(); const close=async id=>{await page.locator('#'+id+' [data-close]').first().click();await page.waitForFunction(id=>!document.getElementById(id).open,id)}; await openLink(); await page.locator('#baud').selectOption('115200'); await page.locator('#link-modal').getByRole('button',{name:'Cancel',exact:true}).click(); await page.waitForFunction(()=>!document.getElementById('link-modal').open); await openLink();assert.equal(await page.locator('#baud').inputValue(),'57600'); await page.locator('#custom-port-input').fill('COM42'); await page.locator('#custom-port-btn').click(); assert(await page.locator('#link-modal').evaluate(d=>d.open)); assert.equal(await page.locator('#port').inputValue(),'COM42'); await page.locator('#baud').selectOption('115200'); assert(await page.locator('#link-form [type="submit"]').evaluate(el=>{el.click();return el.disabled})); await page.waitForFunction(()=>!document.getElementById('link-modal').open); await page.locator('#refresh').click();assert.equal(await page.locator('#port').inputValue(),'COM42'); await page.locator('#connect').click(); await page.waitForFunction(()=>testBridge.calls.connect.length===1); assert.equal(await page.evaluate(()=>testBridge.calls.connect[0].baud),115200); await page.locator('[data-open="battery-modal"]').click(); await page.locator('#battery-form [name="cells"]').fill('5'); await page.keyboard.press('Escape');await page.waitForFunction(()=>!document.getElementById('battery-modal').open); await page.locator('[data-open="battery-modal"]').click(); assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'4'); await page.evaluate(()=>testBridge.failBattery=true); await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click(); await page.locator('#battery-form .form-feedback').waitFor({state:'visible'}); assert(await page.locator('#battery-modal').evaluate(d=>d.open)); assert.equal(await page.locator('#battery-form .form-feedback').textContent(),'Battery save failed'); await page.evaluate(()=>testBridge.failBattery=false); await page.locator('#battery-form [name="cells"]').fill('5'); await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click(); await page.waitForFunction(()=>!document.getElementById('battery-modal').open); await page.locator('[data-open="battery-modal"]').click(); assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'5'); await page.screenshot({path:path.join(output,'battery.png')});await close('battery-modal'); await page.locator('[data-open="motors-modal"]').click(); assert.equal(await page.locator('#motor-list > .motor-row').count(),3); await page.locator('#motor-list > .motor-row').first().getByRole('button').click(); await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Confirming…'); assert(await page.locator('#motor-list > .motor-row').first().getByRole('button').isDisabled()); await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Reversed'); await page.evaluate(()=>testBridge.failMotor=true); await page.locator('#motor-list > .motor-row').first().locator('select').selectOption('2'); await page.locator('#motor-feedback').waitFor({state:'visible'}); assert.equal(await page.locator('#motor-list > .motor-row').first().locator('select').inputValue(),'1'); await page.screenshot({path:path.join(output,'motors.png')});await close('motors-modal'); await page.locator('#link-quality').click(); assert.equal(await page.locator('#quality-detail-rtt').textContent(),'24 ms'); await page.locator('#heavy-quality-run').click(); assert(await page.locator('#heavy-quality-run').isDisabled()); await page.locator('#heavy-quality-cancel').waitFor({state:'visible'}); assert.equal(await page.evaluate(()=>testBridge.calls.heavy),1); await page.locator('#heavy-quality-cancel').click(); await page.waitForFunction(()=>!testBridge.state.heavy_quality.running); await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal'); await page.locator('#console').click(); await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('COMMAND_ACK')); await page.locator('#console-pause').click(); const paused=await page.locator('#console-rx-output').textContent(); await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused); await page.locator('#console-pause').click(); await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused); await page.screenshot({path:path.join(output,'console.png')});await page.locator('#console-close').click(); for(const viewport of [{width:900,height:560},{width:480,height:720}]){ await page.setViewportSize(viewport); for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','controller-modal']){ await page.evaluate(id=>openModal(id),id); const box=await page.locator('#'+id).boundingBox(); assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport'); assert.equal(await page.evaluate(()=>canDrive()),false); const closeGeometry=await page.locator('#'+id+' .modal-close').evaluate(button=>{ const b=button.getBoundingClientRect(),s=button.querySelector('svg').getBoundingClientRect(); return {dx:Math.abs(b.x+b.width/2-s.x-s.width/2),dy:Math.abs(b.y+b.height/2-s.y-s.height/2),bg:getComputedStyle(button).backgroundColor}; }); assert(closeGeometry.dx<.5&&closeGeometry.dy<.5,id+' close icon must be centered'); assert.equal(closeGeometry.bg,'rgba(0, 0, 0, 0)'); await close(id); } } await page.setViewportSize({width:1080,height:720});await openLink(); await page.screenshot({path:path.join(output,'link.png')});await close('link-modal'); await page.setViewportSize({width:1080,height:720}); await page.evaluate(()=>{Object.assign(testBridge.state,{connected:true,armed:false,armed_known:true,app_state:'connected_disarmed',battery:{voltage:15.8,percent:72,cells:4,low:false}});focusChanged(true);render(testBridge.state)}); await page.locator('#engage').focus(); assert.equal(await page.locator('#engage').evaluate(el=>getComputedStyle(el).outlineStyle),'none'); await page.evaluate(()=>startEngageHold()); assert.equal(await page.locator('#input-joystick').isDisabled(),true); await page.evaluate(()=>cancelEngageHold(false)); assert.equal(await page.locator('#input-joystick').isDisabled(),false); await page.locator('#input-joystick').click(); for(const pending of ['arm','disarm']){ await page.evaluate(pending=>{testBridge.state.pending_command=pending;render(testBridge.state)},pending); assert.equal(await page.locator('#input-keyboard').isDisabled(),true); assert.equal(await page.evaluate(()=>{setInputMode('keyboard');return inputMode}),'joystick'); } await page.evaluate(()=>{delete testBridge.state.pending_command;testBridge.state.armed_known=false;render(testBridge.state)}); assert.equal(await page.locator('#input-keyboard').isDisabled(),true); await setArmed(true); assert.equal(await page.locator('#input-keyboard').isDisabled(),true); await page.waitForFunction(()=>document.getElementById('controller-status').textContent.includes('Waiting')); await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[1,-1,0,-1]}]); await page.waitForFunction(()=>document.getElementById('controller-status').textContent==='Center sticks to resume'); assert.deepEqual(await page.evaluate(()=>({...control})),{turn:0,forward:0,vertical:0}); await page.evaluate(()=>testPads[0].axes=[0,0,0,0]); await page.waitForFunction(()=>!controllerNeedCenter); await page.keyboard.down('w');await page.waitForTimeout(420);await page.keyboard.up('w'); assert.equal(await page.evaluate(()=>control.forward),0); await page.evaluate(()=>testPads[0].axes=[1,-1,0,-1]); await page.waitForFunction(()=>control.vertical>0&&control.vertical<.8); await page.waitForFunction(()=>control.vertical>.99); const matching=await page.evaluate(()=>{ const joy=joystick3D.getState(),sent=testBridge.calls.axes.at(-1); return {length:Math.hypot(control.turn,control.forward),joy,vertical:control.vertical,sent,visual:testBridge.calls.axisVisual.at(-1),control:{...control}}; }); assert(matching.length<=1.00001); assert.equal(matching.joy.x,matching.control.turn);assert.equal(matching.joy.y,-matching.control.forward); assert.equal(matching.sent[0],-matching.visual.joy.y);assert.equal(matching.sent[1],matching.visual.joy.x);assert.equal(matching.sent[2],matching.visual.vertical); // Dispatch even though CSS excludes pointers, to exercise the handler gate. await page.locator('#joystick-canvas').dispatchEvent('pointerdown',{pointerId:91,clientX:20,clientY:20,button:0}); await page.locator('#heave').dispatchEvent('pointerdown',{pointerId:92,clientX:20,clientY:20,button:0}); assert.equal(await page.evaluate(()=>joystick3D.getState().active||verticalControl.getState().active),false); await page.locator('#console').click(); await page.waitForFunction(()=>!document.getElementById('console-dock').hidden); await page.waitForFunction(()=>document.querySelector('.rx-dot').classList.contains('lit')&&document.querySelector('.tx-dot').classList.contains('lit')); await page.locator('#console-pause').click(); await page.waitForTimeout(500); assert.equal(await page.locator('.rx-dot').evaluate(el=>el.classList.contains('lit')),false); assert.equal(await page.locator('.tx-dot').evaluate(el=>el.classList.contains('lit')),false); await page.locator('#console-pause').click(); assert.equal(await page.evaluate(()=>canDrive()),true); assert.equal(await page.locator('#console-rx-output .console-row').count(),2); await page.locator('#console-filter').selectOption('all'); assert.equal(await page.locator('#console-rx-output .console-row').count(),3); await page.locator('#console-rx-output details').first().locator('summary').click(); await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output details').first().evaluate(el=>el.open),true); await page.screenshot({path:path.join(output,'controller-console.png')});await page.locator('#console-close').click(); await page.evaluate(()=>testPads[0].axes=[0,0,0,0]); await page.waitForFunction(()=>control.vertical>0&&control.vertical<.95); await page.waitForFunction(()=>control.vertical===0&&control.turn===0&&control.forward===0); await setArmed(false); assert.equal(await page.locator('#input-keyboard').isDisabled(),false); await page.locator('#controller-settings').click(); await page.screenshot({path:path.join(output,'controller-setup.png')}); await page.locator('#controller-form [name="invertTurn"]').check(); await page.locator('#controller-form').getByRole('button',{name:'Cancel',exact:true}).click(); await page.waitForFunction(()=>!document.getElementById('controller-modal').open); await page.locator('#controller-settings').click(); assert.equal(await page.locator('#controller-form [name="invertTurn"]').isChecked(),false); await page.locator('#controller-form [name="invertTurn"]').check(); await page.locator('#controller-form').getByRole('button',{name:'Save mapping',exact:true}).click(); await page.waitForFunction(()=>!document.getElementById('controller-modal').open); await setArmed(true); await page.waitForFunction(()=>!controllerNeedCenter); await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]); await page.waitForFunction(()=>control.turn<-.2); await page.evaluate(()=>testPads=[]); await page.waitForFunction(()=>control.turn===0); await page.evaluate(()=>testPads=[{connected:true,id:'Test controller',index:0,axes:[.5,0,0,0]}]); await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0); await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter); await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2); await page.evaluate(()=>focusChanged(false));assert.equal(await page.evaluate(()=>control.turn),0); await page.evaluate(()=>focusChanged(true));await page.waitForTimeout(300);assert.equal(await page.evaluate(()=>control.turn),0); await page.evaluate(()=>testPads[0].axes=[0,0,0,0]);await page.waitForFunction(()=>!controllerNeedCenter); await page.evaluate(()=>testPads[0].axes=[.5,0,0,0]);await page.waitForFunction(()=>control.turn<-.2); await page.evaluate(()=>testPads[0].id='Different controller'); await page.waitForFunction(()=>control.turn===0); assert.equal(await page.locator('#input-keyboard').isDisabled(),true); await setArmed(false); await page.locator('#input-keyboard').click(); await setArmed(true); await page.keyboard.down('w');await page.waitForFunction(()=>control.forward>.9);await page.keyboard.up('w'); await page.waitForFunction(()=>control.forward===0); await page.evaluate(()=>{Object.assign(testBridge.state,{connected:false,armed_known:false});render(testBridge.state)}); assert.equal(await page.evaluate(()=>canDrive()),false); await setArmed(false); const deckLayout=await page.evaluate(()=>{ const battery=document.querySelector('.battery-card'),sidebar=document.querySelector('.sidebar-deck'),actions=document.querySelector('.activity-actions'),heading=document.querySelector('.activity-heading'); return {last:battery===document.querySelector('.telemetry-zone').lastElementChild,title:heading.textContent, actionHeight:actions.querySelector('button').getBoundingClientRect().height, batteryHeight:battery.getBoundingClientRect().height,connectHeight:document.querySelector('#connect').getBoundingClientRect().height, bottomGap:sidebar.getBoundingClientRect().bottom-battery.getBoundingClientRect().bottom, overlay:getComputedStyle(document.querySelector('.battery-copy')).zIndex, actionRightGap:actions.getBoundingClientRect().right-actions.lastElementChild.getBoundingClientRect().right, sidebarPadding:getComputedStyle(sidebar).paddingTop, workspacePadding:getComputedStyle(document.querySelector('.controls-workspace')).paddingTop}; }); assert.equal(deckLayout.last,true);assert.equal(deckLayout.title,'Activity:'); assert.equal(deckLayout.actionHeight,32);assert.equal(deckLayout.batteryHeight,62);assert.equal(deckLayout.connectHeight,62); assert(deckLayout.bottomGap>=10&&deckLayout.bottomGap<=24);assert.equal(deckLayout.overlay,'1'); assert.equal(deckLayout.actionRightGap,0);assert.equal(deckLayout.sidebarPadding,deckLayout.workspacePadding); await page.screenshot({path:path.join(output,'ui-cleanup.png')}); assert.deepEqual(errors,[]); console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, five dialogs at two window sizes; controller conditioning/read-only graphics, actual bridge values, mapping, focus/unplug/identity safety and dock filtering; mode locking during hold/armed/pending/unknown states, centered SVG close icons and bottom battery layout.'); }catch(error){ const page=browser.contexts()[0]?.pages()[0]; if(page){ await page.screenshot({path:path.join(output,'failure.png')}); console.error(await page.evaluate(()=>[...document.querySelectorAll('input')].filter(el=>!el.checkValidity()).map(el=>({name:el.name,value:el.value,error:el.validationMessage})))); } throw error; }finally{await browser.close();await new Promise(resolve=>server.close(resolve))} } main().catch(error=>{console.error(error);server.close();process.exitCode=1});