Files
2026-10-02 08:11:25 +02:00

271 lines
22 KiB
JavaScript

/* 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);
const batteryReadout=await page.locator('.battery-card').evaluate(el=>{
const percent=el.querySelector('#battery-percent'),voltage=el.querySelector('#battery-title');
return {percent:percent.textContent,voltage:voltage.textContent,border:getComputedStyle(el).borderTopWidth,
mainSize:parseFloat(getComputedStyle(percent).fontSize),voltageSize:parseFloat(getComputedStyle(voltage).fontSize)};
});
assert.match(batteryReadout.percent,/^\d+%$/);assert.match(batteryReadout.voltage,/^\d+\.\d{2} V$/);
assert.equal(batteryReadout.border,'0px');assert(batteryReadout.mainSize>batteryReadout.voltageSize);
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});